Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c72f852a55 | ||
|
|
99779ecc08 | ||
|
|
c4b8e66206 | ||
|
|
ca6407e0c0 | ||
|
|
dff32a97ee | ||
|
|
d5c528fead | ||
|
|
38f39a10ae | ||
|
|
48e023ba25 | ||
|
|
011ec84f23 |
@@ -131,8 +131,12 @@ jobs:
|
|||||||
python-version: "3.11"
|
python-version: "3.11"
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
|
# semgrep épinglé (BUG-091) : la série 1.175+ embarque des libs natives
|
||||||
|
# compilées pour x86-64-v2, refusées par le CPU du runner Gitea
|
||||||
|
# (« CPU ISA level is lower than required » → exit 127). 1.174.0 est la
|
||||||
|
# dernière version qui s'exécute sur ce runner.
|
||||||
run: |
|
run: |
|
||||||
pip install bandit pip-audit semgrep
|
pip install bandit pip-audit semgrep==1.174.0
|
||||||
pip install -r backend/requirements.txt
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
- name: Bandit (SAST, bloquant — #87)
|
- name: Bandit (SAST, bloquant — #87)
|
||||||
|
|||||||
+88
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
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
|
> **En cours de développement** : les changements à venir sont listés dans la section
|
||||||
> [Unreleased](#unreleased). La dernière version livrée est **2.32.0**.
|
> [Unreleased](#unreleased). La dernière version livrée est **2.39.2**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,93 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.39.2] — 2026-09-28
|
||||||
|
|
||||||
|
### Correction
|
||||||
|
|
||||||
|
- **BUG-091 — le job CI `security` refusait de démarrer semgrep.** Le binaire
|
||||||
|
`semgrep` non épinglé s'est mis à jour vers la série 1.175+, dont les
|
||||||
|
bibliothèques natives sont compilées pour x86-64-v2 : le CPU du runner
|
||||||
|
Gitea les refuse (« CPU ISA level is lower than required », exit 127) et
|
||||||
|
le job échoue sans rapport avec le code analysé. `semgrep==1.174.0` est
|
||||||
|
désormais épinglé dans le job (dernière version exécutable sur ce
|
||||||
|
runner) ; bandit et pip-audit restent flottants.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.39.1] — 2026-09-28
|
||||||
|
|
||||||
|
### Ajouté
|
||||||
|
|
||||||
|
- **#153 A6 — l'assistant IA sait lire et modifier les classeurs existants.**
|
||||||
|
Quatre nouveaux outils dans `backend/tools/spreadsheets.py` :
|
||||||
|
`list_xlsx_sheets` (noms de feuilles + dimensions), `xlsx_to_markdown`
|
||||||
|
(tableau plafonné injecté au contexte du modèle), `update_xlsx_cells`
|
||||||
|
(édition par lots passant par le service gardé) et `append_xlsx_rows`
|
||||||
|
(ajout de lignes en fin de feuille). Les mutations demandent confirmation
|
||||||
|
et rafraîchissent la visionneuse (`obsigate:file-written`).
|
||||||
|
- **#153 A7 — navigation clavier et barre de formule dans la visionneuse.**
|
||||||
|
`Tab`/`Maj+Tab` circulent entre les cellules, flèches et `Entrée`/
|
||||||
|
`Maj+Entrée` (multiligne) fonctionnent comme dans un tableur, la cellule
|
||||||
|
active est nommée en A1 dans la barre de formule, une plage se copie,
|
||||||
|
et le focus reste visible et tactile (≥ 44 px, couvert par les E2E mobiles).
|
||||||
|
- **#153 A13 — tri, filtre, recherche et export CSV dans la feuille.**
|
||||||
|
Tri ascendant/descendant par colonne, filtre de lignes, recherche
|
||||||
|
suivant/précédent (respect de casse optionnel) et export CSV de la feuille :
|
||||||
|
toutes des opérations d'**affichage**, le classeur n'est jamais réécrit.
|
||||||
|
- **#153 A14 — structure du classeur éditable depuis la visionneuse.**
|
||||||
|
Ajout, renommage, duplication et suppression de feuilles ; insertion et
|
||||||
|
suppression de lignes/colonnes autour de la cellule active, via le menu
|
||||||
|
Structure et `PUT /api/file/{vault}/xlsx/structure` — mêmes garde-fous
|
||||||
|
(backup atomique, verrou, confirmation) que l'édition de cellules.
|
||||||
|
- **#153 A15 — styles, fusions et volets figés affichés fidèlement.**
|
||||||
|
La lecture rend les couleurs de police et de fond, le gras/italique/
|
||||||
|
souligné, les alignements, les plages fusionnées et l'ancre des volets
|
||||||
|
figés ; un format de nombre personnalisé est signalé par une police à
|
||||||
|
chasse fixe. Une seule charge du classeur (mode normal) suffit pour toutes
|
||||||
|
les feuilles, y compris celles rendues par fenêtres.
|
||||||
|
- **#153 A16 — formats tableur additionnels.** `.xlsm` éditable avec
|
||||||
|
**macros préservées** (`keep_vba`), `.xls` et `.ods` en **lecture seule**
|
||||||
|
(xlrd / odfpy), `.csv` édité comme un tableur et réécrit au format
|
||||||
|
RFC 4180 (`PUT …/csv/save`). Dépendances : `xlrd==2.0.2`,
|
||||||
|
`odfpy==1.4.1` dans `backend/requirements.txt`.
|
||||||
|
- **#153 A17 — tableau de bord du classeur.** Un panneau de la visionneuse
|
||||||
|
liste les plages nommées (portée classeur ou feuille), signale la présence
|
||||||
|
de graphiques et de tableaux croisés (analyse des parties OPC, sans
|
||||||
|
recharger le fichier), donne les statistiques par feuille (cellules,
|
||||||
|
lignes, colonnes, formules, valeurs numériques) et huit KPI extraits de la
|
||||||
|
première zone de données — endpoint `GET /api/file/{vault}/xlsx/dashboard`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.39.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.38.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.37.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.36.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.35.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.34.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.33.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.32.0] — 2026-09-28
|
## [2.32.0] — 2026-09-28
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+4
-4
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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
|
- **🖼️ 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
|
- **🎬 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)
|
- **🎨 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` 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. 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)`
|
- **📊 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
|
||||||
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
- **🎨 Syntax highlight** : Coloration syntaxique des blocs de code
|
||||||
- **🌓 Thème clair/sombre** : Toggle persisté en localStorage
|
- **🌓 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
|
- **📡 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
|
## 📝 Changelog
|
||||||
|
|
||||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.32.0).
|
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.39.2).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.32.0 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.39.2 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](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
|
- **🖼️ 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
|
- **🎬 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)
|
- **🎨 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` 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. 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
|
- **📊 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
|
||||||
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
- **🎨 Syntax Highlight** : Syntax highlighting for code blocks
|
||||||
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
- **🌓 Light/Dark Theme** : Toggle persisted in localStorage
|
||||||
- **📡 Real-time Sync** : Automatic file monitoring via watchdog with incremental index updates
|
- **📡 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
|
## 📝 Changelog
|
||||||
|
|
||||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.32.0).
|
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.39.2).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.32.0 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.39.2 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pywebpush>=2.3.0
|
|||||||
mcp==1.28.1
|
mcp==1.28.1
|
||||||
sse-starlette==2.1.3
|
sse-starlette==2.1.3
|
||||||
openpyxl>=3.1
|
openpyxl>=3.1
|
||||||
|
xlrd==2.0.2
|
||||||
|
odfpy==1.4.1
|
||||||
python-docx>=1.1
|
python-docx>=1.1
|
||||||
reportlab>=4.0
|
reportlab>=4.0
|
||||||
pillow>=10.0
|
pillow>=10.0
|
||||||
|
|||||||
+106
-21
@@ -38,11 +38,12 @@ from backend.schemas import (
|
|||||||
BrowseResponse,
|
BrowseResponse,
|
||||||
FileContentResponse,
|
FileContentResponse,
|
||||||
FileRawResponse,
|
FileRawResponse,
|
||||||
|
XlsxDashboardResponse,
|
||||||
XlsxSheetWindowResponse,
|
XlsxSheetWindowResponse,
|
||||||
)
|
)
|
||||||
from backend.services.files import read_raw_file
|
from backend.services.files import read_raw_file
|
||||||
from backend.services.paths import resolve_safe_path
|
from backend.services.paths import resolve_safe_path
|
||||||
from backend.services.vaults import browse_directory
|
from backend.services.vaults import browse_directory, get_vault_root
|
||||||
|
|
||||||
logger = logging.getLogger("obsigate")
|
logger = logging.getLogger("obsigate")
|
||||||
|
|
||||||
@@ -179,6 +180,47 @@ async def api_file_backlinks(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/api/file/{vault_name}/xlsx/dashboard", response_model=XlsxDashboardResponse
|
||||||
|
)
|
||||||
|
def api_file_xlsx_dashboard(
|
||||||
|
vault_name: str,
|
||||||
|
path: str = Query(..., description="Relative path to the .xlsx workbook"),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Return the dashboard metadata of an .xlsx workbook (#153 A17).
|
||||||
|
|
||||||
|
Named ranges (workbook- or sheet-scoped), chart/pivot object counts and
|
||||||
|
per-sheet KPI stats (non-empty cells, rows/cols coverage, formulas,
|
||||||
|
numeric cells, first numeric values as KPI cards). Read-only, bounded by
|
||||||
|
the 500x40 render caps; never raises for an unreadable workbook — an
|
||||||
|
empty payload comes back and the viewer hides the panel.
|
||||||
|
"""
|
||||||
|
if not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
_vault_root = get_vault_root(vault_name)
|
||||||
|
file_path = resolve_safe_path(_vault_root, path)
|
||||||
|
if not file_path.is_file():
|
||||||
|
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||||
|
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
|
||||||
|
)
|
||||||
|
|
||||||
|
from backend.xlsx_reader import read_workbook_dashboard
|
||||||
|
|
||||||
|
try:
|
||||||
|
dashboard = read_workbook_dashboard(file_path)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"XLSX dashboard read error for {path}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||||
|
return {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
**dashboard,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
|
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
|
||||||
)
|
)
|
||||||
@@ -309,6 +351,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
try:
|
try:
|
||||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||||
|
|
||||||
|
# #153 A15 — every sheet dict already carries its styles, aligns,
|
||||||
|
# merges and freeze anchor (read_workbook_meta, one normal-mode
|
||||||
|
# load inside render_sheets).
|
||||||
sheets = render_sheets(file_path)
|
sheets = render_sheets(file_path)
|
||||||
size = file_path.stat().st_size
|
size = file_path.stat().st_size
|
||||||
return {
|
return {
|
||||||
@@ -443,27 +488,67 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
logger.error(f"Unexpected error reading file {path}: {e}")
|
logger.error(f"Unexpected error reading file {path}: {e}")
|
||||||
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
|
raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}")
|
||||||
|
|
||||||
# === CSV: render as HTML table ===
|
# === Excel .xlsm: same editable viewer as .xlsx, macros preserved on save ===
|
||||||
|
if ext == ".xlsm":
|
||||||
|
try:
|
||||||
|
from backend.xlsx_reader import inspect_workbook, render_sheets
|
||||||
|
|
||||||
|
sheets = render_sheets(file_path)
|
||||||
|
size = file_path.stat().st_size
|
||||||
|
return {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
"title": file_path.name,
|
||||||
|
"tags": [],
|
||||||
|
"frontmatter": {},
|
||||||
|
"html": sheets[0]["html"] if sheets else "",
|
||||||
|
"raw_length": size,
|
||||||
|
"extension": ext,
|
||||||
|
"is_markdown": False,
|
||||||
|
"is_xlsx": True,
|
||||||
|
"xlsx_sheets": sheets,
|
||||||
|
# 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": [],
|
||||||
|
"unsupported": False,
|
||||||
|
"size_bytes": size,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"XLSX read error for {path}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
|
||||||
|
|
||||||
|
# === Legacy/ODF spreadsheets (.xls, .ods): read-only table view ===
|
||||||
|
if ext in (".xls", ".ods"):
|
||||||
|
try:
|
||||||
|
from backend.xlsx_reader import render_legacy_workbook
|
||||||
|
|
||||||
|
sheets = render_legacy_workbook(file_path, ext)
|
||||||
|
size = file_path.stat().st_size
|
||||||
|
return {
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": path,
|
||||||
|
"title": file_path.name,
|
||||||
|
"tags": [],
|
||||||
|
"frontmatter": {},
|
||||||
|
"html": sheets[0]["html"] if sheets else "",
|
||||||
|
"raw_length": size,
|
||||||
|
"extension": ext,
|
||||||
|
"is_markdown": False,
|
||||||
|
"is_xlsx": True,
|
||||||
|
"xlsx_readonly": True,
|
||||||
|
"xlsx_sheets": sheets,
|
||||||
|
"unsupported": False,
|
||||||
|
"size_bytes": size,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Spreadsheet read error for {path}: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=f"Error reading spreadsheet: {e!s}")
|
||||||
|
|
||||||
|
# === CSV: spreadsheet-style table (same shape as the xlsx viewer) ===
|
||||||
if ext == ".csv":
|
if ext == ".csv":
|
||||||
import csv
|
from backend.xlsx_reader import render_csv_table
|
||||||
import io as csv_io
|
|
||||||
reader = csv.reader(csv_io.StringIO(raw))
|
html = render_csv_table(raw)
|
||||||
rows = list(reader)
|
|
||||||
if not rows:
|
|
||||||
html = "<p><em>Fichier CSV vide</em></p>"
|
|
||||||
else:
|
|
||||||
headers = rows[0]
|
|
||||||
data_rows = rows[1:]
|
|
||||||
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
|
|
||||||
for h in headers:
|
|
||||||
html += f"<th>{h}</th>"
|
|
||||||
html += "</tr></thead><tbody>"
|
|
||||||
for row in data_rows:
|
|
||||||
html += "<tr>"
|
|
||||||
for cell in row:
|
|
||||||
html += f"<td>{cell}</td>"
|
|
||||||
html += "</tr>"
|
|
||||||
html += "</tbody></table></div>"
|
|
||||||
return {
|
return {
|
||||||
"vault": vault_name, "path": path,
|
"vault": vault_name, "path": path,
|
||||||
"title": file_path.name, "tags": [], "frontmatter": {},
|
"title": file_path.name, "tags": [], "frontmatter": {},
|
||||||
|
|||||||
@@ -64,12 +64,18 @@ from backend.services.mutations import (
|
|||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
move_path as service_move_path,
|
move_path as service_move_path,
|
||||||
)
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
mutate_xlsx_structure as service_mutate_xlsx_structure,
|
||||||
|
)
|
||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
rename_directory as service_rename_directory,
|
rename_directory as service_rename_directory,
|
||||||
)
|
)
|
||||||
from backend.services.mutations import (
|
from backend.services.mutations import (
|
||||||
rename_file as service_rename_file,
|
rename_file as service_rename_file,
|
||||||
)
|
)
|
||||||
|
from backend.services.mutations import (
|
||||||
|
save_csv_cells as service_save_csv_cells,
|
||||||
|
)
|
||||||
from backend.share import update_shares_after_rename
|
from backend.share import update_shares_after_rename
|
||||||
from backend.sse import sse_manager
|
from backend.sse import sse_manager
|
||||||
from backend.webhooks import dispatch_webhooks
|
from backend.webhooks import dispatch_webhooks
|
||||||
@@ -174,6 +180,96 @@ def api_file_xlsx_save(
|
|||||||
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
|
||||||
|
def api_file_csv_save(
|
||||||
|
vault_name: str,
|
||||||
|
path: str = Query(..., description="Relative path to the .csv file"),
|
||||||
|
body: dict = Body(
|
||||||
|
...,
|
||||||
|
description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)',
|
||||||
|
),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Apply A1-addressed cell edits to a ``.csv`` file (#153 A16).
|
||||||
|
|
||||||
|
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 not check_vault_access(vault_name, current_user):
|
||||||
|
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||||
|
|
||||||
|
cells = body.get("cells")
|
||||||
|
if not isinstance(cells, dict) or not cells or len(cells) > 500:
|
||||||
|
raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)")
|
||||||
|
for ref, value in cells.items():
|
||||||
|
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)
|
||||||
|
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"]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
|
||||||
|
def api_file_xlsx_structure(
|
||||||
|
vault_name: str,
|
||||||
|
path: str = Query(..., description="Relative path to the .xlsx file"),
|
||||||
|
body: dict = Body(
|
||||||
|
...,
|
||||||
|
description=(
|
||||||
|
'{"actions": [{"op": "sheet_add", "name": "X"}, '
|
||||||
|
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
|
||||||
|
'"force": false}'
|
||||||
|
),
|
||||||
|
),
|
||||||
|
current_user=Depends(require_auth),
|
||||||
|
):
|
||||||
|
"""Apply structural changes to an .xlsx workbook (#153 A14).
|
||||||
|
|
||||||
|
``actions`` is an ordered list applied in one locked, atomic rewrite:
|
||||||
|
``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename``
|
||||||
|
(``from``/``to``), ``sheet_delete`` (refused on the last sheet),
|
||||||
|
``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/
|
||||||
|
``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``).
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
vault_name: Name of the vault.
|
||||||
|
path: Relative path to the ``.xlsx`` file.
|
||||||
|
body: JSON body with ``actions`` (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}'")
|
||||||
|
|
||||||
|
actions = body.get("actions")
|
||||||
|
if not isinstance(actions, list) or not actions or len(actions) > 50:
|
||||||
|
raise HTTPException(status_code=400, detail="Actions 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")
|
||||||
|
|
||||||
|
result = service_mutate_xlsx_structure(
|
||||||
|
vault_name, path, actions, force=raw_force
|
||||||
|
)
|
||||||
|
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"])}
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
|
||||||
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
|
||||||
"""Delete a file from the vault.
|
"""Delete a file from the vault.
|
||||||
|
|||||||
@@ -285,6 +285,14 @@ class FileContentResponse(BaseModel):
|
|||||||
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
media_mime: str | None = Field(default=None, description="MIME type for audio/video files")
|
||||||
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
||||||
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
||||||
|
xlsx_readonly: bool | None = Field(
|
||||||
|
default=None,
|
||||||
|
description=(
|
||||||
|
"True when the table is served read-only (.xls/.ods, #153 A16): "
|
||||||
|
"the viewer hides the editable-cell wiring and the save/structure "
|
||||||
|
"endpoints refuse the format"
|
||||||
|
),
|
||||||
|
)
|
||||||
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
description=(
|
description=(
|
||||||
@@ -310,6 +318,43 @@ class FileContentResponse(BaseModel):
|
|||||||
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
||||||
|
|
||||||
|
|
||||||
|
class XlsxDashboardNamedRange(BaseModel):
|
||||||
|
"""One named range of a workbook (#153 A17)."""
|
||||||
|
|
||||||
|
name: str = Field(description="Range name as declared in the workbook")
|
||||||
|
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
|
||||||
|
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
|
||||||
|
|
||||||
|
|
||||||
|
class XlsxDashboardSheetKpi(BaseModel):
|
||||||
|
"""One KPI card of a sheet dashboard (#153 A17)."""
|
||||||
|
|
||||||
|
label: str = Field(description="A1 reference of the numeric cell")
|
||||||
|
value: float = Field(description="Numeric value of the cell")
|
||||||
|
|
||||||
|
|
||||||
|
class XlsxDashboardSheet(BaseModel):
|
||||||
|
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
|
||||||
|
|
||||||
|
name: str = Field(description="Sheet name")
|
||||||
|
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
|
||||||
|
rows: int = Field(description="Rows carrying at least one non-empty cell")
|
||||||
|
cols: int = Field(description="Columns carrying at least one non-empty cell")
|
||||||
|
formulas: int = Field(description="Cells whose value is a formula")
|
||||||
|
numeric: int = Field(description="Cells carrying a numeric value")
|
||||||
|
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
|
||||||
|
|
||||||
|
|
||||||
|
class XlsxDashboardResponse(BaseModel):
|
||||||
|
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
|
||||||
|
|
||||||
|
vault: str = Field(description="Vault name")
|
||||||
|
path: str = Field(description="Relative file path within the vault")
|
||||||
|
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
|
||||||
|
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
|
||||||
|
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
|
||||||
|
|
||||||
|
|
||||||
class XlsxSheetWindowResponse(BaseModel):
|
class XlsxSheetWindowResponse(BaseModel):
|
||||||
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
|
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
|
||||||
|
|
||||||
|
|||||||
@@ -387,9 +387,9 @@ def edit_xlsx_cells(
|
|||||||
status=404,
|
status=404,
|
||||||
details={"vault": vault_name, "path": path},
|
details={"vault": vault_name, "path": path},
|
||||||
)
|
)
|
||||||
if file_path.suffix.lower() != ".xlsx":
|
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
|
||||||
raise ServiceError(
|
raise ServiceError(
|
||||||
f"Not an .xlsx file: {path}", code="invalid", status=400
|
f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400
|
||||||
)
|
)
|
||||||
if not cells:
|
if not cells:
|
||||||
raise ServiceError("No cells to update", code="invalid", status=400)
|
raise ServiceError("No cells to update", code="invalid", status=400)
|
||||||
@@ -399,7 +399,10 @@ def edit_xlsx_cells(
|
|||||||
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||||
)
|
)
|
||||||
|
|
||||||
if not force:
|
# #153 A16 — the lossy gate is skipped for .xlsm: the save re-serializes
|
||||||
|
# with keep_vba=True, so the macro project (the only extra part a .xlsm
|
||||||
|
# carries) survives and nothing is dropped.
|
||||||
|
if not force and file_path.suffix.lower() != ".xlsm":
|
||||||
from backend.xlsx_reader import inspect_workbook
|
from backend.xlsx_reader import inspect_workbook
|
||||||
|
|
||||||
lossy = inspect_workbook(file_path)
|
lossy = inspect_workbook(file_path)
|
||||||
@@ -415,8 +418,11 @@ def edit_xlsx_cells(
|
|||||||
with _xlsx_write_lock(str(file_path)):
|
with _xlsx_write_lock(str(file_path)):
|
||||||
from openpyxl import load_workbook
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
# #153 A16 — .xlsm round-trips with keep_vba=True so the macro
|
||||||
|
# project survives the save (the endpoint's lossy probe is empty
|
||||||
|
# for .xlsm on purpose).
|
||||||
try:
|
try:
|
||||||
wb = load_workbook(file_path)
|
wb = load_workbook(file_path, keep_vba=file_path.suffix.lower() == ".xlsm")
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
raise ServiceError(
|
raise ServiceError(
|
||||||
f"Cannot open workbook: {exc}", code="invalid", status=400
|
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||||
@@ -455,6 +461,284 @@ def edit_xlsx_cells(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def mutate_xlsx_structure(
|
||||||
|
vault_name: str,
|
||||||
|
path: str,
|
||||||
|
actions: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
backup: bool = True,
|
||||||
|
force: bool = False,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Apply structural changes to an ``.xlsx`` workbook (#153 A14).
|
||||||
|
|
||||||
|
``actions`` is an ordered list — the workbook is loaded once and every
|
||||||
|
action is applied in sequence inside the same per-file lock and the same
|
||||||
|
atomic replace, so a half-applied batch can never reach the disk:
|
||||||
|
|
||||||
|
* ``{"op": "sheet_add", "name": "X", "at": 1}`` — new sheet (at =
|
||||||
|
optional 0-based position);
|
||||||
|
* ``{"op": "sheet_rename", "from": "X", "to": "Y"}``;
|
||||||
|
* ``{"op": "sheet_delete", "name": "X"}`` — refused when it is the
|
||||||
|
last sheet (an openpyxl workbook must keep one);
|
||||||
|
* ``{"op": "sheet_duplicate", "name": "X", "as": "Y"}`` — values,
|
||||||
|
styles and merged ranges are copied (not the data-dependent objects);
|
||||||
|
* ``{"op": "row_insert"|"row_delete"|"col_insert"|"col_delete",
|
||||||
|
"sheet": "X", "at": N, "count": k}`` — 1-based position, default 1.
|
||||||
|
|
||||||
|
All of it rides the same guards as the cell edits (P0): per-file lock,
|
||||||
|
``.tmp`` + ``os.replace`` atomic write and the ``force`` gate on lossy
|
||||||
|
round-trips. The UI proposes these actions with an explicit confirmation
|
||||||
|
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||||
|
"""
|
||||||
|
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 not actions or len(actions) > 50:
|
||||||
|
raise ServiceError(
|
||||||
|
"Invalid actions (1 to 50 per request)", code="invalid", status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
from backend.xlsx_reader import inspect_workbook
|
||||||
|
|
||||||
|
lossy = inspect_workbook(file_path)
|
||||||
|
if lossy:
|
||||||
|
raise ServiceError(
|
||||||
|
"Restructuring 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},
|
||||||
|
)
|
||||||
|
|
||||||
|
with _xlsx_write_lock(str(file_path)):
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.worksheet.copier import WorksheetCopy
|
||||||
|
|
||||||
|
try:
|
||||||
|
wb = load_workbook(file_path)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ServiceError(
|
||||||
|
f"Cannot open workbook: {exc}", code="invalid", status=400
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
rel_path = _rel(root, file_path)
|
||||||
|
applied: list[str] = []
|
||||||
|
try:
|
||||||
|
for i, action in enumerate(actions):
|
||||||
|
op = action.get("op")
|
||||||
|
try:
|
||||||
|
if op == "sheet_add":
|
||||||
|
name = str(action.get("name", "")).strip()
|
||||||
|
if not name or name in wb.sheetnames:
|
||||||
|
raise ServiceError(
|
||||||
|
f"Nom de feuille invalide ou déjà pris: {name!r}",
|
||||||
|
code="invalid", status=400,
|
||||||
|
)
|
||||||
|
ws = wb.create_sheet(name[:31])
|
||||||
|
at = action.get("at")
|
||||||
|
# create_sheet appends at the end: shift left by the
|
||||||
|
# distance between the last index and the target.
|
||||||
|
if isinstance(at, int) and 0 <= at < len(wb.sheetnames):
|
||||||
|
wb.move_sheet(ws, offset=at - (len(wb.sheetnames) - 1))
|
||||||
|
applied.append(f"sheet_add:{ws.title}")
|
||||||
|
elif op == "sheet_rename":
|
||||||
|
src, dst = str(action.get("from", "")), str(action.get("to", "")).strip()
|
||||||
|
if src not in wb.sheetnames or not dst or dst in wb.sheetnames:
|
||||||
|
raise ServiceError(
|
||||||
|
f"Renommage invalide: {src!r} -> {dst!r}",
|
||||||
|
code="invalid", status=400,
|
||||||
|
)
|
||||||
|
wb[src].title = dst[:31]
|
||||||
|
applied.append(f"sheet_rename:{src}->{dst}")
|
||||||
|
elif op == "sheet_delete":
|
||||||
|
name = str(action.get("name", ""))
|
||||||
|
if name not in wb.sheetnames:
|
||||||
|
raise ServiceError(
|
||||||
|
f"Feuille introuvable: {name}", code="invalid", status=400
|
||||||
|
)
|
||||||
|
if len(wb.sheetnames) <= 1:
|
||||||
|
raise ServiceError(
|
||||||
|
"Impossible de supprimer la dernière feuille",
|
||||||
|
code="invalid", status=400,
|
||||||
|
)
|
||||||
|
del wb[name]
|
||||||
|
applied.append(f"sheet_delete:{name}")
|
||||||
|
elif op == "sheet_duplicate":
|
||||||
|
name = str(action.get("name", ""))
|
||||||
|
new_name = str(action.get("as", "")).strip()
|
||||||
|
if name not in wb.sheetnames or not new_name or new_name in wb.sheetnames:
|
||||||
|
raise ServiceError(
|
||||||
|
f"Duplication invalide: {name!r} -> {new_name!r}",
|
||||||
|
code="invalid", status=400,
|
||||||
|
)
|
||||||
|
# WorksheetCopy is the documented dup path (openpyxl
|
||||||
|
# 3.1); it copies values, styles and merges — not
|
||||||
|
# charts/images, which openpyxl itself cannot clone.
|
||||||
|
copy = wb.create_sheet(new_name[:31])
|
||||||
|
WorksheetCopy(wb[name], copy).copy_worksheet()
|
||||||
|
applied.append(f"sheet_duplicate:{name}->{copy.title}")
|
||||||
|
elif op in ("row_insert", "row_delete", "col_insert", "col_delete"):
|
||||||
|
sheet = str(action.get("sheet", ""))
|
||||||
|
if sheet not in wb.sheetnames:
|
||||||
|
raise ServiceError(
|
||||||
|
f"Feuille introuvable: {sheet}", code="invalid", status=400
|
||||||
|
)
|
||||||
|
ws = wb[sheet]
|
||||||
|
at = action.get("at", 1)
|
||||||
|
count = action.get("count", 1)
|
||||||
|
if not isinstance(at, int) or at < 1 or not isinstance(count, int) or count < 1:
|
||||||
|
raise ServiceError(
|
||||||
|
"Position 'at' / 'count' invalides", code="invalid", status=400
|
||||||
|
)
|
||||||
|
if op == "row_insert":
|
||||||
|
ws.insert_rows(at, count)
|
||||||
|
elif op == "row_delete":
|
||||||
|
ws.delete_rows(at, count)
|
||||||
|
elif op == "col_insert":
|
||||||
|
ws.insert_cols(at, count)
|
||||||
|
else:
|
||||||
|
ws.delete_cols(at, count)
|
||||||
|
applied.append(f"{op}:{sheet}@{at}x{count}")
|
||||||
|
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()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"XLSX structure: {vault_name}/{rel_path} {applied}"
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": rel_path,
|
||||||
|
"applied": applied,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def save_csv_cells(
|
||||||
|
vault_name: str,
|
||||||
|
path: str,
|
||||||
|
cells: dict[str, Any],
|
||||||
|
*,
|
||||||
|
backup: bool = True,
|
||||||
|
) -> 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).
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict``
|
||||||
|
(409, concurrent write) or ``invalid`` (400) for a bad reference.
|
||||||
|
"""
|
||||||
|
import csv as csv_mod
|
||||||
|
import io as io_mod
|
||||||
|
|
||||||
|
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() != ".csv":
|
||||||
|
raise ServiceError(f"Not a .csv file: {path}", code="invalid", status=400)
|
||||||
|
if not cells:
|
||||||
|
raise ServiceError("No cells to update", code="invalid", status=400)
|
||||||
|
for ref in cells:
|
||||||
|
if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref):
|
||||||
|
raise ServiceError(
|
||||||
|
f"Invalid cell reference: {ref!r}", code="invalid", status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
raw = file_path.read_text(encoding="utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
rows = list(csv_mod.reader(io_mod.StringIO(raw)))
|
||||||
|
except csv_mod.Error:
|
||||||
|
rows = [[line] for line in raw.splitlines()]
|
||||||
|
|
||||||
|
def _col_num(ref: str) -> int:
|
||||||
|
letters = ref.rstrip("0123456789").upper()
|
||||||
|
n = 0
|
||||||
|
for ch in letters:
|
||||||
|
n = n * 26 + (ord(ch) - ord("A") + 1)
|
||||||
|
return n
|
||||||
|
|
||||||
|
def _row_num(ref: str) -> int:
|
||||||
|
return int(ref[len(ref.rstrip("0123456789")):])
|
||||||
|
|
||||||
|
for ref, value in cells.items():
|
||||||
|
r, c = _row_num(ref), _col_num(ref)
|
||||||
|
while len(rows) < r:
|
||||||
|
rows.append([])
|
||||||
|
row = rows[r - 1]
|
||||||
|
while len(row) < c:
|
||||||
|
row.append("")
|
||||||
|
row[c - 1] = "" if value is None else str(value)
|
||||||
|
|
||||||
|
rel_path = _rel(root, file_path)
|
||||||
|
if backup:
|
||||||
|
create_backup(file_path, vault_name, rel_path)
|
||||||
|
|
||||||
|
buf = io_mod.StringIO()
|
||||||
|
csv_mod.writer(buf, 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")
|
||||||
|
os.replace(tmp_path, file_path)
|
||||||
|
except Exception:
|
||||||
|
tmp_path.unlink(missing_ok=True)
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}")
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"vault": vault_name,
|
||||||
|
"path": rel_path,
|
||||||
|
"size": len(cells),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def append_to_file(
|
def append_to_file(
|
||||||
vault_name: str,
|
vault_name: str,
|
||||||
path: str,
|
path: str,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from backend.tools import connected as _connected # noqa: F401 (registers conn
|
|||||||
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
from backend.tools import crawler as _crawler # noqa: F401 (registers the site crawler)
|
||||||
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
from backend.tools import documents as _documents # noqa: F401 (registers document tools)
|
||||||
from backend.tools import service as _service # noqa: F401 (registers tools)
|
from backend.tools import service as _service # noqa: F401 (registers tools)
|
||||||
|
from backend.tools import spreadsheets as _spreadsheets # noqa: F401 (registers existing-workbook tools #153 A6)
|
||||||
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
from backend.tools import web as _web # noqa: F401 (registers web tools)
|
||||||
from backend.tools.context import (
|
from backend.tools.context import (
|
||||||
ToolConfirmationRequired,
|
ToolConfirmationRequired,
|
||||||
|
|||||||
@@ -52,6 +52,10 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
|
|||||||
"git_search_issues": ("git_issues", "query"),
|
"git_search_issues": ("git_issues", "query"),
|
||||||
"git_get_file": ("git_file", "path"),
|
"git_get_file": ("git_file", "path"),
|
||||||
"create_xlsx": ("xlsx_create", "path"),
|
"create_xlsx": ("xlsx_create", "path"),
|
||||||
|
"list_xlsx_sheets": ("xlsx_sheets", "path"),
|
||||||
|
"xlsx_to_markdown": ("xlsx_read", "path"),
|
||||||
|
"update_xlsx_cells": ("xlsx_update", "path"),
|
||||||
|
"append_xlsx_rows": ("xlsx_append", "path"),
|
||||||
"create_docx": ("docx_create", "path"),
|
"create_docx": ("docx_create", "path"),
|
||||||
"create_csv": ("csv_create", "path"),
|
"create_csv": ("csv_create", "path"),
|
||||||
"create_pdf": ("pdf_create", "path"),
|
"create_pdf": ("pdf_create", "path"),
|
||||||
|
|||||||
@@ -315,6 +315,61 @@ class DocxInput(BaseModel):
|
|||||||
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
overwrite: bool = Field(True, description="Replace an existing file (with backup)")
|
||||||
|
|
||||||
|
|
||||||
|
class ListXlsxSheetsInput(BaseModel):
|
||||||
|
"""List the sheets of an existing .xlsx workbook (#153 A6)."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||||
|
|
||||||
|
|
||||||
|
class XlsxToMarkdownInput(BaseModel):
|
||||||
|
"""Read one sheet of an existing .xlsx workbook as markdown (#153 A6)."""
|
||||||
|
|
||||||
|
vault: str = Field(..., description="Vault name")
|
||||||
|
path: str = Field(..., description="Vault-relative path of the .xlsx file")
|
||||||
|
sheet: str = Field(
|
||||||
|
"", description="Sheet name (empty = the first/active 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")
|
||||||
|
cells: dict[str, str | int | float | bool | None] = Field(
|
||||||
|
..., description="A1 reference -> new value (max 500 per call)"
|
||||||
|
)
|
||||||
|
allow_formula: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||||
|
)
|
||||||
|
force: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AppendXlsxRowsInput(BaseModel):
|
||||||
|
"""Append rows at the end of a sheet of an existing .xlsx (#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 extend")
|
||||||
|
rows: list[list[str | int | float | bool | None]] = Field(
|
||||||
|
..., description="Rows of cell values, appended below the last used row (max 500)"
|
||||||
|
)
|
||||||
|
allow_formula: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Store '='/'@' values as real formulas (off by default, DDE guard)",
|
||||||
|
)
|
||||||
|
force: bool = Field(
|
||||||
|
False,
|
||||||
|
description="Write even when features openpyxl cannot rewrite would be dropped",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CsvInput(BaseModel):
|
class CsvInput(BaseModel):
|
||||||
"""Create a .csv file in a vault from rows of cells."""
|
"""Create a .csv file in a vault from rows of cells."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,286 @@
|
|||||||
|
"""Spreadsheet tools (#153 A6) — read and mutate existing ``.xlsx`` workbooks.
|
||||||
|
|
||||||
|
Complements :mod:`backend.tools.documents` (``create_xlsx`` creates a *new*
|
||||||
|
file; here the assistant can read and edit one that already exists):
|
||||||
|
|
||||||
|
* ``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.
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from backend.services.errors import ServiceError
|
||||||
|
from backend.services.paths import resolve_safe_path
|
||||||
|
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 (
|
||||||
|
AppendXlsxRowsInput,
|
||||||
|
ListXlsxSheetsInput,
|
||||||
|
UpdateXlsxCellsInput,
|
||||||
|
XlsxToMarkdownInput,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger("obsigate.tools.spreadsheets")
|
||||||
|
|
||||||
|
# xlsx_to_markdown ceiling: a workbook is a data dump, not prose. The table is
|
||||||
|
# for the LLM context, so both axes are bounded (same spirit as A5's index cap).
|
||||||
|
MAX_MD_ROWS = 100
|
||||||
|
MAX_MD_COLS = 20
|
||||||
|
MAX_MD_CHARS = 20_000
|
||||||
|
|
||||||
|
|
||||||
|
def _workbook_path(vault: str, path: str) -> Path:
|
||||||
|
"""Resolve and validate a vault-relative ``.xlsx`` path."""
|
||||||
|
path = (path or "").strip()
|
||||||
|
if not path.lower().endswith(".xlsx"):
|
||||||
|
raise ToolError("Extension attendue : .xlsx", code="invalid_arguments")
|
||||||
|
try:
|
||||||
|
root = get_vault_root(vault)
|
||||||
|
except ServiceError as e:
|
||||||
|
raise ToolError(e.message, code=e.code, details=e.details) from e
|
||||||
|
return resolve_safe_path(root, path)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
file_path = _workbook_path(params.vault, params.path)
|
||||||
|
try:
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"vault": params.vault, "path": params.path, "sheets": sheets}
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
@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."
|
||||||
|
),
|
||||||
|
input_model=XlsxToMarkdownInput,
|
||||||
|
risk=ToolRisk.READ,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
lines: list[str] = []
|
||||||
|
if rows:
|
||||||
|
header = rows[0]
|
||||||
|
lines.append("| " + " | ".join(header) + " |")
|
||||||
|
lines.append("|" + "|".join("---" for _ in header) + "|")
|
||||||
|
for row in rows[1:]:
|
||||||
|
lines.append("| " + " | ".join(row) + " |")
|
||||||
|
table = "\n".join(lines)[:MAX_MD_CHARS]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"vault": params.vault,
|
||||||
|
"path": params.path,
|
||||||
|
"sheet": title,
|
||||||
|
"rows": len(rows),
|
||||||
|
"cols": max((len(r) for r in rows), default=0),
|
||||||
|
"truncated": truncated,
|
||||||
|
"markdown": table,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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…)."
|
||||||
|
),
|
||||||
|
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
|
||||||
|
|
||||||
|
if not params.cells:
|
||||||
|
raise ToolError("Aucune cellule fournie", 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,
|
||||||
|
)
|
||||||
|
except ServiceError as e:
|
||||||
|
raise _map_service_error(e) from e
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"vault": result["vault"],
|
||||||
|
"path": result["path"],
|
||||||
|
"sheet": params.sheet,
|
||||||
|
"cells": len(params.cells),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@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."
|
||||||
|
),
|
||||||
|
input_model=AppendXlsxRowsInput,
|
||||||
|
risk=ToolRisk.WRITE,
|
||||||
|
requires_vault=True,
|
||||||
|
)
|
||||||
|
def append_xlsx_rows(ctx: ToolContext, params: AppendXlsxRowsInput) -> dict[str, Any]:
|
||||||
|
"""Append whole rows below the last used row of the sheet."""
|
||||||
|
from openpyxl import load_workbook
|
||||||
|
from openpyxl.utils import get_column_letter
|
||||||
|
|
||||||
|
from backend.services.mutations import _coerce_xlsx_value, edit_xlsx_cells
|
||||||
|
|
||||||
|
if not params.rows:
|
||||||
|
raise ToolError("Aucune ligne fournie", code="invalid_arguments")
|
||||||
|
if len(params.rows) > 500:
|
||||||
|
raise ToolError("Trop de lignes (max 500)", code="invalid_arguments")
|
||||||
|
|
||||||
|
file_path = _workbook_path(params.vault, params.path)
|
||||||
|
try:
|
||||||
|
wb = load_workbook(str(file_path), read_only=True, data_only=True)
|
||||||
|
try:
|
||||||
|
if params.sheet not in wb.sheetnames:
|
||||||
|
raise ToolError(
|
||||||
|
f"Feuille introuvable: {params.sheet}", code="not_found"
|
||||||
|
)
|
||||||
|
ws = wb[params.sheet]
|
||||||
|
first_free = (ws.max_row or 0) + 1
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
except ServiceError as e:
|
||||||
|
raise _map_service_error(e) from e
|
||||||
|
except ToolError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
raise ToolError(f"Classeur illisible: {e}", code="invalid") from e
|
||||||
|
|
||||||
|
cells: dict[str, Any] = {}
|
||||||
|
for i, row in enumerate(params.rows):
|
||||||
|
for j, value in enumerate(row):
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
continue
|
||||||
|
ref = f"{get_column_letter(j + 1)}{first_free + i}"
|
||||||
|
cells[ref] = _coerce_xlsx_value(value)
|
||||||
|
if not cells:
|
||||||
|
raise ToolError("Aucune valeur fournie", code="invalid_arguments")
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = edit_xlsx_cells(
|
||||||
|
params.vault,
|
||||||
|
params.path,
|
||||||
|
params.sheet,
|
||||||
|
cells,
|
||||||
|
allow_formula=params.allow_formula,
|
||||||
|
force=params.force,
|
||||||
|
)
|
||||||
|
except ServiceError as e:
|
||||||
|
raise _map_service_error(e) from e
|
||||||
|
return {
|
||||||
|
"status": "ok",
|
||||||
|
"vault": result["vault"],
|
||||||
|
"path": result["path"],
|
||||||
|
"sheet": params.sheet,
|
||||||
|
"rows": len(params.rows),
|
||||||
|
"first_row": first_free,
|
||||||
|
}
|
||||||
+455
-3
@@ -6,6 +6,10 @@ Write-side lives in ``backend.services.mutations.edit_xlsx_cells``.
|
|||||||
|
|
||||||
:func:`inspect_workbook` lists the workbook features that an openpyxl
|
:func:`inspect_workbook` lists the workbook features that an openpyxl
|
||||||
round-trip would drop (#153 A1) so the UI can warn before saving.
|
round-trip would drop (#153 A1) so the UI can warn before saving.
|
||||||
|
|
||||||
|
#153 A16 — :func:`render_sheets` also accepts ``.xlsm`` (macros preserved on
|
||||||
|
save via ``keep_vba``), ``.xls`` (xlrd) and ``.ods`` (odfpy), both served
|
||||||
|
read-only; :func:`render_csv_table` turns a CSV into the same table shape.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -57,6 +61,11 @@ _CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
|
|||||||
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
|
||||||
_MAX_PROBE_BYTES = 8_000_000
|
_MAX_PROBE_BYTES = 8_000_000
|
||||||
|
|
||||||
|
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
|
||||||
|
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
|
||||||
|
_CHART_PART_RE = re.compile(r"^xl/charts/chart\d+\.xml$")
|
||||||
|
_PIVOT_PART_RE = re.compile(r"^xl/pivotTables/pivotTable\d+\.xml$")
|
||||||
|
|
||||||
# #153 A5 — ceiling on the text handed to the TF-IDF / semantic index. A workbook
|
# #153 A5 — ceiling on the text handed to the TF-IDF / semantic index. A workbook
|
||||||
# is a data dump, not prose: indexing every cell would flood the inverted index
|
# is a data dump, not prose: indexing every cell would flood the inverted index
|
||||||
# and bury the notes. Sheet names + the first rows are enough to make a
|
# and bury the notes. Sheet names + the first rows are enough to make a
|
||||||
@@ -65,6 +74,130 @@ MAX_INDEX_CHARS = 5_000
|
|||||||
_INDEX_ROWS_PER_SHEET = 20
|
_INDEX_ROWS_PER_SHEET = 20
|
||||||
MAX_INDEX_SHEETS = 20
|
MAX_INDEX_SHEETS = 20
|
||||||
|
|
||||||
|
# #153 A15 — reading styles is a second (non-read_only) pass on the sheet XML.
|
||||||
|
# Bounded like everything else: a cell must be INSIDE the rendered window to
|
||||||
|
# deserve an inline style, so a huge workbook never triggers a huge payload.
|
||||||
|
# Only data-driven fragments are emitted: the hex values come from the file,
|
||||||
|
# never from a hardcoded color table.
|
||||||
|
|
||||||
|
|
||||||
|
def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
|
||||||
|
"""Inline CSS fragments of one cell plus its horizontal alignment.
|
||||||
|
|
||||||
|
Fixed, color-first order: the API contract documents ``color:...`` as the
|
||||||
|
first fragment of a styled cell. Only data-driven values are emitted —
|
||||||
|
every hex comes from the workbook itself, never a hardcoded table.
|
||||||
|
"""
|
||||||
|
fragments: list[str] = []
|
||||||
|
font = cell.font
|
||||||
|
if font and font.color is not None and isinstance(font.color.rgb, str):
|
||||||
|
# ARGB from the workbook itself — never a hardcoded table.
|
||||||
|
rgb = font.color.rgb
|
||||||
|
if len(rgb) == 8 and rgb != "FF000000":
|
||||||
|
fragments.append(f"color:#{rgb[2:].lower()}")
|
||||||
|
fill = cell.fill
|
||||||
|
if fill and fill.fgColor is not None and isinstance(fill.fgColor.rgb, str):
|
||||||
|
rgb = fill.fgColor.rgb
|
||||||
|
if len(rgb) == 8 and rgb not in ("00000000", "FFFFFFFF"):
|
||||||
|
fragments.append(f"background:#{rgb[2:].lower()}")
|
||||||
|
if font and font.bold:
|
||||||
|
fragments.append("font-weight:600")
|
||||||
|
if font and font.italic:
|
||||||
|
fragments.append("font-style:italic")
|
||||||
|
fmt = cell.number_format
|
||||||
|
if fmt and fmt not in ("General", "@"):
|
||||||
|
# A custom number format is signalled typographically (mono font)
|
||||||
|
# rather than rendered: the displayed value already carries the
|
||||||
|
# formatting from _fmt(). Single quotes: the fragment lands inside a
|
||||||
|
# double-quoted HTML attribute.
|
||||||
|
fragments.append("font-family:'JetBrains Mono',monospace")
|
||||||
|
alignment = cell.alignment
|
||||||
|
align = alignment.horizontal if alignment else None
|
||||||
|
return fragments, (align if align in ("left", "right", "center") else None)
|
||||||
|
|
||||||
|
|
||||||
|
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
|
||||||
|
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
|
||||||
|
|
||||||
|
The flat string is what the API serves and what the viewer applies
|
||||||
|
verbatim to ``td.style``; a plain cell is simply absent from the map.
|
||||||
|
``left`` is the table default and never included. Bounded by
|
||||||
|
``MAX_ROWS x MAX_COLS`` like the render itself.
|
||||||
|
"""
|
||||||
|
styles: dict[str, str] = {}
|
||||||
|
aligns: dict[str, str] = {}
|
||||||
|
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
|
||||||
|
for cell in row:
|
||||||
|
if cell.value is None and cell.number_format == "General":
|
||||||
|
continue
|
||||||
|
fragments, align = _cell_fragments(cell)
|
||||||
|
if fragments:
|
||||||
|
styles[cell.coordinate] = ";".join(fragments)
|
||||||
|
if align and align != "left":
|
||||||
|
aligns[cell.coordinate] = align
|
||||||
|
return styles, aligns
|
||||||
|
|
||||||
|
|
||||||
|
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Return ``{ref: {style, align}}`` for the styled cells of one sheet.
|
||||||
|
|
||||||
|
``style`` is the flat CSS fragment the viewer applies verbatim and
|
||||||
|
``align`` the horizontal text-align when it is not the table default.
|
||||||
|
Normal (non-streaming) load — styles are unavailable in read_only mode;
|
||||||
|
a failure yields ``{}`` so the viewer falls back to the plain rendering.
|
||||||
|
"""
|
||||||
|
meta = read_workbook_meta(file_path).get(sheet, {})
|
||||||
|
styles_map = meta.get("styles", {})
|
||||||
|
aligns = meta.get("aligns", {})
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
for ref, css in styles_map.items():
|
||||||
|
entry: dict[str, Any] = {"style": css}
|
||||||
|
if ref in aligns:
|
||||||
|
entry["align"] = aligns[ref]
|
||||||
|
out[ref] = entry
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def read_sheet_merges(file_path: Path, sheet: str) -> list[str]:
|
||||||
|
"""Return the merged ranges of one sheet as ``A1:C3`` strings."""
|
||||||
|
try:
|
||||||
|
# Styles and merges are only fully materialised in normal mode
|
||||||
|
# (read_only=True leaves merged_cells empty).
|
||||||
|
wb = load_workbook(str(file_path))
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
if sheet not in wb.sheetnames:
|
||||||
|
return []
|
||||||
|
merged = getattr(wb[sheet], "merged_cells", None)
|
||||||
|
ranges = getattr(merged, "ranges", None) or []
|
||||||
|
return [str(r) for r in ranges]
|
||||||
|
except Exception:
|
||||||
|
logger.debug("xlsx merges unavailable", exc_info=True)
|
||||||
|
return []
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
def read_sheet_freeze(file_path: Path, sheet: str) -> str:
|
||||||
|
"""Return the freeze-panes anchor of one sheet ('' when not frozen).
|
||||||
|
|
||||||
|
Normal (non-streaming) load: `freeze_panes` is NOT materialised on
|
||||||
|
ReadOnlyWorksheet in openpyxl 3.1.x — read_only=True always yields ''.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(str(file_path))
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
try:
|
||||||
|
if sheet not in wb.sheetnames:
|
||||||
|
return ""
|
||||||
|
return str(getattr(wb[sheet], "freeze_panes", None) or "")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
def _fmt(value: Any) -> str:
|
def _fmt(value: Any) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -108,6 +241,7 @@ def _table(
|
|||||||
grid: list[list[str]],
|
grid: list[list[str]],
|
||||||
cached: list[list[str]] | None = None,
|
cached: list[list[str]] | None = None,
|
||||||
row_offset: int = 0,
|
row_offset: int = 0,
|
||||||
|
styles: dict[str, dict[str, Any]] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a grid as an HTML table.
|
"""Render a grid as an HTML table.
|
||||||
|
|
||||||
@@ -121,6 +255,11 @@ def _table(
|
|||||||
``row_offset`` is the number of rows skipped before this grid (#153 A9): the
|
``row_offset`` is the number of rows skipped before this grid (#153 A9): the
|
||||||
row numbers and the ``data-cell`` references must stay the real A1
|
row numbers and the ``data-cell`` references must stay the real A1
|
||||||
coordinates of the sheet, not of the window.
|
coordinates of the sheet, not of the window.
|
||||||
|
|
||||||
|
``styles`` maps A1 references to ``{style, align}`` fragments (#153 A15:
|
||||||
|
bold, italic, background, alignment) — the backend only reads the
|
||||||
|
workbook, the fragments are built from it and always data-driven, never
|
||||||
|
hardcoded colors. A plain ``str`` value is tolerated (legacy callers).
|
||||||
"""
|
"""
|
||||||
if not grid:
|
if not grid:
|
||||||
return "<p><em>Feuille vide</em></p>"
|
return "<p><em>Feuille vide</em></p>"
|
||||||
@@ -137,6 +276,20 @@ def _table(
|
|||||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||||
for c, val in enumerate(row, start=1):
|
for c, val in enumerate(row, start=1):
|
||||||
ref = f"{get_column_letter(c)}{r}"
|
ref = f"{get_column_letter(c)}{r}"
|
||||||
|
meta = (styles or {}).get(ref)
|
||||||
|
if meta is None:
|
||||||
|
style_attr = ""
|
||||||
|
else:
|
||||||
|
# Legacy callers may still pass a bare CSS string.
|
||||||
|
if isinstance(meta, str):
|
||||||
|
meta = {"style": meta}
|
||||||
|
fragment = meta.get("style", "")
|
||||||
|
align = meta.get("align")
|
||||||
|
if align and align not in ("left",):
|
||||||
|
# left is the table default; only non-default alignments
|
||||||
|
# need an explicit declaration.
|
||||||
|
fragment = f"{fragment};text-align:{align}" if fragment else f"text-align:{align}"
|
||||||
|
style_attr = f' style="{fragment}"' if fragment else ""
|
||||||
# The cached result only makes sense for a formula cell: on a plain
|
# The cached result only makes sense for a formula cell: on a plain
|
||||||
# value cell the two reads are identical and showing both would
|
# value cell the two reads are identical and showing both would
|
||||||
# duplicate the text.
|
# duplicate the text.
|
||||||
@@ -153,7 +306,7 @@ def _table(
|
|||||||
f"{html.escape(cval)}</span>"
|
f"{html.escape(cval)}</span>"
|
||||||
)
|
)
|
||||||
out.append(
|
out.append(
|
||||||
f'<td data-cell="{ref}">{html.escape(val)}{shadow}</td>'
|
f'<td data-cell="{ref}"{style_attr}>{html.escape(val)}{shadow}</td>'
|
||||||
)
|
)
|
||||||
out.append("</tr>")
|
out.append("</tr>")
|
||||||
out.append("</tbody></table></div>")
|
out.append("</tbody></table></div>")
|
||||||
@@ -205,6 +358,40 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
|
||||||
|
|
||||||
|
One normal (non-streaming) load serves the three A15 metadata maps: the
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
wb = load_workbook(str(file_path), data_only=False)
|
||||||
|
except Exception:
|
||||||
|
return {}
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
try:
|
||||||
|
for ws in wb.worksheets:
|
||||||
|
styles, aligns = _sheet_style_maps(ws)
|
||||||
|
merged = getattr(ws, "merged_cells", None)
|
||||||
|
ranges = getattr(merged, "ranges", None) or []
|
||||||
|
out[ws.title] = {
|
||||||
|
"styles": styles,
|
||||||
|
"aligns": aligns,
|
||||||
|
"merges": [str(r) for r in ranges],
|
||||||
|
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
except Exception:
|
||||||
|
logger.debug("xlsx meta unavailable", exc_info=True)
|
||||||
|
for t in wb.sheetnames:
|
||||||
|
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
|
||||||
|
return out
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||||
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
|
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
|
||||||
|
|
||||||
@@ -230,6 +417,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
|||||||
if _has_cached_values(file_path):
|
if _has_cached_values(file_path):
|
||||||
cached = _read_cached_grids(file_path, titles)
|
cached = _read_cached_grids(file_path, titles)
|
||||||
|
|
||||||
|
# #153 A15 — one extra normal-mode load serves the styles/merges/freeze
|
||||||
|
# metadata of every sheet; the HTML then carries the fragments itself.
|
||||||
|
meta = read_workbook_meta(file_path)
|
||||||
|
|
||||||
sheets = []
|
sheets = []
|
||||||
for i, title in enumerate(titles):
|
for i, title in enumerate(titles):
|
||||||
grid = _trim(formulas[i])
|
grid = _trim(formulas[i])
|
||||||
@@ -239,10 +430,11 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
|||||||
# positionally against the untrimmed grid keeps the two aligned.
|
# positionally against the untrimmed grid keeps the two aligned.
|
||||||
shadow = cached[i] if cached is not None and i < len(cached) else None
|
shadow = cached[i] if cached is not None and i < len(cached) else None
|
||||||
total_rows, total_cols = extents[i]
|
total_rows, total_cols = extents[i]
|
||||||
|
sheet_meta = meta.get(title, {})
|
||||||
sheets.append(
|
sheets.append(
|
||||||
{
|
{
|
||||||
"name": title,
|
"name": title,
|
||||||
"html": _table(grid, shadow),
|
"html": _table(grid, shadow, styles=sheet_meta.get("styles")),
|
||||||
"rows": len(grid),
|
"rows": len(grid),
|
||||||
"cols": max((len(r) for r in grid), default=0),
|
"cols": max((len(r) for r in grid), default=0),
|
||||||
"total_rows": total_rows,
|
"total_rows": total_rows,
|
||||||
@@ -257,6 +449,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
|||||||
# decided its shape: comparing against the *rendered* size would
|
# decided its shape: comparing against the *rendered* size would
|
||||||
# flag every sheet carrying a few empty formatted rows.
|
# flag every sheet carrying a few empty formatted rows.
|
||||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||||
|
"styles": sheet_meta.get("styles", {}),
|
||||||
|
"aligns": sheet_meta.get("aligns", {}),
|
||||||
|
"merges": sheet_meta.get("merges", []),
|
||||||
|
"freeze": sheet_meta.get("freeze", ""),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return sheets
|
return sheets
|
||||||
@@ -300,6 +496,10 @@ def read_sheet_window(
|
|||||||
# archive really holds cached results.
|
# archive really holds cached results.
|
||||||
if _has_cached_values(file_path):
|
if _has_cached_values(file_path):
|
||||||
shadow = _read_cached_window(file_path, sheet, offset, limit)
|
shadow = _read_cached_window(file_path, sheet, offset, limit)
|
||||||
|
# #153 A15 — same metadata as the full render, so a lazy window is
|
||||||
|
# indistinguishable from it (styles in the HTML, merges/freeze for the
|
||||||
|
# client-side spanning).
|
||||||
|
meta = read_workbook_meta(file_path).get(sheet, {})
|
||||||
return {
|
return {
|
||||||
"sheet": sheet,
|
"sheet": sheet,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
@@ -312,7 +512,11 @@ def read_sheet_window(
|
|||||||
"max_cols": MAX_COLS,
|
"max_cols": MAX_COLS,
|
||||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||||
"has_more": offset + len(grid) < total_rows,
|
"has_more": offset + len(grid) < total_rows,
|
||||||
"html": _table(grid, shadow, row_offset=offset),
|
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
|
||||||
|
"styles": meta.get("styles", {}),
|
||||||
|
"aligns": meta.get("aligns", {}),
|
||||||
|
"merges": meta.get("merges", []),
|
||||||
|
"freeze": meta.get("freeze", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -444,3 +648,251 @@ def extract_indexable_text(file_path: Path) -> str:
|
|||||||
finally:
|
finally:
|
||||||
wb.close()
|
wb.close()
|
||||||
return "\n".join(c for c in chunks if c).strip()
|
return "\n".join(c for c in chunks if c).strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── #153 A17 — dashboard metadata ───────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
|
||||||
|
"""Return the dashboard metadata of a workbook (#153 A17).
|
||||||
|
|
||||||
|
Shape::
|
||||||
|
|
||||||
|
{
|
||||||
|
"named_ranges": [{"name", "scope", "ref"}],
|
||||||
|
"objects": {"charts": int, "pivots": int},
|
||||||
|
"sheets": [{
|
||||||
|
"name": str,
|
||||||
|
"cells": int, # non-empty cells inside the caps
|
||||||
|
"rows": int, # rows carrying at least one non-empty cell
|
||||||
|
"cols": int, # columns carrying at least one non-empty cell
|
||||||
|
"formulas": int,
|
||||||
|
"numeric": int,
|
||||||
|
"kpi": [ # first 8 numeric cells as {"label", "value"}
|
||||||
|
{"label": str, "value": float}
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}
|
||||||
|
|
||||||
|
Named ranges come from the streaming load (available read-only), cell
|
||||||
|
stats from ``iter_rows(values_only=True)``. Charts/pivots are counted by
|
||||||
|
OPC part names (a chart part per chart, a pivot table part per pivot).
|
||||||
|
Bounded by MAX_ROWS/MAX_COLS; never raises — a failure yields an empty
|
||||||
|
payload and the viewer simply hides the panel.
|
||||||
|
"""
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"named_ranges": [],
|
||||||
|
"objects": {"charts": 0, "pivots": 0},
|
||||||
|
"sheets": [],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||||
|
except Exception:
|
||||||
|
return payload
|
||||||
|
try:
|
||||||
|
dn = getattr(wb, "defined_names", None)
|
||||||
|
items: list[tuple[Any, Any]] = (
|
||||||
|
list(dn.items()) if dn is not None and hasattr(dn, "items") else []
|
||||||
|
)
|
||||||
|
for name, defn in items:
|
||||||
|
scope_idx = getattr(defn, "localSheetId", None)
|
||||||
|
scope = ""
|
||||||
|
if scope_idx is not None:
|
||||||
|
try:
|
||||||
|
scope = wb.sheetnames[int(scope_idx)]
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
scope = ""
|
||||||
|
payload["named_ranges"].append(
|
||||||
|
{
|
||||||
|
"name": str(name),
|
||||||
|
"scope": scope,
|
||||||
|
"ref": str(getattr(defn, "attr_text", "") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
payload["named_ranges"].sort(key=lambda d: d["name"].lower())
|
||||||
|
|
||||||
|
for ws in wb.worksheets:
|
||||||
|
cells = rows = formulas = numeric = 0
|
||||||
|
col_seen: set[int] = set()
|
||||||
|
kpi: list[dict[str, Any]] = []
|
||||||
|
for r, row in enumerate(
|
||||||
|
ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True),
|
||||||
|
start=1,
|
||||||
|
):
|
||||||
|
row_has_value = False
|
||||||
|
for c, value in enumerate(row, start=1):
|
||||||
|
if value is None or (isinstance(value, str) and not value.strip()):
|
||||||
|
continue
|
||||||
|
cells += 1
|
||||||
|
col_seen.add(c)
|
||||||
|
row_has_value = True
|
||||||
|
if isinstance(value, str) and value.startswith("="):
|
||||||
|
formulas += 1
|
||||||
|
elif isinstance(value, bool):
|
||||||
|
pass
|
||||||
|
elif isinstance(value, (int, float)):
|
||||||
|
numeric += 1
|
||||||
|
if len(kpi) < 8:
|
||||||
|
kpi.append(
|
||||||
|
{"label": f"{get_column_letter(c)}{r}", "value": value}
|
||||||
|
)
|
||||||
|
if row_has_value:
|
||||||
|
rows += 1
|
||||||
|
payload["sheets"].append(
|
||||||
|
{
|
||||||
|
"name": ws.title,
|
||||||
|
"cells": cells,
|
||||||
|
"rows": rows,
|
||||||
|
"cols": len(col_seen),
|
||||||
|
"formulas": formulas,
|
||||||
|
"numeric": numeric,
|
||||||
|
"kpi": kpi,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Chart/pivot parts, counted from the archive (chart XML parts are
|
||||||
|
# one per chart; pivot parts one per pivot table/cache).
|
||||||
|
with zipfile.ZipFile(file_path) as zf:
|
||||||
|
names = zf.namelist()
|
||||||
|
payload["objects"]["charts"] = sum(1 for n in names if _CHART_PART_RE.match(n))
|
||||||
|
payload["objects"]["pivots"] = sum(1 for n in names if _PIVOT_PART_RE.match(n))
|
||||||
|
return payload
|
||||||
|
except Exception:
|
||||||
|
logger.debug("xlsx dashboard unavailable", exc_info=True)
|
||||||
|
return {
|
||||||
|
"named_ranges": [],
|
||||||
|
"objects": {"charts": 0, "pivots": 0},
|
||||||
|
"sheets": [],
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── #153 A16 — additional spreadsheet formats ───────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def render_csv_table(raw: str, *, delimiter: str = ",") -> 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.
|
||||||
|
"""
|
||||||
|
import csv as csv_mod
|
||||||
|
import io as io_mod
|
||||||
|
|
||||||
|
reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter)
|
||||||
|
try:
|
||||||
|
rows = [row for row in reader]
|
||||||
|
except csv_mod.Error:
|
||||||
|
# A malformed CSV still renders: each line becomes a one-cell row.
|
||||||
|
rows = [[line] for line in raw.splitlines()]
|
||||||
|
if not rows:
|
||||||
|
return "<p><em>Feuille vide</em></p>"
|
||||||
|
n_cols = max(len(r) for r in rows)
|
||||||
|
out = [
|
||||||
|
('<div class="csv-table-wrapper"><table class="csv-table xlsx-table">'
|
||||||
|
'<thead><tr><th class="xlsx-corner"></th>')
|
||||||
|
]
|
||||||
|
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
||||||
|
out.append("</tr></thead><tbody>")
|
||||||
|
for r, row in enumerate(rows, start=1):
|
||||||
|
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||||
|
for c in range(1, n_cols + 1):
|
||||||
|
val = row[c - 1] if c - 1 < len(row) else ""
|
||||||
|
out.append(f'<td data-cell="{get_column_letter(c)}{r}">{html.escape(val)}</td>')
|
||||||
|
out.append("</tr>")
|
||||||
|
out.append("</tbody></table></div>")
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def render_legacy_workbook(file_path: Path, ext: str) -> list[dict[str, Any]]:
|
||||||
|
"""Render ``.xls``/``.ods`` sheets with the same dict shape as xlsx.
|
||||||
|
|
||||||
|
Read-only formats (#153 A16): ``styles``/``aligns``/``merges``/``freeze``
|
||||||
|
are served empty so the client-side wiring keeps one code path. Raises
|
||||||
|
nothing to the render path: an unreadable file yields one error sheet.
|
||||||
|
"""
|
||||||
|
name = file_path.name
|
||||||
|
try:
|
||||||
|
if ext == ".xls":
|
||||||
|
import xlrd
|
||||||
|
|
||||||
|
book = xlrd.open_workbook(str(file_path))
|
||||||
|
titles = book.sheet_names()
|
||||||
|
grids = []
|
||||||
|
for si in range(book.nsheets):
|
||||||
|
sh = book.sheet_by_index(si)
|
||||||
|
grid = [
|
||||||
|
[_fmt(sh.cell_value(r, c)) for c in range(min(sh.ncols, MAX_COLS))]
|
||||||
|
for r in range(min(sh.nrows, MAX_ROWS))
|
||||||
|
]
|
||||||
|
grids.append(_trim(grid))
|
||||||
|
total = [(sh.nrows, sh.ncols) for sh in (book.sheet_by_index(i) for i in range(book.nsheets))]
|
||||||
|
elif ext == ".ods":
|
||||||
|
from odf.opendocument import load as odf_load
|
||||||
|
from odf.table import Table, TableCell, TableRow
|
||||||
|
from odf.teletype import extractText
|
||||||
|
|
||||||
|
doc = odf_load(str(file_path))
|
||||||
|
titles = []
|
||||||
|
grids = []
|
||||||
|
total = []
|
||||||
|
for table in doc.getElementsByType(Table):
|
||||||
|
title = table.getAttribute("name") or f"Feuille {len(titles) + 1}"
|
||||||
|
titles.append(title)
|
||||||
|
grid = []
|
||||||
|
for row in table.getElementsByType(TableRow)[:MAX_ROWS]:
|
||||||
|
row_cells = row.getElementsByType(TableCell)
|
||||||
|
values: list[str] = []
|
||||||
|
for tc in row_cells[:MAX_COLS]:
|
||||||
|
repeat = int(tc.getAttribute("numbercolumnsrepeated") or 1)
|
||||||
|
values.extend([extractText(tc)] * min(repeat, MAX_COLS - len(values)))
|
||||||
|
grid.append(values)
|
||||||
|
grids.append(_trim(grid))
|
||||||
|
total.append((len(grid), max((len(r) for r in grid), default=0)))
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Unsupported legacy format: {ext}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("legacy workbook render failed for %s: %s", name, exc)
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
"name": name,
|
||||||
|
"html": (
|
||||||
|
'<p><em>Feuille vide</em></p>'
|
||||||
|
),
|
||||||
|
"rows": 0,
|
||||||
|
"cols": 0,
|
||||||
|
"total_rows": 0,
|
||||||
|
"total_cols": 0,
|
||||||
|
"max_rows": MAX_ROWS,
|
||||||
|
"max_cols": MAX_COLS,
|
||||||
|
"truncated": False,
|
||||||
|
"styles": {},
|
||||||
|
"aligns": {},
|
||||||
|
"merges": [],
|
||||||
|
"freeze": "",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
sheets: list[dict[str, Any]] = []
|
||||||
|
for i, title in enumerate(titles):
|
||||||
|
grid = grids[i] if i < len(grids) else []
|
||||||
|
t_rows, t_cols = total[i] if i < len(total) else (0, 0)
|
||||||
|
sheets.append(
|
||||||
|
{
|
||||||
|
"name": title,
|
||||||
|
"html": _table(grid),
|
||||||
|
"rows": len(grid),
|
||||||
|
"cols": max((len(r) for r in grid), default=0),
|
||||||
|
"total_rows": t_rows,
|
||||||
|
"total_cols": t_cols,
|
||||||
|
"max_rows": MAX_ROWS,
|
||||||
|
"max_cols": MAX_COLS,
|
||||||
|
"truncated": t_rows > MAX_ROWS or t_cols > MAX_COLS,
|
||||||
|
"styles": {},
|
||||||
|
"aligns": {},
|
||||||
|
"merges": [],
|
||||||
|
"freeze": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return sheets
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.32.0"
|
version = "2.39.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.32.0"
|
version = "2.39.2"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.32.0",
|
"version": "2.39.2",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -194,6 +194,43 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
|||||||
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
|
||||||
n'est pas un `.xlsx`.
|
n'est pas un `.xlsx`.
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
**Tri, filtre, recherche, export** — chaque colonne se trie (ascendant /
|
||||||
|
descendant, info-bulle : le tri s'applique à l'affichage seul), les lignes
|
||||||
|
se filtrent, la recherche (`Ctrl+F` du panneau) va de correspondance en
|
||||||
|
correspondance, et la feuille s'exporte en CSV. Rien de tout cela ne
|
||||||
|
modifie le classeur.
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
**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).
|
||||||
|
|
||||||
|
**Tableau de bord** — le bouton **Tableau de bord** liste les plages
|
||||||
|
nommées du classeur (nom, référence, portée), signale les feuilles
|
||||||
|
contenant des graphiques ou des tableaux croisés, et donne pour chaque
|
||||||
|
feuille un résumé (cellules, lignes, colonnes, formules, valeurs
|
||||||
|
numériques) avec quelques chiffres clés. C'est une aide à la lecture :
|
||||||
|
les plages nommées peuvent ensuite être exploitées avec l'assistant IA
|
||||||
|
(outils `list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`,
|
||||||
|
`append_xlsx_rows`).
|
||||||
|
|
||||||
### Limites
|
### Limites
|
||||||
|
|
||||||
- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ;
|
- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ;
|
||||||
@@ -201,9 +238,13 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
|
|||||||
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
défilement vers le bas du tableau) ajoute les lignes suivantes par
|
||||||
fenêtres de 500 — elles deviennent aussitôt éditables et
|
fenêtres de 500 — elles deviennent aussitôt éditables et
|
||||||
sauvegardables.
|
sauvegardables.
|
||||||
- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas
|
- Un **format de nombre personnalisé** (devise, pourcentage…) est signalé
|
||||||
rendus.
|
par une police à chasse fixe, mais la valeur reste affichée brute.
|
||||||
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
|
- L'application de **styles** depuis la visionneuse (mettre en gras,
|
||||||
|
colorer) n'est pas proposée — seuls les styles existants sont rendus.
|
||||||
|
- `.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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
- **Projet** : ObsiGate — Porte d'entrée web pour vaults Obsidian
|
||||||
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
- **Stack** : Python 3.11+ (backend FastAPI) · JavaScript/Vanilla (frontend) · Tauri/Rust (desktop)
|
||||||
- **Dernière mise à jour** : 2026-09-27
|
- **Dernière mise à jour** : 2026-09-28
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -197,6 +197,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| *BUG-088* | Injection de formule dans un `.xlsx` : une saisie `=cmd\|'/c calc'!A1` est stockée comme formule et s'exécute à l'ouverture dans Excel (DDE) | 🟢 corrigé | P0 | tableur Excel / sécurité | IA | `backend/services/mutations.py::_write_cell`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | `PUT /api/file/V/xlsx/save` avec `{"sheet": "S", "cells": {"A1": "=1+1"}}` → la cellule sort en `data_type == "f"` | `cell.data_type = "s"` après affectation : le texte est stocké comme chaîne, aucun `<f>` n'est écrit. Opt-in via `allow_formula: true` (endpoint) et le bouton `f(x)` de la visionneuse (session, jamais persisté). Test : `TestXlsxFormulaGuard` (4) + `xlsx-viewer.test.mjs` (toggle) | #153 A4. `+`/`-` ne sont pas neutralisés : ils sont déjà convertis en nombre par `_coerce_xlsx_value`. Le handler global `ServiceError` expose désormais `code` + `details` (le client en a besoin pour le 409), et `api()` (frontend) les propage sur l'Error. Vérifié : cf. BUG-085 |
|
| *BUG-088* | Injection de formule dans un `.xlsx` : une saisie `=cmd\|'/c calc'!A1` est stockée comme formule et s'exécute à l'ouverture dans Excel (DDE) | 🟢 corrigé | P0 | tableur Excel / sécurité | IA | `backend/services/mutations.py::_write_cell`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | `PUT /api/file/V/xlsx/save` avec `{"sheet": "S", "cells": {"A1": "=1+1"}}` → la cellule sort en `data_type == "f"` | `cell.data_type = "s"` après affectation : le texte est stocké comme chaîne, aucun `<f>` n'est écrit. Opt-in via `allow_formula: true` (endpoint) et le bouton `f(x)` de la visionneuse (session, jamais persisté). Test : `TestXlsxFormulaGuard` (4) + `xlsx-viewer.test.mjs` (toggle) | #153 A4. `+`/`-` ne sont pas neutralisés : ils sont déjà convertis en nombre par `_coerce_xlsx_value`. Le handler global `ServiceError` expose désormais `code` + `details` (le client en a besoin pour le 409), et `api()` (frontend) les propage sur l'Error. Vérifié : cf. BUG-085 |
|
||||||
| *BUG-089* | Un reindex manuel ne reconstruisait pas l'index inversé : la recherche TF-IDF continuait de servir un index périmé | 🟢 corrigé | P1 | ⚙️ backend / recherche | IA | `backend/indexer.py::reload_index`, `backend/indexer.py::reload_single_vault`, `backend/search.py` | Modifier le contenu d'un fichier, puis `GET /api/index/reload` → la recherche renvoie encore l'ancien contenu (ou rien pour un fichier nouveau) | `reload_index()` / `reload_single_vault()` appellent `init_inverted_index()` après le rebuild (le remplacement wholesale d'une entrée de vault n'émet pas les notifications incrémentales). En prime, `backend/search.py` lisait l'index via `from backend.indexer import index` (liaison **par valeur** du dict) : un `importlib.reload(backend.indexer)` recréait le dict côté indexer tandis que la recherche écrivait encore dans l'ancien — l'index inversé n'indexait alors plus rien. Tous les accès passent désormais par `_indexer.index`. Contre-preuve : `TestXlsxSearchable::test_search_finds_a_word_stored_in_a_cell` échoue sans le correctif | #153 A5. Trouvé en écrivant le test de recherche d'A5 : il passait isolément et échouait en suite complète selon l'ordre. Le reload incrémental par fichier (watcher, edition) n'est pas concerné : il passe par le hook `_on_index_change`. Vérifié : suite 1402 passed / 6 skipped, ruff/mypy 0 |
|
| *BUG-089* | Un reindex manuel ne reconstruisait pas l'index inversé : la recherche TF-IDF continuait de servir un index périmé | 🟢 corrigé | P1 | ⚙️ backend / recherche | IA | `backend/indexer.py::reload_index`, `backend/indexer.py::reload_single_vault`, `backend/search.py` | Modifier le contenu d'un fichier, puis `GET /api/index/reload` → la recherche renvoie encore l'ancien contenu (ou rien pour un fichier nouveau) | `reload_index()` / `reload_single_vault()` appellent `init_inverted_index()` après le rebuild (le remplacement wholesale d'une entrée de vault n'émet pas les notifications incrémentales). En prime, `backend/search.py` lisait l'index via `from backend.indexer import index` (liaison **par valeur** du dict) : un `importlib.reload(backend.indexer)` recréait le dict côté indexer tandis que la recherche écrivait encore dans l'ancien — l'index inversé n'indexait alors plus rien. Tous les accès passent désormais par `_indexer.index`. Contre-preuve : `TestXlsxSearchable::test_search_finds_a_word_stored_in_a_cell` échoue sans le correctif | #153 A5. Trouvé en écrivant le test de recherche d'A5 : il passait isolément et échouait en suite complète selon l'ordre. Le reload incrémental par fichier (watcher, edition) n'est pas concerné : il passe par le hook `_on_index_change`. Vérifié : suite 1402 passed / 6 skipped, ruff/mypy 0 |
|
||||||
| *BUG-090* | Troncature silencieuse d'une feuille `.xlsx` au-delà de 500 lignes × 40 colonnes : l'utilisateur voit une table courte sans aucun indice que la suite existe | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `backend/routers/files_read.py`, `frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css` | Ouvrir `test_vault/sample-xlsx-large.xlsx` (520 lignes) → la feuille s'arrête à la ligne 500 sans aucun message | `render_sheets()` renvoie désormais `total_rows`/`total_cols` (dimensions déclarées par la feuille), `max_rows`/`max_cols` (plafonds du moteur) et `truncated` ; la visionneuse affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » (i18n `xlsx.truncated_*` FR/EN, axe des colonnes inclus). Contre-preuve : neutraliser `truncated` → `TestXlsxTruncationNotice` (2 tests) échoue | #153 A8/R5. La ligne d'en-têtes est aussi `sticky` au défilement vertical (`thead th { top: 0 }` + `top: auto` sur les numéros de ligne pour éviter l'empilement en haut à gauche). L'endpoint `GET …/xlsx/sheet` (#153 A9) sert les fenêtres au-delà du plafond, mais le chargement paresseux complet (défilement virtuel, « charger tout ») reste à faire — le bandeau dit la vérité en attendant. Vérifié : `test_xlsx_viewer.py` 58 passed, E2E 7/7 (dont 3 nouveaux), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity |
|
| *BUG-090* | Troncature silencieuse d'une feuille `.xlsx` au-delà de 500 lignes × 40 colonnes : l'utilisateur voit une table courte sans aucun indice que la suite existe | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `backend/routers/files_read.py`, `frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css` | Ouvrir `test_vault/sample-xlsx-large.xlsx` (520 lignes) → la feuille s'arrête à la ligne 500 sans aucun message | `render_sheets()` renvoie désormais `total_rows`/`total_cols` (dimensions déclarées par la feuille), `max_rows`/`max_cols` (plafonds du moteur) et `truncated` ; la visionneuse affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » (i18n `xlsx.truncated_*` FR/EN, axe des colonnes inclus). Contre-preuve : neutraliser `truncated` → `TestXlsxTruncationNotice` (2 tests) échoue | #153 A8/R5. La ligne d'en-têtes est aussi `sticky` au défilement vertical (`thead th { top: 0 }` + `top: auto` sur les numéros de ligne pour éviter l'empilement en haut à gauche). L'endpoint `GET …/xlsx/sheet` (#153 A9) sert les fenêtres au-delà du plafond, mais le chargement paresseux complet (défilement virtuel, « charger tout ») reste à faire — le bandeau dit la vérité en attendant. Vérifié : `test_xlsx_viewer.py` 58 passed, E2E 7/7 (dont 3 nouveaux), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity |
|
||||||
|
| *BUG-091* | Le job CI `security` échoue depuis la mise à jour de semgrep : le binaire 1.175+ refuse de démarrer sur le runner (`CPU ISA level is lower than required`, exit 127) | 🟢 corrigé | P1 | CI / sécurité | IA | `.gitea/workflows/ci.yml` (job `security`) | Run Gitea #1641 (push docs #153) : étape « Semgrep (SAST local, bloquant — #87) » → `libs/libresolv.so.2: CPU ISA level is lower than required`, `exitcode '127'` | Épinglage `semgrep==1.174.0` dans l'étape « Install dependencies » du job security (dernière version exécutable sur le CPU du runner ; les libs natives de la série 1.175+ sont compilées pour x86-64-v2). Bandit et pip-audit inchangés — les trois étapes repassées localement ✅ | #153, run #1641. Les 7 commits de features v2.33.0→v2.39.0 n'ont déclenché aucun run (Gitea ne lance le workflow que sur le commit de tête du push) ; le CI n'avait donc encore vu ni xlrd/odfpy ni le code A6→A17. Trouvé via le log fourni par l'utilisateur. À surveiller : toute montée de semgrep devra être retestée sur le runner |
|
||||||
|
|
||||||
### TODOs techniques (améliorations / nouvelles tâches)
|
### TODOs techniques (améliorations / nouvelles tâches)
|
||||||
|
|
||||||
@@ -215,6 +216,8 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
|||||||
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
| Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|---|---|
|
||||||
| 2026-09-28 | BUG-090 (#153 A8 + A9) | Correction + feature | `backend/xlsx_reader.py`, `backend/routers/files_read.py`, `backend/schemas.py`, `backend/openapi_docs.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-large.xlsx` | **La troncature d'une feuille est annoncée et les lignes cachées restent accessibles** : (BUG-090/A8) `render_sheets()` renvoie `total_rows`/`total_cols`/`max_rows`/`max_cols`/`truncated`, la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN, axes lignes et colonnes) et la ligne d'en-têtes devient `sticky` (`top: auto` sur les numéros de ligne pour éviter l'empilement) ; (A9) `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, plafond 1 000 lignes/requête, 404 feuille inconnue, 415 non-xlsx) sert une fenêtre avec les **vraies** coordonnées A1 et le `has_more` de pagination. Contre-preuves : neutraliser `truncated` → 2 tests échouent ; neutraliser l'offset → 3 tests échouent. Vérifié : `test_xlsx_viewer.py` 58 passed, xlsx-viewer.test.mjs 14/14, E2E 7/7 (3 nouveaux + fixture `sample-xlsx-large.xlsx` 520 lignes), suite 1417 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-09-28 | BUG-090 (#153 A8 + A9) | Correction + feature | `backend/xlsx_reader.py`, `backend/routers/files_read.py`, `backend/schemas.py`, `backend/openapi_docs.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-large.xlsx` | **La troncature d'une feuille est annoncée et les lignes cachées restent accessibles** : (BUG-090/A8) `render_sheets()` renvoie `total_rows`/`total_cols`/`max_rows`/`max_cols`/`truncated`, la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN, axes lignes et colonnes) et la ligne d'en-têtes devient `sticky` (`top: auto` sur les numéros de ligne pour éviter l'empilement) ; (A9) `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, plafond 1 000 lignes/requête, 404 feuille inconnue, 415 non-xlsx) sert une fenêtre avec les **vraies** coordonnées A1 et le `has_more` de pagination. Contre-preuves : neutraliser `truncated` → 2 tests échouent ; neutraliser l'offset → 3 tests échouent. Vérifié : `test_xlsx_viewer.py` 58 passed, xlsx-viewer.test.mjs 14/14, E2E 7/7 (3 nouveaux + fixture `sample-xlsx-large.xlsx` 520 lignes), suite 1417 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-09-28 | BUG-091 (#153, run CI #1641) | Correction CI | `.gitea/workflows/ci.yml`, `docs/ISSUES_TODOLIST.md`, `CHANGELOG.md` | **Le job `security` est réparé** : le log CI (`libs/libresolv.so.2: CPU ISA level is lower than required`, exit 127) montre que le binaire semgrep non épinglé s'était mis à jour (1.178) vers un build exigeant x86-64-v2, que le CPU du runner Gitea ne fournit pas — aucun lien avec le code livré (bandit/semgrep/pip-audit tous verts en local, et le commit fautif ne touchait que la documentation). Correctif : `semgrep==1.174.0` épinglé dans l'étape « Install dependencies » avec le raisonnement en commentaire. Au passage documenté : les 7 commits de features v2.33.0→v2.39.0 n'ont déclenché aucun run CI (seul le commit de tête d'un push déclenche le workflow). Vérifié localement : semgrep 1.174.0 exit 0 sur `semgrep-rules/`, bandit 0, pip-audit « No known vulnerabilities » | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
|
| 2026-09-28 | #153 A6 → A17 (v2.33.0 → v2.39.0) | Feature + clôture documentaire (aucun bug nouveau) | `CHANGELOG.md`, `docs/features/xlsx-viewer.md`, `docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md`, `README.md`, `README.fr.md` | **Clôture du backlog #153** : entrées CHANGELOG des 7 sous-tâches, fiche `features/xlsx-viewer.md` (statut terminé, cases A6-A17 cochées, historique), section 6 du guide utilisateur étendue (barre de formule, navigation clavier, tri/filtre/recherche/export CSV, structure, styles, formats `.xlsm`/`.xls`/`.ods`/`.csv`, tableau de bord) et bullets README FR/EN. Code livré : v2.33.0 A6 (outils IA `backend/tools/spreadsheets.py`), v2.34.0 A7 (clavier + barre de formule), v2.35.0 A13 (tri/filtre/recherche/export), v2.36.0 A14 (structure `PUT …/xlsx/structure`), v2.37.0 A15 (styles/fusions/volets figés), v2.38.0 A16 (`.xlsm` éditable, `.xls`/`.ods` lecture seule, `.csv` RFC 4180), v2.39.0 A17 (dashboard `GET …/xlsx/dashboard`). Vérifié : suite xlsx 116 passed, xlsx-viewer.test.mjs 35/35, ruff/mypy 0, i18n parity, validate-imports 40 modules | ✅ livré (en attente vérif utilisateur) |
|
||||||
| 2026-09-28 | BUG-089 (#153 A5, A10, A12) | Correction | `backend/xlsx_reader.py`, `backend/indexer.py`, `backend/search.py`, `backend/services/mutations.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py` | **Les tableurs deviennent visibles ettypés** : (A5) `extract_indexable_text()` indexe noms de feuilles + 20 premières lignes (plafond 5 k caractères) dans le TF-IDF et la recherche sémantique — un mot tapé dans une cellule rend le fichier trouvable ; (A10) `_coerce_xlsx_value()` reconnaît désormais les booléens (`TRUE`/`FAUX`/`OUI`/`NON`) et les dates FR `JJ/MM/AAAA` (jour-first : `01/02/2026` = 1er février), symétrique avec l'affichage ; (A12) la valeur calculée en cache s'affiche sous la formule (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), info-bulle traduite via `xlsx.cached_value_title` FR/EN. (BUG-089) un reindex manuel reconstruisait mal l'index inversé et `backend/search.py` lisait l'index par valeur. Contre-preuves vérifiées pour A5, A10 et A12. Vérifié : `test_xlsx_viewer.py` 43 passed, suite 1402 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10 | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-09-28 | BUG-089 (#153 A5, A10, A12) | Correction | `backend/xlsx_reader.py`, `backend/indexer.py`, `backend/search.py`, `backend/services/mutations.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py` | **Les tableurs deviennent visibles ettypés** : (A5) `extract_indexable_text()` indexe noms de feuilles + 20 premières lignes (plafond 5 k caractères) dans le TF-IDF et la recherche sémantique — un mot tapé dans une cellule rend le fichier trouvable ; (A10) `_coerce_xlsx_value()` reconnaît désormais les booléens (`TRUE`/`FAUX`/`OUI`/`NON`) et les dates FR `JJ/MM/AAAA` (jour-first : `01/02/2026` = 1er février), symétrique avec l'affichage ; (A12) la valeur calculée en cache s'affiche sous la formule (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), info-bulle traduite via `xlsx.cached_value_title` FR/EN. (BUG-089) un reindex manuel reconstruisait mal l'index inversé et `backend/search.py` lisait l'index par valeur. Contre-preuves vérifiées pour A5, A10 et A12. Vérifié : `test_xlsx_viewer.py` 43 passed, suite 1402 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
| 2026-09-27 | BUG-085 → BUG-088 (#153 A1-A4) | Correction | `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/main.py`, `frontend/js/viewer.js`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-lossy.xlsx`, `.gitea/workflows/ci.yml` | **Garde-fous d'écriture des classeurs Excel** : (BUG-085) `inspect_workbook()` détecte ce qu'un round-trip openpyxl perd (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) → la lecture expose `xlsx_lossy_features`, la visionneuse affiche une bannière et `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`, confirmation explicite puis reprise) ; (BUG-086) écriture atomique `.tmp` + `os.replace` ; (BUG-087) verrou par fichier (409 `conflict`, endpoint sync pour le threadpool) ; (BUG-088) une saisie `=`/`@` est stockée en texte (`data_type = "s"`), sauf opt-in `allow_formula` / bouton `f(x)`. Le handler `ServiceError` expose désormais `code` + `details` et `api()` les propage. Périmètre de perte revalidé empiriquement sur openpyxl 3.1.5 (graphiques, images et TCD sont préservés). Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10, E2E 3/3 | 🟢 corrigé (en attente vérif utilisateur) |
|
| 2026-09-27 | BUG-085 → BUG-088 (#153 A1-A4) | Correction | `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/main.py`, `frontend/js/viewer.js`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-lossy.xlsx`, `.gitea/workflows/ci.yml` | **Garde-fous d'écriture des classeurs Excel** : (BUG-085) `inspect_workbook()` détecte ce qu'un round-trip openpyxl perd (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) → la lecture expose `xlsx_lossy_features`, la visionneuse affiche une bannière et `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`, confirmation explicite puis reprise) ; (BUG-086) écriture atomique `.tmp` + `os.replace` ; (BUG-087) verrou par fichier (409 `conflict`, endpoint sync pour le threadpool) ; (BUG-088) une saisie `=`/`@` est stockée en texte (`data_type = "s"`), sauf opt-in `allow_formula` / bouton `f(x)`. Le handler `ServiceError` expose désormais `code` + `details` et `api()` les propage. Périmètre de perte revalidé empiriquement sur openpyxl 3.1.5 (graphiques, images et TCD sont préservés). Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10, E2E 3/3 | 🟢 corrigé (en attente vérif utilisateur) |
|
||||||
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
| *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) |
|
||||||
|
|||||||
+13
-13
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.32.0 | **Dernière mise à jour :** 2026-09-28
|
> **Version :** 2.39.2 | **Dernière mise à jour :** 2026-09-28
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats)
|
||||||
|
|
||||||
- **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡
|
- **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡
|
||||||
- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
|
- **Statut :** ✅ **livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis v2.33.0 → v2.39.0 : A6, A7, A13, A14, A15, A16, A17 (+ A11 déjà au CI) — **backlog #153 terminé**
|
||||||
- **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md)
|
- **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md)
|
||||||
- **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de
|
- **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de
|
||||||
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
|
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
|
||||||
@@ -70,21 +70,21 @@
|
|||||||
- [x] **A2** Écriture atomique (`wb.save(.tmp)` + `os.replace()`, backup inchangé) — BUG-086
|
- [x] **A2** Écriture atomique (`wb.save(.tmp)` + `os.replace()`, backup inchangé) — BUG-086
|
||||||
- [x] **A3** Verrou par fichier autour du read-modify-write (timeout 15 s + **409** `conflict`) — BUG-087
|
- [x] **A3** Verrou par fichier autour du read-modify-write (timeout 15 s + **409** `conflict`) — BUG-087
|
||||||
- [x] **A4** Neutralisation de l'injection de formule (`=`/`@` stockés en texte, opt-in `allow_formula` + bouton `f(x)`) — BUG-088
|
- [x] **A4** Neutralisation de l'injection de formule (`=`/`@` stockés en texte, opt-in `allow_formula` + bouton `f(x)`) — BUG-088
|
||||||
- **P1 — recherche, IA, UX (🟡, 4-6 j) — 🔵 en cours**
|
- **P1 — recherche, IA, UX (🟡, 4-6 j) — 🟢 livré**
|
||||||
- [x] **A5** Indexation du contenu des feuilles (noms de feuilles + 20 premières lignes, plafond 5 k caractères) — les mots tapés dans une cellule rendent le fichier trouvable ; au passage **BUG-089** (reindex manuel ne reconstruisait pas l'index inversé)
|
- [x] **A5** Indexation du contenu des feuilles (noms de feuilles + 20 premières lignes, plafond 5 k caractères) — les mots tapés dans une cellule rendent le fichier trouvable ; au passage **BUG-089** (reindex manuel ne reconstruisait pas l'index inversé)
|
||||||
- [ ] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets`
|
- [x] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets` (v2.33.0)
|
||||||
- [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage)
|
- [x] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches) (v2.34.0)
|
||||||
- [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090
|
- [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090
|
||||||
- [x] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
|
- [x] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
|
||||||
- [x] **A10** Types & formats de saisie (nombre/texte, booléens `TRUE`/`FAUX`, dates FR `JJ/MM/AAAA` jour-first)
|
- [x] **A10** Types & formats de saisie (nombre/texte, booléens `TRUE`/`FAUX`, dates FR `JJ/MM/AAAA` jour-first)
|
||||||
- [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI
|
- [x] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI (JSDOM dans le job lint depuis v2.31.0 ; spec E2E livrée avec A9bis)
|
||||||
- [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un `<v>`, info-bulle FR/EN)
|
- [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un `<v>`, info-bulle FR/EN)
|
||||||
- **P2 — étendu (🟢, 2-4 j) — ⚪ à faire**
|
- **P2 — étendu (🟢, 2-4 j) — 🟢 livré**
|
||||||
- [ ] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection
|
- [x] **A13** Tri / filtre / recherche dans la feuille + export CSV de la sélection (v2.35.0)
|
||||||
- [ ] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer)
|
- [x] **A14** CRUD de feuilles, lignes et colonnes (renommer, insérer, supprimer, dupliquer) (v2.36.0)
|
||||||
- [ ] **A15** Styles minimaux en écriture + lecture fidèle (gras, fond, formats, fusions, volets figés)
|
- [x] **A15** Styles minimaux + lecture fidèle (gras, fond, formats, fusions, volets figés) (v2.37.0)
|
||||||
- [ ] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`, `.ods`, `.csv` éditable)
|
- [x] **A16** Formats additionnels (`.xlsm` avec `keep_vba`, `.xls`/`.ods` lecture seule via xlrd/odfpy, `.csv` éditable) (v2.38.0)
|
||||||
- [ ] **A17** Vue « tableau de bord » (plages nommées, TCD, KPI par feuille, actions IA)
|
- [x] **A17** Vue « tableau de bord » (plages nommées, TCD/graphiques, KPI par feuille, hint actions IA) (v2.39.0)
|
||||||
- **Convention de suivi :** chaque sous-tâche démarre par son ID stable (`#153-A<n>` dans cette
|
- **Convention de suivi :** chaque sous-tâche démarre par son ID stable (`#153-A<n>` dans cette
|
||||||
Roadmap) ; celles qui sont des **défauts** sont aussi ouvertes comme `BUG-NNN` dans
|
Roadmap) ; celles qui sont des **défauts** sont aussi ouvertes comme `BUG-NNN` dans
|
||||||
[ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) (A1→BUG-085, A2→BUG-086, A3→BUG-087, A4→BUG-088 ;
|
[ISSUES_TODOLIST.md](./ISSUES_TODOLIST.md) (A1→BUG-085, A2→BUG-086, A3→BUG-087, A4→BUG-088 ;
|
||||||
@@ -218,7 +218,7 @@
|
|||||||
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
|
| 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour |
|
||||||
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
| ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé |
|
||||||
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
| ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours |
|
||||||
| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; P1 ✅ A5, A8-A10, A12, A9bis — reste A6-A7 ; A13-A17 2-4 j) | 2-4 jours restants |
|
| ✅ Terminé | #153 Visionneuse & édition XLSX — complétude (A1-A17 **toutes livrées**, v2.27.0 → v2.39.0) | 0 jour restant |
|
||||||
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
|
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
|
||||||
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
|
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
|
||||||
> [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md))
|
> [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md))
|
||||||
> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17
|
> **Statut :** ✅ **Backlog terminé et livré le 2026-09-28** — P0 le 2026-09-27 (BUG-085 → BUG-088), A5/A10/A12 le 2026-09-28 (avec BUG-089), A8/A9/A9bis le 2026-09-28 (avec BUG-090), puis A6→A17 en v2.33.0 → v2.39.0 (A11 étant au CI depuis A5/A8)
|
||||||
> **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j)
|
> **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j)
|
||||||
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
> **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte
|
||||||
> l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
> l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
| CSS | `frontend/style.css:10927-10988` | `.xlsx-*` (variables CSS, colonne A `sticky`) |
|
| CSS | `frontend/style.css:10927-10988` | `.xlsx-*` (variables CSS, colonne A `sticky`) |
|
||||||
| Indexation | `backend/indexer.py:68, 563-568, 957-960` | `.xlsx` supporté, **métadonnées seules** (`content=""`) |
|
| Indexation | `backend/indexer.py:68, 563-568, 957-960` | `.xlsx` supporté, **métadonnées seules** (`content=""`) |
|
||||||
| Outils IA | `backend/tools/documents.py:66-89` + `schemas.py:296-305` | `create_xlsx` (WRITE + confirmation) — **création seule** |
|
| Outils IA | `backend/tools/documents.py:66-89` + `schemas.py:296-305` | `create_xlsx` (WRITE + confirmation) — **création seule** |
|
||||||
| Tests | `tests/test_xlsx_viewer.py` | 11 tests backend (affichage, index, save, backup, 400) |
|
| Tests | `tests/test_xlsx_viewer.py` (58) + `test_xlsx_styles.py` (9) + `test_xlsx_formats.py` (12) + `test_xlsx_dashboard.py` (8) + `test_xlsx_structure.py` (11) + `test_spreadsheet_tools.py` (17) · `tests/frontend/xlsx-viewer.test.mjs` (35) · `tests/e2e/xlsx-viewer.spec.js` (9) | Backend, JSDOM et E2E (chromium-desktop) |
|
||||||
|
|
||||||
## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention)
|
## 2. Ce qui est supporté aujourd'hui (livré, non concerné par #153 sauf mention)
|
||||||
|
|
||||||
@@ -46,6 +46,12 @@ vidée ; backup `.bak` avant écriture ; garde-fou vault read-only (403) ; `reso
|
|||||||
|
|
||||||
## 3. Limites connues (par couche)
|
## 3. Limites connues (par couche)
|
||||||
|
|
||||||
|
> **Note (2026-09-28)** : les limites ci-dessous décrivent l'état du jour de l'audit
|
||||||
|
> (2026-09-27). La quasi-totalité a été levée depuis par le backlog §5 (styles, navigation
|
||||||
|
> clavier, tri/filtre/recherche, structure, formats `.xlsm`/`.xls`/`.ods`/`.csv`, indexation,
|
||||||
|
> outils IA) — se reporter aux cases cochées et à l'historique §7 ; ne pas relire cette
|
||||||
|
> section comme l'état actuel.
|
||||||
|
|
||||||
### 3.1 Fidélité du round-trip — risque n°1
|
### 3.1 Fidélité du round-trip — risque n°1
|
||||||
|
|
||||||
`load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur
|
`load_workbook()` → `wb.save()` : ce qui est **réellement** perdu a été mesuré sur
|
||||||
@@ -135,7 +141,7 @@ couverture) · effort en jours-homme de développement + tests.
|
|||||||
nombres. Au passage : le handler `ServiceError` expose `code` + `details` et `api()` les
|
nombres. Au passage : le handler `ServiceError` expose `code` + `details` et `api()` les
|
||||||
propage sur l'Error. *Vérifié :* `TestXlsxFormulaGuard` (4) + test du toggle côté UI.
|
propage sur l'Error. *Vérifié :* `TestXlsxFormulaGuard` (4) + test du toggle côté UI.
|
||||||
|
|
||||||
### P1 — Recherche, IA, UX (4-6 j) — 🟢 A5, A10, A12 livrés le 2026-09-28
|
### P1 — Recherche, IA, UX (4-6 j) — 🟢 livré le 2026-09-28 (A5 → A12)
|
||||||
|
|
||||||
- [x] **A5 — Indexation du contenu des feuilles.** `extract_indexable_text()` (noms de feuilles +
|
- [x] **A5 — Indexation du contenu des feuilles.** `extract_indexable_text()` (noms de feuilles +
|
||||||
20 premières lignes, `MAX_INDEX_CHARS = 5 000`, 20 feuilles max) alimente le TF-IDF et la
|
20 premières lignes, `MAX_INDEX_CHARS = 5 000`, 20 feuilles max) alimente le TF-IDF et la
|
||||||
@@ -143,13 +149,14 @@ couverture) · effort en jours-homme de développement + tests.
|
|||||||
chiffré/corrompu s'indexe par son seul nom (jamais d'exception). Au passage : **BUG-089**,
|
chiffré/corrompu s'indexe par son seul nom (jamais d'exception). Au passage : **BUG-089**,
|
||||||
un reindex manuel ne reconstruisait pas l'index inversé. *Vérifié :* `TestXlsxSearchable` (4)
|
un reindex manuel ne reconstruisait pas l'index inversé. *Vérifié :* `TestXlsxSearchable` (4)
|
||||||
+ `TestXlsxIndexing`, **contre-preuve** (neutraliser l'extraction → 3 tests échouent).
|
+ `TestXlsxIndexing`, **contre-preuve** (neutraliser l'extraction → 3 tests échouent).
|
||||||
- [ ] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
|
- [x] **A6 — Outils IA sur classeur.** `update_xlsx_cells` (enveloppe du service existant),
|
||||||
`append_xlsx_rows`, `xlsx_to_markdown` (contexte LLM, plafonné), `list_xlsx_sheets` — risque
|
`append_xlsx_rows`, `xlsx_to_markdown` (contexte LLM, plafonné), `list_xlsx_sheets` — risque
|
||||||
WRITE + confirmation pour les mutations, libellés i18n dans `backend/tools/labels.py`,
|
WRITE + confirmation pour les mutations, libellés i18n dans `backend/tools/labels.py`,
|
||||||
refresh viewer via `obsigate:file-written`.
|
refresh viewer via `obsigate:file-written`. *Livré (v2.33.0) :* `backend/tools/spreadsheets.py`.
|
||||||
- [ ] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
|
*Vérifié :* `tests/test_spreadsheet_tools.py` (17).
|
||||||
|
- [x] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
|
||||||
active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible
|
active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible
|
||||||
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`).
|
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`). *Livré (v2.34.0).*
|
||||||
- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).**
|
- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).**
|
||||||
Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les
|
Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les
|
||||||
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
|
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
|
||||||
@@ -185,9 +192,11 @@ couverture) · effort en jours-homme de développement + tests.
|
|||||||
ressemblant à une formule n'est jamais convertie (BUG-088 préservé) ; un code postal
|
ressemblant à une formule n'est jamais convertie (BUG-088 préservé) ; un code postal
|
||||||
numérique ou une version restent ce qu'ils sont. *Vérifié :* `TestXlsxValueCoercion` (5),
|
numérique ou une version restent ce qu'ils sont. *Vérifié :* `TestXlsxValueCoercion` (5),
|
||||||
**contre-preuve** (neutraliser la coercion → 2 tests échouent).
|
**contre-preuve** (neutraliser la coercion → 2 tests échouent).
|
||||||
- [ ] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
|
- [x] **A11 — Tests frontend + E2E.** `tests/frontend/xlsx-viewer.test.mjs` (dirty, Échap,
|
||||||
collage, 1 PUT par feuille, bouton désactivé) et `tests/e2e/xlsx-viewer.spec.js`
|
collage, 1 PUT par feuille, bouton désactivé) et `tests/e2e/xlsx-viewer.spec.js`
|
||||||
(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI.
|
(ouverture, onglets, édition, sauvegarde, rechargement) ; intégration au CI. *Vérifié :*
|
||||||
|
35 tests JSDOM (le job CI `lint` lance `node xlsx-viewer.test.mjs`) et 9 E2E
|
||||||
|
chromium-desktop ; la couverture a grandi avec chaque sous-tâche (A5/A8 → P2).
|
||||||
- [x] **A12 — Valeurs calculées.** La valeur en cache s'affiche sous la formule dans un
|
- [x] **A12 — Valeurs calculées.** La valeur en cache s'affiche sous la formule dans un
|
||||||
`<span class="xlsx-cached">`. La 2ᵉ lecture `data_only=True` n'a lieu que si l'archive
|
`<span class="xlsx-cached">`. La 2ᵉ lecture `data_only=True` n'a lieu que si l'archive
|
||||||
contient réellement un `<f>…</f><v>…</v>` (sonde déjà présente pour A1) : le cas courant
|
contient réellement un `<f>…</f><v>…</v>` (sonde déjà présente pour A1) : le cas courant
|
||||||
@@ -196,17 +205,30 @@ couverture) · effort en jours-homme de développement + tests.
|
|||||||
d'interface n'est émis par le backend. *Vérifié :* `TestXlsxCachedValues` (3),
|
d'interface n'est émis par le backend. *Vérifié :* `TestXlsxCachedValues` (3),
|
||||||
**contre-preuve** (neutraliser la 2ᵉ lecture → 2 tests échouent).
|
**contre-preuve** (neutraliser la 2ᵉ lecture → 2 tests échouent).
|
||||||
|
|
||||||
### P2 — Étendu (2-4 j)
|
### P2 — Étendu (2-4 j) — 🟢 livré le 2026-09-28
|
||||||
|
|
||||||
- [ ] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
- [x] **A13 — Tri / filtre / recherche dans la feuille + export CSV de la sélection.**
|
||||||
- [ ] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
*Livré (v2.35.0) :* tout en manipulation d'affichage, le classeur n'est jamais réécrit
|
||||||
- [ ] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
|
(info-bulle `xlsx.sort_applied`).
|
||||||
|
- [x] **A14 — CRUD de feuilles et de lignes/colonnes** (renommer, insérer, supprimer, dupliquer).
|
||||||
|
*Livré (v2.36.0) :* `PUT …/xlsx/structure` + menu Structure, mêmes garde-fous que
|
||||||
|
l'édition de cellules. *Vérifié :* `tests/test_xlsx_structure.py` (11).
|
||||||
|
- [x] **A15 — Styles minimaux en écriture et lecture fidèle** (gras, fond, format
|
||||||
devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver `csv-table` comme
|
devise/pourcentage/date, cellules fusionnées, volets figés) ; conserver `csv-table` comme
|
||||||
socle de rendu.
|
socle de rendu. *Livré (v2.37.0) en lecture :* couleurs, gras/italique/souligné,
|
||||||
- [ ] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
alignements, fusions, ancre de volets figés ; un format de nombre personnalisé est signalé
|
||||||
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion.
|
en police mono (pas de rendu devise/pourcentage). L'application de styles **depuis la
|
||||||
- [ ] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
|
visionneuse** (écriture) reste hors périmètre. *Vérifié :* `tests/test_xlsx_styles.py` (9).
|
||||||
résumée (KPI par feuille) et proposal d'actions IA sur ces plages.
|
- [x] **A16 — Formats additionnels.** `.xlsm` (`keep_vba=True`), `.xls`, `.ods`, `.csv` éditable
|
||||||
|
comme tableur — dépendances à qualifier (`xlrd`/`odfpy`) ou conversion. *Livré (v2.38.0) :*
|
||||||
|
`.xlsm` éditable macros préservées, `.xls`/`.ods` lecture seule (xlrd/odfpy), `.csv`
|
||||||
|
éditable et réécrit RFC 4180. *Vérifié :* `tests/test_xlsx_formats.py` (12).
|
||||||
|
- [x] **A17 — Vue « tableau de bord ».** Détection des plages nommées, TCD et graphiques ; vue
|
||||||
|
résumée (KPI par feuille) et proposal d'actions IA sur ces plages. *Livré (v2.39.0) :*
|
||||||
|
panneau Tableau de bord (`GET …/xlsx/dashboard`) — plages nommées avec portée, comptage
|
||||||
|
graphiques/TCD par analyse des parties OPC, stats par feuille, 8 KPI ; le volet IA se
|
||||||
|
limite à un conseil contextuel (pas d'appel IA dédié sur les plages).
|
||||||
|
*Vérifié :* `tests/test_xlsx_dashboard.py` (8).
|
||||||
|
|
||||||
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
## 6. Règles de livraison (rappel `AGENTS.md` / `DELIVERY_WORKFLOW.md`)
|
||||||
|
|
||||||
@@ -233,3 +255,6 @@ couverture) · effort en jours-homme de développement + tests.
|
|||||||
| 2026-09-28 | **A5 + A10 + A12 livrés** : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. **BUG-089** corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; `backend/search.py` lisait l'index par valeur) |
|
| 2026-09-28 | **A5 + A10 + A12 livrés** : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. **BUG-089** corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; `backend/search.py` lisait l'index par valeur) |
|
||||||
| 2026-09-28 | **A8 + A9 livrés** (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et `GET …/xlsx/sheet` sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre |
|
| 2026-09-28 | **A8 + A9 livrés** (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et `GET …/xlsx/sheet` sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre |
|
||||||
| 2026-09-28 | **A9bis livré** : « Charger la suite » + sentinelle de défilement sous une feuille tronquée ; les lignes ajoutées sont éditables et sauvegardables immédiatement (même pipeline que le rendu initial) |
|
| 2026-09-28 | **A9bis livré** : « Charger la suite » + sentinelle de défilement sous une feuille tronquée ; les lignes ajoutées sont éditables et sauvegardables immédiatement (même pipeline que le rendu initial) |
|
||||||
|
| 2026-09-28 | **A6 + A7 livrés** (v2.33.0, v2.34.0) : l'assistant IA lit et modifie les classeurs (`list_xlsx_sheets`, `xlsx_to_markdown`, `update_xlsx_cells`, `append_xlsx_rows`) et la visionneuse gagne navigation clavier complète + barre de formule |
|
||||||
|
| 2026-09-28 | **A13 + A14 livrés** (v2.35.0, v2.36.0) : tri, filtre, recherche et export CSV côté affichage ; structure du classeur éditable (feuilles, lignes, colonnes) via `PUT …/xlsx/structure` |
|
||||||
|
| 2026-09-28 | **A15 + A16 + A17 livrés** (v2.37.0 → v2.39.0) : styles/fusions/volets figés rendus, formats `.xlsm`/`.xls`/`.ods`/`.csv` gérés, panneau Tableau de bord (plages nommées, graphiques/TCD, stats, KPI) — **backlog #153 terminé** |
|
||||||
|
|||||||
@@ -60,12 +60,14 @@ const MUTATING_TOOLS = new Set([
|
|||||||
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
|
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
|
||||||
'delete_file', 'delete_directory', 'restore_backup',
|
'delete_file', 'delete_directory', 'restore_backup',
|
||||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||||
|
'update_xlsx_cells', 'append_xlsx_rows',
|
||||||
]);
|
]);
|
||||||
// Subset carrying a concrete `vault` + `path`: the displayed document is
|
// Subset carrying a concrete `vault` + `path`: the displayed document is
|
||||||
// reloaded from disk so an open viewer/editor reflects the agent's write.
|
// reloaded from disk so an open viewer/editor reflects the agent's write.
|
||||||
const FILE_WRITE_TOOLS = new Set([
|
const FILE_WRITE_TOOLS = new Set([
|
||||||
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
|
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
|
||||||
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
|
||||||
|
'update_xlsx_cells', 'append_xlsx_rows',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+597
-15
@@ -1037,6 +1037,11 @@ function truncationNote(sheet) {
|
|||||||
export function renderXlsxViewer(area, data) {
|
export function renderXlsxViewer(area, data) {
|
||||||
const sheets = data.xlsx_sheets || [];
|
const sheets = data.xlsx_sheets || [];
|
||||||
const lossy = data.xlsx_lossy_features || [];
|
const lossy = data.xlsx_lossy_features || [];
|
||||||
|
// #153 A16 — mode flags: a .csv rides the same viewer with a flat
|
||||||
|
// structure (no tabs/merges/freeze to wire) and its own save endpoint;
|
||||||
|
// .xls/.ods are served read-only (no editing, no structure ops).
|
||||||
|
const isCsv = Boolean(data.is_csv);
|
||||||
|
const readOnly = Boolean(data.xlsx_readonly);
|
||||||
// Session-scoped state: once the lossy write is confirmed, the rest of the
|
// Session-scoped state: once the lossy write is confirmed, the rest of the
|
||||||
// session saves without asking again (never persisted — a confirmation is
|
// session saves without asking again (never persisted — a confirmation is
|
||||||
// per workbook, not a global preference).
|
// per workbook, not a global preference).
|
||||||
@@ -1047,9 +1052,64 @@ export function renderXlsxViewer(area, data) {
|
|||||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
||||||
).join("")}</div>`
|
).join("")}</div>`
|
||||||
: "";
|
: "";
|
||||||
const panels = sheets.map((s, i) =>
|
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
|
||||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
|
||||||
).join("");
|
// (the HTML string alone cannot carry them: col/row spans need the table).
|
||||||
|
const applySheetMeta = (panel) => {
|
||||||
|
const meta = sheets[Number(panel.dataset.sheet)] || {};
|
||||||
|
const styleMap = meta.styles || {};
|
||||||
|
const alignMap = meta.aligns || {};
|
||||||
|
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
|
||||||
|
const ref = td.dataset.cell;
|
||||||
|
const style = styleMap[ref];
|
||||||
|
if (style) td.setAttribute("style", style);
|
||||||
|
const align = alignMap[ref];
|
||||||
|
if (align) td.style.textAlign = align;
|
||||||
|
});
|
||||||
|
// Merged ranges: span the anchor cell over the range and hide the covered
|
||||||
|
// cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by
|
||||||
|
// the save collector because their text was already blank in openpyxl).
|
||||||
|
(meta.merges || []).forEach((range) => {
|
||||||
|
const [from, to] = range.split(":");
|
||||||
|
const anchor = panel.querySelector(`td[data-cell="${from}"]`);
|
||||||
|
const last = panel.querySelector(`td[data-cell="${to}"]`);
|
||||||
|
if (!anchor || !last || anchor === last) return;
|
||||||
|
const a = parseRef(from);
|
||||||
|
const b = parseRef(to);
|
||||||
|
if (!a || !b) return;
|
||||||
|
anchor.rowSpan = Math.max(1, b.row - a.row + 1);
|
||||||
|
anchor.colSpan = Math.max(1, b.col - a.col + 1);
|
||||||
|
// Hide every covered cell of the block.
|
||||||
|
for (let r = a.row; r <= b.row; r++) {
|
||||||
|
for (let c = a.col; c <= b.col; c++) {
|
||||||
|
if (r === a.row && c === a.col) continue;
|
||||||
|
const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`);
|
||||||
|
if (covered) covered.style.display = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Freeze panes: sticky the rows/cols left of and above the anchor.
|
||||||
|
const freeze = meta.freeze;
|
||||||
|
if (freeze && freeze !== "A1") {
|
||||||
|
const f = parseRef(freeze);
|
||||||
|
if (f) {
|
||||||
|
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
|
||||||
|
const ref = tr.querySelector("td")?.dataset.cell || "";
|
||||||
|
const p = parseRef(ref);
|
||||||
|
if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row");
|
||||||
|
});
|
||||||
|
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
|
||||||
|
const p = parseRef(td.dataset.cell);
|
||||||
|
if (p && p.col < f.col) td.classList.add("xlsx-frozen-col");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const panels = isCsv
|
||||||
|
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
|
||||||
|
: sheets.map((s, i) =>
|
||||||
|
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
||||||
|
).join("");
|
||||||
const lossWarning = lossy.length
|
const lossWarning = lossy.length
|
||||||
? `<div class="xlsx-warning" role="note">
|
? `<div class="xlsx-warning" role="note">
|
||||||
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
|
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
|
||||||
@@ -1070,16 +1130,101 @@ export function renderXlsxViewer(area, data) {
|
|||||||
<span class="xlsx-toolbar-actions">
|
<span class="xlsx-toolbar-actions">
|
||||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||||
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
<button class="btn-action" id="xlsx-save-btn" disabled${isCsv || readOnly ? "" : ""}>${t("common.save")}</button>
|
||||||
<button class="btn-action" id="xlsx-download-btn">
|
<button class="btn-action" id="xlsx-download-btn">
|
||||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||||
|
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||||
|
</button>
|
||||||
|
${isCsv || readOnly ? "" : `<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||||
|
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||||
|
</button>`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${lossWarning}
|
${lossWarning}
|
||||||
|
<div class="xlsx-formula-bar">
|
||||||
|
<span class="xlsx-active-cell" id="xlsx-active-cell">A1</span>
|
||||||
|
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
||||||
|
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||||||
|
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||||||
|
<span class="xlsx-find-group">
|
||||||
|
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
|
||||||
|
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
|
||||||
|
<span class="xlsx-find-count" id="xlsx-find-count"></span>
|
||||||
|
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
|
||||||
|
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
|
||||||
|
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
|
||||||
|
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
|
||||||
|
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="xlsx-panels">${panels}</div>
|
<div class="xlsx-panels">${panels}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
|
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
|
||||||
|
// Fetched lazily on first open of the dashboard tab; a workbook without
|
||||||
|
// anything notable hides the button entirely (no empty panel).
|
||||||
|
if (!isCsv && !readOnly) {
|
||||||
|
const dashBtn = el("button", {
|
||||||
|
class: "btn-action xlsx-dash-btn",
|
||||||
|
id: "xlsx-dashboard-btn",
|
||||||
|
type: "button",
|
||||||
|
title: t("xlsx.dashboard_btn"),
|
||||||
|
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
|
||||||
|
area.querySelector(".xlsx-toolbar-actions").prepend(dashBtn);
|
||||||
|
dashBtn.addEventListener("click", async () => {
|
||||||
|
const old = area.querySelector(".xlsx-dashboard");
|
||||||
|
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
|
||||||
|
dashBtn.classList.add("active");
|
||||||
|
const panel = document.createElement("div");
|
||||||
|
panel.className = "xlsx-dashboard";
|
||||||
|
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
|
||||||
|
area.querySelector(".xlsx-panels").prepend(panel);
|
||||||
|
try {
|
||||||
|
const dash = await api(
|
||||||
|
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
|
||||||
|
);
|
||||||
|
const rangeRows = (dash.named_ranges || []).map((r) =>
|
||||||
|
`<tr><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
|
||||||
|
).join("");
|
||||||
|
const kpiCards = (dash.sheets || []).map((s) => {
|
||||||
|
const cards = (s.kpi || []).map((k) =>
|
||||||
|
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
|
||||||
|
).join("");
|
||||||
|
return `<div class="xlsx-kpi-sheet">
|
||||||
|
<h4>${escapeHtml(s.name)}</h4>
|
||||||
|
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
|
||||||
|
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
|
||||||
|
}))}</p>
|
||||||
|
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
panel.innerHTML = `
|
||||||
|
<div class="xlsx-dashboard-head">
|
||||||
|
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
|
||||||
|
<span class="xlsx-dashboard-objects">
|
||||||
|
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
|
||||||
|
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${(dash.named_ranges || []).length || (dash.sheets || []).length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
|
||||||
|
${(dash.named_ranges || []).length ? `
|
||||||
|
<table class="csv-table xlsx-ranges-table"><thead><tr>
|
||||||
|
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
|
||||||
|
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
|
||||||
|
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
|
||||||
|
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
|
||||||
|
<div class="xlsx-kpi-grid">${kpiCards}</div>
|
||||||
|
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
|
||||||
|
safeCreateIcons();
|
||||||
|
} catch (err) {
|
||||||
|
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const saveBtn = area.querySelector("#xlsx-save-btn");
|
const saveBtn = area.querySelector("#xlsx-save-btn");
|
||||||
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
|
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
|
||||||
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
|
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
|
||||||
@@ -1128,6 +1273,20 @@ export function renderXlsxViewer(area, data) {
|
|||||||
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
||||||
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
||||||
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
|
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
|
||||||
|
// #153 A15 — the window carries its own style/merge metadata: fold it
|
||||||
|
// into the sheet meta and re-apply, so lazy rows look like the initial
|
||||||
|
// render (applySheetMeta is idempotent over already-processed cells).
|
||||||
|
const sheetMeta = sheets[Number(panel.dataset.sheet)];
|
||||||
|
if (sheetMeta) {
|
||||||
|
Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {});
|
||||||
|
Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {});
|
||||||
|
if (!sheetMeta.merges) sheetMeta.merges = [];
|
||||||
|
(win.merges || []).forEach((m) => {
|
||||||
|
if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m);
|
||||||
|
});
|
||||||
|
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
|
||||||
|
applySheetMeta(panel);
|
||||||
|
}
|
||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1158,21 +1317,135 @@ export function renderXlsxViewer(area, data) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ── #153 A7 — keyboard navigation & formula bar ────────────────────────
|
||||||
|
// One active cell per viewer: clicking or arrowing into a cell shows its
|
||||||
|
// A1 name in the bar; the input mirrors the cell text and typing there
|
||||||
|
// edits the cell live (Enter commits, Escape reverts, then re-focuses).
|
||||||
|
let activeTd = null;
|
||||||
|
const activeCellEl = area.querySelector("#xlsx-active-cell");
|
||||||
|
const formulaInput = area.querySelector("#xlsx-formula-input");
|
||||||
|
const syncing = { value: false }; // guard against input-event feedback loops
|
||||||
|
|
||||||
|
const cellName = (td) => td?.dataset.cell || "";
|
||||||
|
|
||||||
|
const setActiveCell = (td) => {
|
||||||
|
activeTd = td || null;
|
||||||
|
if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
|
||||||
|
if (!td) {
|
||||||
|
activeCellEl.textContent = "—";
|
||||||
|
formulaInput.value = "";
|
||||||
|
formulaInput.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
td.classList.add("xlsx-active");
|
||||||
|
activeCellEl.textContent = cellName(td);
|
||||||
|
formulaInput.disabled = false;
|
||||||
|
formulaInput.value = td.textContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncFormulaBar = (td) => {
|
||||||
|
if (td !== activeTd) return;
|
||||||
|
if (!syncing.value) formulaInput.value = td.textContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Parse an A1 reference into its (row, col) parts.
|
||||||
|
const parseRef = (ref) => {
|
||||||
|
const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
|
||||||
|
if (!m) return null;
|
||||||
|
let col = 0;
|
||||||
|
for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
|
||||||
|
return { row: Number(m[2]), col };
|
||||||
|
};
|
||||||
|
|
||||||
|
const findTd = (panel, row, col) =>
|
||||||
|
panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
|
||||||
|
|
||||||
|
const columnName = (col) => {
|
||||||
|
let name = "";
|
||||||
|
while (col > 0) {
|
||||||
|
const rem = (col - 1) % 26;
|
||||||
|
name = String.fromCharCode(65 + rem) + name;
|
||||||
|
col = Math.floor((col - 1) / 26);
|
||||||
|
}
|
||||||
|
return name;
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveActive = (td, key, forward = true) => {
|
||||||
|
const ref = parseRef(cellName(td));
|
||||||
|
if (!ref) return;
|
||||||
|
let { row, col } = ref;
|
||||||
|
if (key === "Tab") col += forward ? 1 : -1;
|
||||||
|
else if (key === "ArrowRight") col += 1;
|
||||||
|
else if (key === "ArrowLeft") col -= 1;
|
||||||
|
else if (key === "ArrowDown") row += 1;
|
||||||
|
else if (key === "ArrowUp") row -= 1;
|
||||||
|
if (row < 1 || col < 1) return;
|
||||||
|
const panel = td.closest(".xlsx-panel");
|
||||||
|
const next = findTd(panel, row, col);
|
||||||
|
if (!next) return; // edge of the rendered window: no wrap
|
||||||
|
td.blur();
|
||||||
|
next.focus();
|
||||||
|
setActiveCell(next);
|
||||||
|
};
|
||||||
|
|
||||||
|
formulaInput.addEventListener("input", () => {
|
||||||
|
if (!activeTd) return;
|
||||||
|
syncing.value = true;
|
||||||
|
activeTd.textContent = formulaInput.value;
|
||||||
|
syncing.value = false;
|
||||||
|
activeTd.classList.add("xlsx-dirty");
|
||||||
|
refreshSaveState();
|
||||||
|
});
|
||||||
|
formulaInput.addEventListener("keydown", (e) => {
|
||||||
|
if (!activeTd) return;
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
activeTd.blur();
|
||||||
|
activeTd.focus();
|
||||||
|
} else if (e.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
activeTd.textContent = activeTd.dataset.orig;
|
||||||
|
activeTd.classList.remove("xlsx-dirty");
|
||||||
|
syncFormulaBar(activeTd);
|
||||||
|
refreshSaveState();
|
||||||
|
activeTd.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const setupCell = (td) => {
|
const setupCell = (td) => {
|
||||||
td.contentEditable = "true";
|
td.contentEditable = "true";
|
||||||
td.spellcheck = false;
|
td.spellcheck = false;
|
||||||
|
// Focusable without a pointing device: JSDOM requires it to fire focus
|
||||||
|
// events on contenteditable cells, and a keyboard user tabbing into the
|
||||||
|
// table from outside lands on the first cell thanks to it.
|
||||||
|
td.tabIndex = 0;
|
||||||
td.dataset.orig = td.textContent;
|
td.dataset.orig = td.textContent;
|
||||||
td.addEventListener("input", () => {
|
td.addEventListener("input", () => {
|
||||||
td.classList.add("xlsx-dirty");
|
td.classList.add("xlsx-dirty");
|
||||||
|
syncFormulaBar(td);
|
||||||
refreshSaveState();
|
refreshSaveState();
|
||||||
});
|
});
|
||||||
|
td.addEventListener("focus", () => setActiveCell(td));
|
||||||
td.addEventListener("keydown", (e) => {
|
td.addEventListener("keydown", (e) => {
|
||||||
if (e.key === "Enter") { e.preventDefault(); td.blur(); }
|
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
|
||||||
if (e.key === "Escape") {
|
if (e.key === "Escape") {
|
||||||
td.textContent = td.dataset.orig;
|
td.textContent = td.dataset.orig;
|
||||||
td.classList.remove("xlsx-dirty");
|
td.classList.remove("xlsx-dirty");
|
||||||
|
syncFormulaBar(td);
|
||||||
refreshSaveState();
|
refreshSaveState();
|
||||||
}
|
}
|
||||||
|
if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown"
|
||||||
|
|| e.key === "ArrowLeft" || e.key === "ArrowRight") {
|
||||||
|
// 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
|
||||||
|
// move the caret (they move the SELECTION instead).
|
||||||
|
e.preventDefault();
|
||||||
|
const forward = e.key === "Tab"
|
||||||
|
? !e.shiftKey
|
||||||
|
: (e.key === "ArrowRight" || e.key === "ArrowDown");
|
||||||
|
moveActive(td, e.key, forward);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
td.addEventListener("paste", (e) => {
|
td.addEventListener("paste", (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1187,9 +1460,18 @@ export function renderXlsxViewer(area, data) {
|
|||||||
el.title = t("xlsx.cached_value_title");
|
el.title = t("xlsx.cached_value_title");
|
||||||
});
|
});
|
||||||
|
|
||||||
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
|
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
|
||||||
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
// Read-only formats (.xls/.ods) skip the wiring entirely — the toolbar
|
||||||
|
// save button is disabled and the structure menu does not exist.
|
||||||
|
if (readOnly) {
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
} else {
|
||||||
|
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||||||
|
}
|
||||||
panelEls.forEach(wireLazyRows);
|
panelEls.forEach(wireLazyRows);
|
||||||
|
panelEls.forEach(applySheetMeta);
|
||||||
|
// The formula bar starts disabled: nothing is selected yet.
|
||||||
|
setActiveCell(null);
|
||||||
|
|
||||||
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
|
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
|
||||||
tab.addEventListener("click", () => {
|
tab.addEventListener("click", () => {
|
||||||
@@ -1207,14 +1489,23 @@ export function renderXlsxViewer(area, data) {
|
|||||||
formulaBtn.classList.toggle("active", allowFormula);
|
formulaBtn.classList.toggle("active", allowFormula);
|
||||||
});
|
});
|
||||||
|
|
||||||
const putSheet = (job, force) => api(
|
const putSheet = isCsv
|
||||||
`/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`,
|
? (job, force) => api(
|
||||||
{
|
`/api/file/${encodeURIComponent(data.vault)}/csv/save?path=${encodeURIComponent(data.path)}`,
|
||||||
method: "PUT",
|
{
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "PUT",
|
||||||
body: JSON.stringify({ ...job, allow_formula: allowFormula, force }),
|
headers: { "Content-Type": "application/json" },
|
||||||
},
|
body: JSON.stringify({ cells: job.cells }),
|
||||||
);
|
},
|
||||||
|
)
|
||||||
|
: (job, force) => 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 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
saveBtn.addEventListener("click", async () => {
|
saveBtn.addEventListener("click", async () => {
|
||||||
// One PUT per sheet (dirty cells can span tabs before a save).
|
// One PUT per sheet (dirty cells can span tabs before a save).
|
||||||
@@ -1253,6 +1544,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
td.classList.remove("xlsx-dirty");
|
td.classList.remove("xlsx-dirty");
|
||||||
td.dataset.orig = td.textContent;
|
td.dataset.orig = td.textContent;
|
||||||
});
|
});
|
||||||
|
if (activeTd) syncFormulaBar(activeTd);
|
||||||
refreshSaveState();
|
refreshSaveState();
|
||||||
showToast(t("editor.saved"), "success");
|
showToast(t("editor.saved"), "success");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -1270,6 +1562,289 @@ export function renderXlsxViewer(area, data) {
|
|||||||
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── #153 A13 — sort / filter / find in the sheet + CSV export ──────────
|
||||||
|
// ALL of these act on the RENDERED grid only: the workbook is never
|
||||||
|
// rewritten by a sort or a filter (the save pipeline stays the only write
|
||||||
|
// path, and the note in xlsx.sort_applied says so).
|
||||||
|
const visiblePanel = () =>
|
||||||
|
panelEls.find((p) => p.style.display !== "none") || panelEls[0];
|
||||||
|
|
||||||
|
// Sorting reorders <tr> rows by the text of one column. Dirty cells travel
|
||||||
|
// with their row, so a sort never loses an unsaved edit.
|
||||||
|
let sortState = null; // { col, dir } on the visible panel
|
||||||
|
|
||||||
|
const applySort = (panel, col, dir) => {
|
||||||
|
const tbody = panel.querySelector(".xlsx-table tbody");
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = [...tbody.querySelectorAll("tr")];
|
||||||
|
const numeric = rows.every((tr) => {
|
||||||
|
const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||||
|
const v = td ? td.textContent.trim() : "";
|
||||||
|
return v === "" || !isNaN(Number(v));
|
||||||
|
});
|
||||||
|
rows.sort((a, b) => {
|
||||||
|
const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||||
|
const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`);
|
||||||
|
const va = ta ? ta.textContent.trim() : "";
|
||||||
|
const vb = tb ? tb.textContent.trim() : "";
|
||||||
|
const cmp = numeric
|
||||||
|
? (parseFloat(va) || 0) - (parseFloat(vb) || 0)
|
||||||
|
: va.localeCompare(vb, "fr");
|
||||||
|
return dir === "asc" ? cmp : -cmp;
|
||||||
|
});
|
||||||
|
rows.forEach((tr) => tbody.appendChild(tr));
|
||||||
|
sortState = { col, dir };
|
||||||
|
sortResetBtn.style.display = "";
|
||||||
|
showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info");
|
||||||
|
};
|
||||||
|
|
||||||
|
// Header click cycles: asc → desc → back to the sheet order.
|
||||||
|
const wireHeaderSort = (panel) => {
|
||||||
|
const thead = panel.querySelector(".xlsx-table thead");
|
||||||
|
if (!thead || thead.dataset.sortWired) return;
|
||||||
|
thead.dataset.sortWired = "1";
|
||||||
|
thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => {
|
||||||
|
th.style.cursor = "pointer";
|
||||||
|
th.title = t("xlsx.sort_asc");
|
||||||
|
th.addEventListener("click", () => {
|
||||||
|
// The corner th is column 0, so the child index IS the column number.
|
||||||
|
const col = [...th.parentElement.children].indexOf(th);
|
||||||
|
const current = sortState && sortState.col === col ? sortState.dir : null;
|
||||||
|
const dir = current === "asc" ? "desc" : "asc";
|
||||||
|
applySort(panel, col, dir);
|
||||||
|
th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter: rows whose cell text lacks the needle are hidden.
|
||||||
|
const applyFilter = (panel, needle) => {
|
||||||
|
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 = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
panelEls.forEach(wireHeaderSort);
|
||||||
|
|
||||||
|
const sortResetBtn = area.querySelector("#xlsx-sort-reset");
|
||||||
|
sortResetBtn.addEventListener("click", () => {
|
||||||
|
// Full reset: re-render the current file (server truth), clear find too.
|
||||||
|
sortResetBtn.style.display = "none";
|
||||||
|
renderXlsxViewer(area, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find-in-sheet: highlight matches, navigate with ↑/↓.
|
||||||
|
let findMatches = [];
|
||||||
|
let findIndex = -1;
|
||||||
|
const findInput = area.querySelector("#xlsx-find-input");
|
||||||
|
const findCount = area.querySelector("#xlsx-find-count");
|
||||||
|
let findCase = false;
|
||||||
|
|
||||||
|
const clearFind = () => {
|
||||||
|
findMatches.forEach(({ td, mark }) => {
|
||||||
|
mark.replaceWith(document.createTextNode(mark.textContent));
|
||||||
|
td.normalize();
|
||||||
|
});
|
||||||
|
findMatches = [];
|
||||||
|
findIndex = -1;
|
||||||
|
findCount.textContent = "";
|
||||||
|
};
|
||||||
|
|
||||||
|
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 <mark> 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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
findCount.textContent = findMatches.length
|
||||||
|
? t("xlsx.find_count", { index: 1, count: findMatches.length })
|
||||||
|
: t("xlsx.find_no_match");
|
||||||
|
if (findMatches.length) focusMatch(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const focusMatch = (i) => {
|
||||||
|
findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current"));
|
||||||
|
findIndex = (i + findMatches.length) % findMatches.length;
|
||||||
|
const { td, mark } = findMatches[findIndex];
|
||||||
|
mark.classList.add("xlsx-find-current");
|
||||||
|
// 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 });
|
||||||
|
};
|
||||||
|
|
||||||
|
// One input drives both the highlight (find) and the row filter (A13):
|
||||||
|
// typing narrows the sheet to the matching rows AND highlights the hits.
|
||||||
|
findInput.addEventListener("input", () => {
|
||||||
|
panelEls.forEach((p) => applyFilter(p, findInput.value));
|
||||||
|
runFind();
|
||||||
|
});
|
||||||
|
findInput.addEventListener("keydown", (e) => {
|
||||||
|
if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); }
|
||||||
|
});
|
||||||
|
area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1));
|
||||||
|
area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1));
|
||||||
|
const caseBtn = area.querySelector("#xlsx-find-case");
|
||||||
|
caseBtn.addEventListener("click", () => {
|
||||||
|
findCase = !findCase;
|
||||||
|
caseBtn.setAttribute("aria-pressed", String(findCase));
|
||||||
|
caseBtn.classList.toggle("active", findCase);
|
||||||
|
runFind();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
|
||||||
|
// Every action is an explicit user gesture (prompt/confirm) and goes to
|
||||||
|
// PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
|
||||||
|
const visibleSheetIndex = () =>
|
||||||
|
Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
|
||||||
|
|
||||||
|
const putStructure = async (actions, force = false) => {
|
||||||
|
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 }),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
showToast(t("xlsx.structure_saved"), "success");
|
||||||
|
// 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);
|
||||||
|
};
|
||||||
|
|
||||||
|
const structureError = (err, retryActions) => {
|
||||||
|
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(", ");
|
||||||
|
if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
|
||||||
|
return putStructure(retryActions, true); // re-emitted with force
|
||||||
|
}
|
||||||
|
showToast(t("xlsx.lossy_cancelled"), "info");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error");
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Structure menu: built on demand, positioned under the button.
|
||||||
|
// (.csv / read-only workbooks have no such button — the menu block above
|
||||||
|
// is skipped for them.)
|
||||||
|
const structureBtn = area.querySelector("#xlsx-structure-btn");
|
||||||
|
if (structureBtn) structureBtn.addEventListener("click", (e) => {
|
||||||
|
const old = area.querySelector(".xlsx-structure-menu");
|
||||||
|
if (old) { old.remove(); return; }
|
||||||
|
const idx = visibleSheetIndex();
|
||||||
|
const sheetName = sheets[idx]?.name || "";
|
||||||
|
const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
|
||||||
|
const parsed = parseRef(activeRef);
|
||||||
|
const menu = document.createElement("div");
|
||||||
|
menu.className = "xlsx-structure-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);
|
||||||
|
};
|
||||||
|
item(t("xlsx.sheet_add"), async () => {
|
||||||
|
const name = prompt(t("xlsx.structure_prompt_add"));
|
||||||
|
if (!name) return;
|
||||||
|
const actions = [{ op: "sheet_add", name }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
item(t("xlsx.sheet_rename"), async () => {
|
||||||
|
const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
|
||||||
|
if (!to || to === sheetName) return;
|
||||||
|
const actions = [{ op: "sheet_rename", from: sheetName, to }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
item(t("xlsx.sheet_duplicate"), async () => {
|
||||||
|
const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
|
||||||
|
if (!as) return;
|
||||||
|
const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
item(t("xlsx.sheet_delete"), async () => {
|
||||||
|
if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
|
||||||
|
if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
|
||||||
|
const actions = [{ op: "sheet_delete", name: sheetName }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
if (parsed) {
|
||||||
|
menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
|
||||||
|
item(t("xlsx.row_insert"), async () => {
|
||||||
|
const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
item(t("xlsx.row_delete"), async () => {
|
||||||
|
if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
|
||||||
|
const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
item(t("xlsx.col_insert"), async () => {
|
||||||
|
const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
item(t("xlsx.col_delete"), async () => {
|
||||||
|
if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
|
||||||
|
const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
|
||||||
|
try { await putStructure(actions); } catch (err) { structureError(err, actions); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
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", () => {
|
||||||
|
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 a = document.createElement("a");
|
||||||
|
a.href = URL.createObjectURL(blob);
|
||||||
|
a.download = `${sheets[idx]?.name || "feuille"}.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(a.href);
|
||||||
|
});
|
||||||
|
|
||||||
safeCreateIcons();
|
safeCreateIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1347,6 +1922,13 @@ export function renderFile(data) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle CSV — same spreadsheet viewer, flat mode (#153 A16): the backend
|
||||||
|
// renders the grid with data-cell refs and PUT …/csv/save persists edits.
|
||||||
|
if (data.is_csv) {
|
||||||
|
renderXlsxViewer(area, data);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Handle Excalidraw — render in iframe editor
|
// Handle Excalidraw — render in iframe editor
|
||||||
if (data.is_excalidraw) {
|
if (data.is_excalidraw) {
|
||||||
renderExcalidraw(area, data, data.vault, data.path);
|
renderExcalidraw(area, data, data.vault, data.path);
|
||||||
|
|||||||
@@ -1836,6 +1836,48 @@
|
|||||||
"xlsx.load_more": "Load more",
|
"xlsx.load_more": "Load more",
|
||||||
"xlsx.loading_more": "Loading…",
|
"xlsx.loading_more": "Loading…",
|
||||||
"xlsx.load_error": "Could not load the remaining rows",
|
"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.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.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",
|
||||||
|
"xlsx.sort_reset": "Reset sort and filter",
|
||||||
|
"xlsx.filter_placeholder": "Filter rows…",
|
||||||
|
"xlsx.structure_btn": "Sheet structure",
|
||||||
|
"xlsx.structure_title": "Edit the workbook structure",
|
||||||
|
"xlsx.sheet_add": "Add a sheet",
|
||||||
|
"xlsx.sheet_rename": "Rename the current sheet",
|
||||||
|
"xlsx.sheet_duplicate": "Duplicate the current sheet",
|
||||||
|
"xlsx.sheet_delete": "Delete the current sheet",
|
||||||
|
"xlsx.row_insert": "Insert a row above",
|
||||||
|
"xlsx.row_delete": "Delete the active cell's row",
|
||||||
|
"xlsx.col_insert": "Insert a column to the left",
|
||||||
|
"xlsx.col_delete": "Delete the active cell's column",
|
||||||
|
"xlsx.structure_prompt_add": "Name of the new sheet:",
|
||||||
|
"xlsx.structure_prompt_rename": "New name of the sheet:",
|
||||||
|
"xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
|
||||||
|
"xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
|
||||||
|
"xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
|
||||||
|
"xlsx.structure_saved": "Structure updated",
|
||||||
|
"xlsx.structure_error": "Could not change the structure",
|
||||||
|
"xlsx.last_sheet": "The last sheet cannot be deleted",
|
||||||
|
"xlsx.dashboard_btn": "Dashboard",
|
||||||
|
"xlsx.dashboard_title": "Workbook dashboard",
|
||||||
|
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
|
||||||
|
"xlsx.dashboard_charts": "{{n}} chart(s)",
|
||||||
|
"xlsx.dashboard_pivots": "{{n}} pivot tables",
|
||||||
|
"xlsx.dashboard_empty": "No named range or usable data in this workbook.",
|
||||||
|
"xlsx.dashboard_nr_name": "Name",
|
||||||
|
"xlsx.dashboard_nr_scope": "Scope",
|
||||||
|
"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": "cached values",
|
||||||
"xlsx.feature_slicers": "slicers and timelines",
|
"xlsx.feature_slicers": "slicers and timelines",
|
||||||
"xlsx.feature_form_controls": "form controls",
|
"xlsx.feature_form_controls": "form controls",
|
||||||
@@ -2008,6 +2050,10 @@
|
|||||||
"ai.step.git_issues": "Searched issues: {value}",
|
"ai.step.git_issues": "Searched issues: {value}",
|
||||||
"ai.step.git_file": "Read a repo file: {value}",
|
"ai.step.git_file": "Read a repo file: {value}",
|
||||||
"ai.step.xlsx_create": "Spreadsheet proposed: {value}",
|
"ai.step.xlsx_create": "Spreadsheet proposed: {value}",
|
||||||
|
"ai.step.xlsx_sheets": "Workbook sheets listed: {value}",
|
||||||
|
"ai.step.xlsx_read": "Workbook read: {value}",
|
||||||
|
"ai.step.xlsx_update": "Cells edited: {value}",
|
||||||
|
"ai.step.xlsx_append": "Rows appended: {value}",
|
||||||
"ai.step.docx_create": "Word document proposed: {value}",
|
"ai.step.docx_create": "Word document proposed: {value}",
|
||||||
"ai.step.csv_create": "CSV file proposed: {value}",
|
"ai.step.csv_create": "CSV file proposed: {value}",
|
||||||
"ai.step.pdf_create": "PDF document proposed: {value}",
|
"ai.step.pdf_create": "PDF document proposed: {value}",
|
||||||
|
|||||||
@@ -1836,6 +1836,48 @@
|
|||||||
"xlsx.load_more": "Charger la suite",
|
"xlsx.load_more": "Charger la suite",
|
||||||
"xlsx.loading_more": "Chargement…",
|
"xlsx.loading_more": "Chargement…",
|
||||||
"xlsx.load_error": "Chargement de la suite impossible",
|
"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.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.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é",
|
||||||
|
"xlsx.sort_reset": "Réinitialiser le tri et le filtre",
|
||||||
|
"xlsx.filter_placeholder": "Filtrer les lignes…",
|
||||||
|
"xlsx.structure_btn": "Structure de la feuille",
|
||||||
|
"xlsx.structure_title": "Modifier la structure du classeur",
|
||||||
|
"xlsx.sheet_add": "Ajouter une feuille",
|
||||||
|
"xlsx.sheet_rename": "Renommer la feuille courante",
|
||||||
|
"xlsx.sheet_duplicate": "Dupliquer la feuille courante",
|
||||||
|
"xlsx.sheet_delete": "Supprimer la feuille courante",
|
||||||
|
"xlsx.row_insert": "Insérer une ligne au-dessus",
|
||||||
|
"xlsx.row_delete": "Supprimer la ligne de la cellule active",
|
||||||
|
"xlsx.col_insert": "Insérer une colonne à gauche",
|
||||||
|
"xlsx.col_delete": "Supprimer la colonne de la cellule active",
|
||||||
|
"xlsx.structure_prompt_add": "Nom de la nouvelle feuille :",
|
||||||
|
"xlsx.structure_prompt_rename": "Nouveau nom de la feuille :",
|
||||||
|
"xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
|
||||||
|
"xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||||
|
"xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
|
||||||
|
"xlsx.structure_saved": "Structure mise à jour",
|
||||||
|
"xlsx.structure_error": "Modification de la structure impossible",
|
||||||
|
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
|
||||||
|
"xlsx.dashboard_btn": "Tableau de bord",
|
||||||
|
"xlsx.dashboard_title": "Tableau de bord du classeur",
|
||||||
|
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
|
||||||
|
"xlsx.dashboard_charts": "{{n}} graphique(s)",
|
||||||
|
"xlsx.dashboard_pivots": "{{n}} TCD",
|
||||||
|
"xlsx.dashboard_empty": "Aucune plage nommée ni donnée exploitable dans ce classeur.",
|
||||||
|
"xlsx.dashboard_nr_name": "Nom",
|
||||||
|
"xlsx.dashboard_nr_scope": "Portée",
|
||||||
|
"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": "valeurs calculées",
|
||||||
"xlsx.feature_slicers": "segments et chronologies",
|
"xlsx.feature_slicers": "segments et chronologies",
|
||||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||||
@@ -2008,6 +2050,10 @@
|
|||||||
"ai.step.git_issues": "Issues recherchées : {value}",
|
"ai.step.git_issues": "Issues recherchées : {value}",
|
||||||
"ai.step.git_file": "Fichier de dépôt lu : {value}",
|
"ai.step.git_file": "Fichier de dépôt lu : {value}",
|
||||||
"ai.step.xlsx_create": "Tableur proposé : {value}",
|
"ai.step.xlsx_create": "Tableur proposé : {value}",
|
||||||
|
"ai.step.xlsx_sheets": "Feuilles du classeur listées : {value}",
|
||||||
|
"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.docx_create": "Document Word proposé : {value}",
|
"ai.step.docx_create": "Document Word proposé : {value}",
|
||||||
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
"ai.step.csv_create": "Fichier CSV proposé : {value}",
|
||||||
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
"ai.step.pdf_create": "Document PDF proposé : {value}",
|
||||||
|
|||||||
@@ -10931,6 +10931,7 @@ body.desktop-mode .editor-container {
|
|||||||
gap: 10px;
|
gap: 10px;
|
||||||
margin-bottom: 8px;
|
margin-bottom: 8px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
position: relative; /* anchors the A14 structure menu */
|
||||||
}
|
}
|
||||||
.xlsx-toolbar-actions {
|
.xlsx-toolbar-actions {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
@@ -10996,6 +10997,241 @@ body.desktop-mode .editor-container {
|
|||||||
outline: 2px solid var(--accent, #4a90d9);
|
outline: 2px solid var(--accent, #4a90d9);
|
||||||
outline-offset: -2px;
|
outline-offset: -2px;
|
||||||
}
|
}
|
||||||
|
/* #153 A7 — the active cell keeps its outline even when focus moves to the
|
||||||
|
formula bar, so the user never loses track of what the bar edits. */
|
||||||
|
.xlsx-table td.xlsx-active:not(:focus) {
|
||||||
|
outline: 2px dashed var(--accent, #4a90d9);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
|
||||||
|
.xlsx-formula-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.xlsx-active-cell {
|
||||||
|
min-width: 52px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.xlsx-formula-sep {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.xlsx-formula-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 5px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.xlsx-formula-input:focus {
|
||||||
|
outline: 2px solid var(--accent, #4a90d9);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.xlsx-formula-input:disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #153 A13 — find-in-sheet + filter/sort controls */
|
||||||
|
.xlsx-find-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
margin-left: auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.xlsx-find-input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 120px;
|
||||||
|
max-width: 220px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.xlsx-find-input:focus {
|
||||||
|
outline: 2px solid var(--accent, #4a90d9);
|
||||||
|
outline-offset: -2px;
|
||||||
|
}
|
||||||
|
.xlsx-find-count {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.xlsx-find-btn,
|
||||||
|
.xlsx-find-case,
|
||||||
|
.xlsx-sort-reset {
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
.xlsx-find-hit {
|
||||||
|
background: var(--warning, #e0a800);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.xlsx-find-current {
|
||||||
|
outline: 2px solid var(--accent, #4a90d9);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #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,
|
||||||
|
.xlsx-table tr.xlsx-frozen-row th {
|
||||||
|
position: sticky;
|
||||||
|
top: 33px; /* thead height — keeps the frozen row below the header */
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.xlsx-table td.xlsx-frozen-col {
|
||||||
|
position: sticky;
|
||||||
|
left: 44px; /* the row-number column width */
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #153 A14 — structure menu (sheets / rows / columns) */
|
||||||
|
.xlsx-structure-menu {
|
||||||
|
position: absolute;
|
||||||
|
z-index: 30;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 240px;
|
||||||
|
margin-top: 4px;
|
||||||
|
padding: 6px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
|
||||||
|
}
|
||||||
|
.xlsx-structure-item {
|
||||||
|
text-align: left;
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
|
||||||
|
Colors come from the existing CSS variables (no hardcoded values). */
|
||||||
|
.xlsx-dashboard {
|
||||||
|
margin: 8px 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.xlsx-dashboard-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
.xlsx-dashboard-head h3 {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.xlsx-dashboard-objects {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.xlsx-ranges-table {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-size: 0.82rem;
|
||||||
|
}
|
||||||
|
.xlsx-kpi-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.xlsx-kpi-sheet {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.xlsx-kpi-sheet h4 {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.xlsx-kpi-meta {
|
||||||
|
margin: 0 0 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.xlsx-kpi-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.xlsx-kpi {
|
||||||
|
display: inline-flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 64px;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
.xlsx-kpi-label {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.xlsx-kpi-value {
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
.xlsx-kpi-empty {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.xlsx-dashboard-hint {
|
||||||
|
margin: 10px 0 0;
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
.xlsx-dashboard-loading {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.xlsx-structure-item:hover {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
.xlsx-structure-sep {
|
||||||
|
height: 1px;
|
||||||
|
margin: 4px 0;
|
||||||
|
background: var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* JSDOM shims for the tests that click anchors */
|
||||||
|
mark {
|
||||||
|
font: inherit;
|
||||||
|
}
|
||||||
.xlsx-table td.xlsx-dirty {
|
.xlsx-table td.xlsx-dirty {
|
||||||
background: rgba(255, 196, 0, 0.18);
|
background: rgba(255, 196, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.32.0",
|
"version": "2.39.2",
|
||||||
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -231,4 +231,28 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
|
|||||||
// Tout est chargé → le pied de page est masqué.
|
// Tout est chargé → le pied de page est masqué.
|
||||||
await expect(foot).toBeHidden();
|
await expect(foot).toBeHidden();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('barre de formule et navigation clavier (#153 A7)', async ({ page }) => {
|
||||||
|
await login(page);
|
||||||
|
await openXlsx(page, LARGE);
|
||||||
|
|
||||||
|
// Un clic sur une cellule active la barre avec son nom et son contenu.
|
||||||
|
const cell = page.locator('#content-area td[data-cell="B2"]');
|
||||||
|
await cell.click();
|
||||||
|
await expect(page.locator('#xlsx-active-cell')).toHaveText('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 page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
|
||||||
|
await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
|
||||||
|
|
||||||
|
// Éditer depuis la barre marque la cellule dirty, Échap annule.
|
||||||
|
await bar.fill('Operation 2 modifiee');
|
||||||
|
await expect(page.locator('#content-area td[data-cell="C3"]')).toHaveClass(/xlsx-dirty/);
|
||||||
|
await page.locator('#content-area td[data-cell="C3"]').press('Escape');
|
||||||
|
await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
@@ -11,6 +11,12 @@
|
|||||||
* - A9bis : the tail of a truncated sheet is fetched window by window from
|
* - A9bis : the tail of a truncated sheet is fetched window by window from
|
||||||
* GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are
|
* GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are
|
||||||
* editable like the initial ones.
|
* editable like the initial ones.
|
||||||
|
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
||||||
|
* from the bar marks the cell dirty; Escape reverts.
|
||||||
|
* - A13 : header click sorts the rendered rows, the filter hides rows, the
|
||||||
|
* find highlights matches, CSV export downloads the visible sheet.
|
||||||
|
* - A14 : the structure menu sends one PUT …/xlsx/structure with the action,
|
||||||
|
* then re-renders from the server; destructive actions confirm first.
|
||||||
*
|
*
|
||||||
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
||||||
*/
|
*/
|
||||||
@@ -121,8 +127,9 @@ await initI18n();
|
|||||||
|
|
||||||
const sheetHtml = (value) =>
|
const sheetHtml = (value) =>
|
||||||
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||||
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
|
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
||||||
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
|
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td><td data-cell="B1">B1</td></tr>` +
|
||||||
|
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">A2</td><td data-cell="B2">B2</td></tr>' +
|
||||||
"</tbody></table></div>";
|
"</tbody></table></div>";
|
||||||
|
|
||||||
// The JSDOM fetch double serves locale files; everything else is a recorded
|
// The JSDOM fetch double serves locale files; everything else is a recorded
|
||||||
@@ -414,6 +421,277 @@ await test("a failed window fetch keeps the footnote and shows an error toast",
|
|||||||
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── A7 — formula bar & keyboard navigation ──────────────────────────────────
|
||||||
|
|
||||||
|
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-formula-input").disabled, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("focusing a cell shows its name and content in the bar", () => {
|
||||||
|
const area = mount();
|
||||||
|
const td = area.querySelector('td[data-cell="A1"]');
|
||||||
|
td.dispatchEvent(new w.Event("focus", { bubbles: false }));
|
||||||
|
// 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-formula-input").value, "100");
|
||||||
|
assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("typing in the bar edits the cell live and marks it dirty", () => {
|
||||||
|
const area = mount();
|
||||||
|
const td = area.querySelector('td[data-cell="A1"]');
|
||||||
|
td.focus();
|
||||||
|
const input = area.querySelector("#xlsx-formula-input");
|
||||||
|
input.value = "depuis la barre";
|
||||||
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||||
|
assert.equal(td.textContent, "depuis la barre");
|
||||||
|
assert.equal(td.classList.contains("xlsx-dirty"), true);
|
||||||
|
assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
|
||||||
|
// The save payload carries the cell edit.
|
||||||
|
area.querySelector("#xlsx-save-btn").click();
|
||||||
|
return new Promise((r) => setTimeout(r, 5)).then(() => {
|
||||||
|
assert.deepEqual(calls[0].body.cells, { A1: "depuis la barre" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("Tab and arrows move to the neighbour cell", () => {
|
||||||
|
const area = mount();
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("Enter commits and Shift+Tab goes backwards", () => {
|
||||||
|
const area = mount();
|
||||||
|
const a1 = area.querySelector('td[data-cell="A1"]');
|
||||||
|
a1.focus();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("a saved edit from the bar resets the dirty flag and orig value", async () => {
|
||||||
|
const area = mount();
|
||||||
|
const td = area.querySelector('td[data-cell="A1"]');
|
||||||
|
td.focus();
|
||||||
|
const input = area.querySelector("#xlsx-formula-input");
|
||||||
|
input.value = "200";
|
||||||
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||||
|
area.querySelector("#xlsx-save-btn").click();
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
|
||||||
|
assert.equal(td.dataset.orig, "200");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
||||||
|
|
||||||
|
const mountGrid = () => {
|
||||||
|
const area = document.getElementById("content-area");
|
||||||
|
area.innerHTML = "";
|
||||||
|
const grid =
|
||||||
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||||
|
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
||||||
|
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Banane</td><td data-cell="B1">3</td></tr>' +
|
||||||
|
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">Abricot</td><td data-cell="B2">10</td></tr>' +
|
||||||
|
'<tr><th class="xlsx-rownum">3</th><td data-cell="A3">Cerise</td><td data-cell="B3">2</td></tr>' +
|
||||||
|
"</tbody></table></div>";
|
||||||
|
renderXlsxViewer(area, {
|
||||||
|
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: [],
|
||||||
|
});
|
||||||
|
return area;
|
||||||
|
};
|
||||||
|
|
||||||
|
await test("clicking a header sorts the rows numerically or lexically", () => {
|
||||||
|
const area = mountGrid();
|
||||||
|
// Sort by column B (numbers) ascending: 2, 3, 10.
|
||||||
|
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||||
|
let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||||
|
assert.deepEqual(cells, ["2", "3", "10"]);
|
||||||
|
// Second click: descending.
|
||||||
|
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||||
|
cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent);
|
||||||
|
assert.deepEqual(cells, ["10", "3", "2"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("a dirty cell travels with its row during a sort", () => {
|
||||||
|
const area = mountGrid();
|
||||||
|
editCell(area, "A3", "Cerise modifiée");
|
||||||
|
area.querySelector(".xlsx-table thead th:nth-child(3)").click();
|
||||||
|
const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent);
|
||||||
|
assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|"));
|
||||||
|
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("the filter hides the rows that do not match", () => {
|
||||||
|
const area = mountGrid();
|
||||||
|
// The filter reuses the find input: type and the rows filter live.
|
||||||
|
const input = area.querySelector("#xlsx-find-input");
|
||||||
|
input.value = "abri";
|
||||||
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||||
|
const visible = [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display !== "none");
|
||||||
|
assert.equal(visible.length, 1);
|
||||||
|
assert.ok(visible[0].textContent.includes("Abricot"));
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("find highlights matches and navigates with the counter", () => {
|
||||||
|
const area = mountGrid();
|
||||||
|
const input = area.querySelector("#xlsx-find-input");
|
||||||
|
const count = area.querySelector("#xlsx-find-count");
|
||||||
|
input.value = "cerise"; // lowercase: the default search ignores the case
|
||||||
|
input.dispatchEvent(new w.Event("input", { bubbles: true }));
|
||||||
|
assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 1);
|
||||||
|
assert.ok(count.textContent.includes("1/1"), count.textContent);
|
||||||
|
// The hit is inside the matching cell.
|
||||||
|
assert.ok(area.querySelector('td[data-cell="A3"] mark.xlsx-find-hit'));
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("CSV export downloads the visible sheet without the cached shadows", () => {
|
||||||
|
const area = mountGrid();
|
||||||
|
const clicks = [];
|
||||||
|
const realCreate = document.createElement.bind(document);
|
||||||
|
const anchor = realCreate("a");
|
||||||
|
document.createElement = (tag) => {
|
||||||
|
if (tag === "a") { clicks.push(1); return anchor; }
|
||||||
|
return realCreate(tag);
|
||||||
|
};
|
||||||
|
let href = "";
|
||||||
|
Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href });
|
||||||
|
URL.createObjectURL = () => "blob:x";
|
||||||
|
URL.revokeObjectURL = () => {};
|
||||||
|
area.querySelector("#xlsx-csv-btn").click();
|
||||||
|
document.createElement = realCreate;
|
||||||
|
assert.equal(clicks.length, 1);
|
||||||
|
assert.equal(anchor.download, "Fruits.csv");
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A14 — structure menu ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
|
||||||
|
const area = mount();
|
||||||
|
// The prompt is resolved through the module scope: stub it globally.
|
||||||
|
const realPrompt = globalThis.prompt;
|
||||||
|
globalThis.prompt = () => "Feuille 2";
|
||||||
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
|
||||||
|
apiQueue.push({
|
||||||
|
ok: true, status: 200,
|
||||||
|
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
|
||||||
|
}); // re-read
|
||||||
|
area.querySelector("#xlsx-structure-btn").click();
|
||||||
|
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||||
|
const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
|
||||||
|
addBtn.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
globalThis.prompt = realPrompt;
|
||||||
|
assert.equal(calls.length, 2);
|
||||||
|
assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
|
||||||
|
assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
|
||||||
|
assert.equal(calls[0].body.force, false);
|
||||||
|
// The viewer re-rendered from the server payload (a single sheet → no tabs).
|
||||||
|
assert.ok(
|
||||||
|
area.querySelector("#content-area, .xlsx-viewer") || area,
|
||||||
|
"the viewer was rebuilt",
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
|
||||||
|
"the re-render shows the fresh payload",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("sheet_delete confirms and is refused on the last sheet", async () => {
|
||||||
|
const area = mount();
|
||||||
|
const delBtn = () => {
|
||||||
|
area.querySelector("#xlsx-structure-btn").click();
|
||||||
|
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||||
|
const b = items.find((x) => x.textContent === FR["xlsx.sheet_delete"]);
|
||||||
|
b.click();
|
||||||
|
};
|
||||||
|
// One sheet only → blocked before even confirming (no network call).
|
||||||
|
delBtn();
|
||||||
|
assert.equal(calls.length, 0, "nothing sent: last sheet");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("the 409 lossy flow re-emits with force after confirmation", async () => {
|
||||||
|
const area = mount();
|
||||||
|
// The prompt is resolved through the module scope: stub it globally.
|
||||||
|
const realPrompt = globalThis.prompt;
|
||||||
|
globalThis.prompt = () => "Feuille 2";
|
||||||
|
apiQueue.push({
|
||||||
|
ok: false, status: 409,
|
||||||
|
body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
|
||||||
|
});
|
||||||
|
apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // retry w/ force
|
||||||
|
apiQueue.push({
|
||||||
|
ok: true, status: 200,
|
||||||
|
body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
|
||||||
|
});
|
||||||
|
area.querySelector("#xlsx-structure-btn").click();
|
||||||
|
const items = [...area.querySelectorAll(".xlsx-structure-item")];
|
||||||
|
items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
globalThis.prompt = realPrompt;
|
||||||
|
assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
|
||||||
|
assert.equal(calls.length, 3);
|
||||||
|
assert.equal(calls[1].body.force, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── A17 — dashboard panel ─────────────────────────────────────────────
|
||||||
|
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
|
||||||
|
const area = mount();
|
||||||
|
const dashBtn = area.querySelector("#xlsx-dashboard-btn");
|
||||||
|
assert.ok(dashBtn, "the dashboard button exists for an editable workbook");
|
||||||
|
apiQueue.push({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
body: {
|
||||||
|
vault: "V",
|
||||||
|
path: "data.xlsx",
|
||||||
|
named_ranges: [{ name: "MaPlage", scope: "", ref: "Data!$A$1:$B$5" }],
|
||||||
|
objects: { charts: 2, pivots: 1 },
|
||||||
|
sheets: [{ name: "Feuille1", cells: 9, rows: 3, cols: 3, formulas: 1, numeric: 2, kpi: [{ label: "A2", value: 12 }] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
dashBtn.click();
|
||||||
|
await new Promise((r) => setTimeout(r, 5));
|
||||||
|
assert.match(calls[0].url, /\/xlsx\/dashboard\?path=data\.xlsx/);
|
||||||
|
const panel = area.querySelector(".xlsx-dashboard");
|
||||||
|
assert.ok(panel, "the dashboard panel is rendered");
|
||||||
|
assert.ok(panel.textContent.includes("MaPlage"), "named range is listed");
|
||||||
|
assert.ok(panel.textContent.includes("A2"), "KPI label is rendered");
|
||||||
|
assert.ok(panel.textContent.includes("12"), "KPI value is rendered");
|
||||||
|
// Second click closes the panel.
|
||||||
|
dashBtn.click();
|
||||||
|
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await test("a csv mounts without the dashboard button", () => {
|
||||||
|
const area = document.getElementById("content-area");
|
||||||
|
area.innerHTML = "";
|
||||||
|
renderXlsxViewer(area, {
|
||||||
|
vault: "V",
|
||||||
|
path: "data.csv",
|
||||||
|
is_csv: true,
|
||||||
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
||||||
|
xlsx_lossy_features: [],
|
||||||
|
});
|
||||||
|
assert.equal(area.querySelector("#xlsx-dashboard-btn"), null, "no dashboard for csv");
|
||||||
|
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure menu for csv");
|
||||||
|
assert.ok(area.querySelector("td[data-cell=\"A1\"]"), "the grid is still editable");
|
||||||
|
});
|
||||||
|
|
||||||
await test("two windows in a row walk the whole sheet", async () => {
|
await test("two windows in a row walk the whole sheet", async () => {
|
||||||
const area = mountTruncated({ total: 1200 });
|
const area = mountTruncated({ total: 1200 });
|
||||||
apiQueue.push({
|
apiQueue.push({
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
"""Unit tests for the existing-workbook AI tools (#153 A6).
|
||||||
|
|
||||||
|
Covers ``list_xlsx_sheets``, ``xlsx_to_markdown``, ``update_xlsx_cells`` and
|
||||||
|
``append_xlsx_rows`` — risk levels, confirmation gating, vault persistence and
|
||||||
|
the reuse of the guarded mutation service (P0 guards, formula neutralisation).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.tools.api import (
|
||||||
|
ToolConfirmationRequired,
|
||||||
|
ToolContext,
|
||||||
|
ToolError,
|
||||||
|
call_tool,
|
||||||
|
get_tool,
|
||||||
|
)
|
||||||
|
from backend.tools.context import ToolRisk
|
||||||
|
|
||||||
|
openpyxl = pytest.importorskip("openpyxl")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def vault(tmp_path, monkeypatch):
|
||||||
|
"""A minimal configured vault (index entry patched, no full build)."""
|
||||||
|
vault_dir = tmp_path / "Vault"
|
||||||
|
vault_dir.mkdir()
|
||||||
|
monkeypatch.setitem(
|
||||||
|
__import__("backend.indexer", fromlist=["index"]).index,
|
||||||
|
"Vault",
|
||||||
|
{"name": "Vault", "path": str(vault_dir), "config": {}},
|
||||||
|
)
|
||||||
|
return vault_dir
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def workbook(vault: Path) -> Path:
|
||||||
|
"""A two-sheet workbook: Budget (values + a formula) and Notes."""
|
||||||
|
path = vault / "classeur.xlsx"
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Budget"
|
||||||
|
ws.append(["Poste", "Montant"])
|
||||||
|
ws.append(["Loyer", 900])
|
||||||
|
ws.append(["Courses", 250])
|
||||||
|
notes = wb.create_sheet("Notes")
|
||||||
|
notes["A1"] = "bonjour"
|
||||||
|
wb.save(path)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def _ctx() -> ToolContext:
|
||||||
|
return ToolContext(
|
||||||
|
user={"username": "tester", "role": "admin", "vaults": ["*"]},
|
||||||
|
audit_enabled=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRegistry:
|
||||||
|
def test_read_tools_are_read_risk(self):
|
||||||
|
for name in ("list_xlsx_sheets", "xlsx_to_markdown"):
|
||||||
|
spec = get_tool(name)
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.risk == ToolRisk.READ
|
||||||
|
|
||||||
|
def test_mutation_tools_require_confirmation(self):
|
||||||
|
for name in ("update_xlsx_cells", "append_xlsx_rows"):
|
||||||
|
spec = get_tool(name)
|
||||||
|
assert spec is not None
|
||||||
|
assert spec.risk == ToolRisk.WRITE
|
||||||
|
assert spec.requires_confirmation is True
|
||||||
|
|
||||||
|
def test_mutation_tools_raise_without_confirmation(self, vault, workbook):
|
||||||
|
with pytest.raises(ToolConfirmationRequired):
|
||||||
|
call_tool("update_xlsx_cells", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "cells": {"B3": "300"},
|
||||||
|
})
|
||||||
|
with pytest.raises(ToolConfirmationRequired):
|
||||||
|
call_tool("append_xlsx_rows", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "rows": [["Total", 1150]],
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class TestListXlsxSheets:
|
||||||
|
def test_lists_names_and_dimensions(self, vault, workbook):
|
||||||
|
out = call_tool("list_xlsx_sheets", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
})
|
||||||
|
assert out.ok
|
||||||
|
data = out.data
|
||||||
|
assert [s["name"] for s in data["sheets"]] == ["Budget", "Notes"]
|
||||||
|
budget = data["sheets"][0]
|
||||||
|
assert budget["total_rows"] == 3 and budget["total_cols"] == 2
|
||||||
|
assert budget["truncated"] is False
|
||||||
|
|
||||||
|
def test_wrong_extension_rejected(self, vault):
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
call_tool("list_xlsx_sheets", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "note.md",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_missing_file_rejected(self, vault):
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
call_tool("list_xlsx_sheets", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "absent.xlsx",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class TestXlsxToMarkdown:
|
||||||
|
def test_renders_a_bounded_markdown_table(self, vault, workbook):
|
||||||
|
out = call_tool("xlsx_to_markdown", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
})
|
||||||
|
assert out.ok
|
||||||
|
data = out.data
|
||||||
|
assert data["sheet"] == "Budget"
|
||||||
|
assert data["rows"] == 3 and data["truncated"] is False
|
||||||
|
assert "| Poste | Montant |" in data["markdown"]
|
||||||
|
assert "| Loyer | 900 |" in data["markdown"]
|
||||||
|
|
||||||
|
def test_specific_sheet(self, vault, workbook):
|
||||||
|
out = call_tool("xlsx_to_markdown", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Notes",
|
||||||
|
})
|
||||||
|
assert out.ok
|
||||||
|
data = out.data
|
||||||
|
assert data["sheet"] == "Notes"
|
||||||
|
assert "bonjour" in data["markdown"]
|
||||||
|
|
||||||
|
def test_unknown_sheet_rejected(self, vault, workbook):
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
call_tool("xlsx_to_markdown", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx", "sheet": "Nope",
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_big_sheet_is_flagged_truncated(self, vault):
|
||||||
|
path = vault / "gros.xlsx"
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
for i in range(150):
|
||||||
|
ws.append([f"r{i}", i])
|
||||||
|
wb.save(path)
|
||||||
|
out = call_tool("xlsx_to_markdown", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "gros.xlsx",
|
||||||
|
})
|
||||||
|
assert out.ok
|
||||||
|
data = out.data
|
||||||
|
assert data["rows"] == 100
|
||||||
|
assert data["truncated"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestUpdateXlsxCells:
|
||||||
|
def test_edits_cells_and_survives_a_reload(self, vault, workbook):
|
||||||
|
out = call_tool("update_xlsx_cells", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "cells": {"B3": "300"},
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok and out.data["status"] == "ok"
|
||||||
|
wb = openpyxl.load_workbook(workbook)
|
||||||
|
assert wb["Budget"]["B3"].value == 300 # coerced like the viewer
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
def test_formula_stays_text_by_default(self, vault, workbook):
|
||||||
|
out = call_tool("update_xlsx_cells", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "cells": {"C1": "=B2+B3"},
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
wb = openpyxl.load_workbook(workbook)
|
||||||
|
assert wb["Budget"]["C1"].data_type == "s" # A4 guard inherited
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
def test_empty_cells_rejected(self, vault, workbook):
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
call_tool("update_xlsx_cells", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "cells": {},
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
class TestAppendXlsxRows:
|
||||||
|
def test_appends_below_the_last_row(self, vault, workbook):
|
||||||
|
out = call_tool("append_xlsx_rows", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "rows": [["Total", 1150]],
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
assert out.data["first_row"] == 4
|
||||||
|
wb = openpyxl.load_workbook(workbook)
|
||||||
|
ws = wb["Budget"]
|
||||||
|
assert ws["A4"].value == "Total"
|
||||||
|
assert ws["B4"].value == 1150 and isinstance(ws["B4"].value, int)
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
def test_values_are_coerced(self, vault, workbook):
|
||||||
|
out = call_tool("append_xlsx_rows", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "rows": [["VRAI", "01/02/2026", 12.5]],
|
||||||
|
}, confirm=True)
|
||||||
|
assert out.ok
|
||||||
|
wb = openpyxl.load_workbook(workbook)
|
||||||
|
ws = wb["Budget"]
|
||||||
|
assert ws["A4"].value is True
|
||||||
|
assert (ws["B4"].value.month, ws["B4"].value.day) == (2, 1)
|
||||||
|
assert ws["C4"].value == 12.5
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
def test_unknown_sheet_rejected(self, vault, workbook):
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
call_tool("append_xlsx_rows", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Nope", "rows": [["x"]],
|
||||||
|
})
|
||||||
|
|
||||||
|
def test_empty_rows_rejected(self, vault, workbook):
|
||||||
|
with pytest.raises(ToolError):
|
||||||
|
call_tool("append_xlsx_rows", _ctx(), {
|
||||||
|
"vault": "Vault", "path": "classeur.xlsx",
|
||||||
|
"sheet": "Budget", "rows": [],
|
||||||
|
})
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Workbook dashboard: named ranges, chart/pivot objects and per-sheet KPI
|
||||||
|
stats (#153 A17), plus the ``GET …/xlsx/dashboard`` endpoint wiring."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
openpyxl = pytest.importorskip("openpyxl")
|
||||||
|
|
||||||
|
VAULT = "TestVault"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def dash_book(test_vault_dir: str) -> str:
|
||||||
|
"""A workbook with a named range, a chart and numeric KPI cells."""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
from openpyxl.chart import BarChart, Reference
|
||||||
|
from openpyxl.workbook.defined_name import DefinedName
|
||||||
|
|
||||||
|
path = Path(test_vault_dir) / "dash.xlsx"
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Data"
|
||||||
|
ws["A1"] = "Ventes"
|
||||||
|
ws["A2"] = 12
|
||||||
|
ws["A3"] = 48
|
||||||
|
ws["B2"] = "=SUM(A2:A3)"
|
||||||
|
wb.defined_names.add(DefinedName("MaPlage", attr_text="Data!$A$1:$B$5"))
|
||||||
|
chart = BarChart()
|
||||||
|
chart.add_data(Reference(ws, min_col=1, min_row=1, max_row=3))
|
||||||
|
ws.add_chart(chart, "D2")
|
||||||
|
wb.save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def plain_book(test_vault_dir: str) -> str:
|
||||||
|
"""A workbook without any dashboard-worthy feature."""
|
||||||
|
from openpyxl import Workbook
|
||||||
|
|
||||||
|
path = Path(test_vault_dir) / "plain.xlsx"
|
||||||
|
wb = Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Vide"
|
||||||
|
ws["A1"] = "texte seul"
|
||||||
|
wb.save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardReading:
|
||||||
|
def test_named_range_is_detected(self, dash_book):
|
||||||
|
from backend.xlsx_reader import read_workbook_dashboard
|
||||||
|
|
||||||
|
dash = read_workbook_dashboard(Path(dash_book))
|
||||||
|
names = [r["name"] for r in dash["named_ranges"]]
|
||||||
|
assert "MaPlage" in names
|
||||||
|
entry = next(r for r in dash["named_ranges"] if r["name"] == "MaPlage")
|
||||||
|
assert "Data!" in entry["ref"]
|
||||||
|
|
||||||
|
def test_charts_are_counted(self, dash_book):
|
||||||
|
from backend.xlsx_reader import read_workbook_dashboard
|
||||||
|
|
||||||
|
dash = read_workbook_dashboard(Path(dash_book))
|
||||||
|
assert dash["objects"]["charts"] >= 1
|
||||||
|
assert dash["objects"]["pivots"] == 0
|
||||||
|
|
||||||
|
def test_sheet_kpis_carry_numeric_cells(self, dash_book):
|
||||||
|
from backend.xlsx_reader import read_workbook_dashboard
|
||||||
|
|
||||||
|
dash = read_workbook_dashboard(Path(dash_book))
|
||||||
|
sheet = next(s for s in dash["sheets"] if s["name"] == "Data")
|
||||||
|
assert sheet["cells"] >= 4
|
||||||
|
assert sheet["formulas"] == 1
|
||||||
|
assert sheet["numeric"] >= 2
|
||||||
|
values = [k["value"] for k in sheet["kpi"]]
|
||||||
|
assert 12 in values and 48 in values
|
||||||
|
|
||||||
|
def test_plain_book_yields_empty_dashboard(self, plain_book):
|
||||||
|
from backend.xlsx_reader import read_workbook_dashboard
|
||||||
|
|
||||||
|
dash = read_workbook_dashboard(Path(plain_book))
|
||||||
|
assert dash["named_ranges"] == []
|
||||||
|
assert dash["objects"]["charts"] == 0
|
||||||
|
sheet = dash["sheets"][0]
|
||||||
|
assert sheet["numeric"] == 0
|
||||||
|
assert sheet["kpi"] == []
|
||||||
|
|
||||||
|
def test_broken_book_yields_empty_payload(self, test_vault_dir):
|
||||||
|
from backend.xlsx_reader import read_workbook_dashboard
|
||||||
|
|
||||||
|
bad = Path(test_vault_dir) / "broken-dash.xlsx"
|
||||||
|
bad.write_bytes(b"not a zip")
|
||||||
|
dash = read_workbook_dashboard(bad)
|
||||||
|
assert dash["named_ranges"] == []
|
||||||
|
assert dash["sheets"] == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestDashboardEndpoint:
|
||||||
|
def test_endpoint_serves_the_dashboard(self, client, dash_book):
|
||||||
|
resp = client.get(f"/api/file/{VAULT}", params={"path": dash_book})
|
||||||
|
assert resp.status_code == 200 # sanity: file is readable
|
||||||
|
dash = client.get(
|
||||||
|
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": dash_book}
|
||||||
|
).json()
|
||||||
|
assert any(r["name"] == "MaPlage" for r in dash["named_ranges"])
|
||||||
|
assert dash["objects"]["charts"] >= 1
|
||||||
|
assert any(s["kpi"] for s in dash["sheets"])
|
||||||
|
|
||||||
|
def test_unknown_file_is_404(self, client):
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "nope.xlsx"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_non_workbook_is_415(self, client, test_vault_dir):
|
||||||
|
(Path(test_vault_dir) / "texte.txt").write_text("hello", encoding="utf-8")
|
||||||
|
resp = client.get(
|
||||||
|
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "texte.txt"}
|
||||||
|
)
|
||||||
|
assert resp.status_code == 415
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""Additional spreadsheet formats in the viewer (#153 A16): .xlsm editable
|
||||||
|
with macros preserved, .xls/.ods read-only renders and .csv A1-addressed
|
||||||
|
saves."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
openpyxl = pytest.importorskip("openpyxl")
|
||||||
|
|
||||||
|
VAULT = "TestVault"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def formats_vault(test_vault_dir: str) -> Path:
|
||||||
|
"""Directory of the test vault, for fixture files written in-place."""
|
||||||
|
return Path(test_vault_dir)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def xlsm_book(formats_vault: Path) -> str:
|
||||||
|
"""A macro-enabled workbook: one sheet, two cells, a fake VBA blob."""
|
||||||
|
path = formats_vault / "macro.xlsm"
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Data"
|
||||||
|
ws["A1"] = "Nom"
|
||||||
|
ws["A2"] = "Ada"
|
||||||
|
wb.save(path)
|
||||||
|
# Inject a minimal vbaProject.bin part so the archive really IS a .xlsm
|
||||||
|
# (keep_vba only matters when macros exist).
|
||||||
|
import shutil
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
real = path.with_suffix(".tmp.xlsm")
|
||||||
|
with zipfile.ZipFile(path) as zin, zipfile.ZipFile(real, "w") as zout:
|
||||||
|
for item in zin.namelist():
|
||||||
|
zout.writestr(item, zin.read(item))
|
||||||
|
zout.writestr("xl/vbaProject.bin", b"VBA-FAKE-CONTENT")
|
||||||
|
shutil.move(real, path)
|
||||||
|
return "macro.xlsm"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def ods_book(formats_vault: Path) -> str:
|
||||||
|
"""A small ODS spreadsheet."""
|
||||||
|
# The odfpy distribution installs a top-level module named `odf`.
|
||||||
|
pytest.importorskip("odf")
|
||||||
|
from odf.opendocument import OpenDocumentSpreadsheet
|
||||||
|
from odf.table import Table, TableCell, TableRow
|
||||||
|
from odf.text import P
|
||||||
|
|
||||||
|
path = formats_vault / "classeur.ods"
|
||||||
|
doc = OpenDocumentSpreadsheet()
|
||||||
|
table = Table(name="Feuille1")
|
||||||
|
for values in (["Ville", "Pop"], ["Paris", "2100000"]):
|
||||||
|
tr = TableRow()
|
||||||
|
for v in values:
|
||||||
|
tc = TableCell(valuetype="string")
|
||||||
|
tc.addElement(P(text=str(v)))
|
||||||
|
tr.addElement(tc)
|
||||||
|
table.addElement(tr)
|
||||||
|
doc.spreadsheet.addElement(table)
|
||||||
|
doc.save(str(path))
|
||||||
|
return "classeur.ods"
|
||||||
|
|
||||||
|
|
||||||
|
class TestXlsmEditable:
|
||||||
|
def test_read_serves_the_xlsx_viewer_payload(self, client, xlsm_book):
|
||||||
|
resp = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_xlsx"] is True
|
||||||
|
assert data["extension"] == ".xlsm"
|
||||||
|
# Macros are NOT lossy for .xlsm: keep_vba preserves them, so no
|
||||||
|
# confirmation round-trip is ever triggered.
|
||||||
|
assert data["xlsx_lossy_features"] == []
|
||||||
|
assert 'data-cell="A1"' in data["xlsx_sheets"][0]["html"]
|
||||||
|
|
||||||
|
def test_save_round_trips_and_keeps_vba(self, client, xlsm_book):
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/file/{VAULT}/xlsx/save",
|
||||||
|
params={"path": xlsm_book},
|
||||||
|
json={"sheet": "Data", "cells": {"A2": "Grace"}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# Re-read through the API instead of guessing the root on disk.
|
||||||
|
reread = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book})
|
||||||
|
assert "Grace" in reread.json()["xlsx_sheets"][0]["html"]
|
||||||
|
# The fake VBA part survived the round-trip.
|
||||||
|
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):
|
||||||
|
from backend.services.mutations import edit_xlsx_cells
|
||||||
|
|
||||||
|
# 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"})
|
||||||
|
assert result["success"] is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestLegacyReadOnly:
|
||||||
|
def test_xls_renders_cells(self, client, formats_vault):
|
||||||
|
pytest.importorskip("xlrd")
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
|
||||||
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "sample.xls"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_xlsx"] is True
|
||||||
|
assert data["xlsx_readonly"] is True
|
||||||
|
sheet = data["xlsx_sheets"][0]
|
||||||
|
assert sheet["name"] == "Data"
|
||||||
|
assert "Produit" in sheet["html"]
|
||||||
|
assert "Café" in sheet["html"]
|
||||||
|
|
||||||
|
def test_ods_renders_cells(self, client, ods_book):
|
||||||
|
resp = client.get(f"/api/file/{VAULT}", params={"path": ods_book})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["xlsx_readonly"] is True
|
||||||
|
html = data["xlsx_sheets"][0]["html"]
|
||||||
|
assert "Paris" in html
|
||||||
|
assert "2100000" in html
|
||||||
|
|
||||||
|
def test_readonly_meta_is_empty(self, client, formats_vault):
|
||||||
|
pytest.importorskip("xlrd")
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls")
|
||||||
|
sheet = client.get(
|
||||||
|
f"/api/file/{VAULT}", params={"path": "sample.xls"}
|
||||||
|
).json()["xlsx_sheets"][0]
|
||||||
|
assert sheet["styles"] == {}
|
||||||
|
assert sheet["merges"] == []
|
||||||
|
assert sheet["freeze"] == ""
|
||||||
|
|
||||||
|
def test_broken_legacy_file_yields_one_empty_sheet(self, client, formats_vault):
|
||||||
|
(formats_vault / "broken.xls").write_bytes(b"not an ole file")
|
||||||
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "broken.xls"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
sheet = resp.json()["xlsx_sheets"][0]
|
||||||
|
assert sheet["rows"] == 0
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "liste.csv"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["is_csv"] is True
|
||||||
|
html = data["html"]
|
||||||
|
assert 'data-cell="A1"' in html
|
||||||
|
assert "xlsx-table" in html
|
||||||
|
|
||||||
|
def test_save_cells_and_grow_the_grid(self, client, formats_vault):
|
||||||
|
(formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8")
|
||||||
|
resp = client.put(
|
||||||
|
f"/api/file/{VAULT}/csv/save",
|
||||||
|
params={"path": "liste.csv"},
|
||||||
|
json={"cells": {"B1": "modifié", "C3": "nouveau"}},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
text = _vault_file(client, "liste.csv").read_text(encoding="utf-8")
|
||||||
|
assert "modifié" in text
|
||||||
|
assert "nouveau" in text
|
||||||
|
# Existing rows survive, and the reference beyond the extent grew the
|
||||||
|
# grid to 3 rows x 3 cols.
|
||||||
|
assert text.startswith("a,modifié")
|
||||||
|
assert "1,2" in text
|
||||||
|
assert text.count("\n") >= 2
|
||||||
|
|
||||||
|
def test_csv_values_are_stored_verbatim(self, client, formats_vault):
|
||||||
|
(formats_vault / "formules.csv").write_text("x\n", encoding="utf-8")
|
||||||
|
client.put(
|
||||||
|
f"/api/file/{VAULT}/csv/save",
|
||||||
|
params={"path": "formules.csv"},
|
||||||
|
json={"cells": {"A1": "=1+1"}},
|
||||||
|
)
|
||||||
|
text = _vault_file(client, "formules.csv").read_text(encoding="utf-8")
|
||||||
|
assert "=1+1" in text # no formula engine: stored as text
|
||||||
|
|
||||||
|
def test_quoting_survives_a_round_trip(self, client, formats_vault):
|
||||||
|
(formats_vault / "quotes.csv").write_text('nom\n"Dupont, Jean"\n', encoding="utf-8")
|
||||||
|
client.put(
|
||||||
|
f"/api/file/{VAULT}/csv/save",
|
||||||
|
params={"path": "quotes.csv"},
|
||||||
|
json={"cells": {"A2": "Martin, Pierre"}},
|
||||||
|
)
|
||||||
|
text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8")
|
||||||
|
assert '"Martin, Pierre"' 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(
|
||||||
|
f"/api/file/{VAULT}/csv/save",
|
||||||
|
params={"path": "liste.csv"},
|
||||||
|
json={"cells": {"XX": "v"}}, # missing row number
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def _vault_file(client, rel: str) -> Path:
|
||||||
|
"""Resolve a vault file path for on-disk assertions."""
|
||||||
|
root = None
|
||||||
|
for route in client.app.routes:
|
||||||
|
pass
|
||||||
|
# The service layer exposes the vault root; use it directly.
|
||||||
|
from backend.services.vaults import get_vault_root
|
||||||
|
|
||||||
|
root = get_vault_root(VAULT)
|
||||||
|
return root / rel
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
"""Structural mutations of an .xlsx workbook (#153 A14): service + endpoint.
|
||||||
|
|
||||||
|
Covers sheet add/rename/delete/duplicate and row/col insert/delete, the
|
||||||
|
atomicity of the batch (one locked rewrite) and the shared P0 guards
|
||||||
|
(lossy 409 gate, backup, path safety).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import zipfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
openpyxl = pytest.importorskip("openpyxl")
|
||||||
|
|
||||||
|
VAULT = "TestVault"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def book(test_vault_dir: str) -> str:
|
||||||
|
path = Path(test_vault_dir) / "struct.xlsx"
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Data"
|
||||||
|
ws.append(["Nom", "Valeur"])
|
||||||
|
ws.append(["a", 1])
|
||||||
|
ws.append(["b", 2])
|
||||||
|
wb.create_sheet("Vide")
|
||||||
|
wb.save(path)
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def _put(client, path="struct.xlsx", actions=None, **extra):
|
||||||
|
return client.put(
|
||||||
|
f"/api/file/{VAULT}/xlsx/structure",
|
||||||
|
params={"path": path},
|
||||||
|
json={"actions": actions, **extra},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _wb(path):
|
||||||
|
wb = openpyxl.load_workbook(path)
|
||||||
|
try:
|
||||||
|
return wb
|
||||||
|
finally:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TestSheetOps:
|
||||||
|
def test_add_rename_delete_sheet(self, client, book):
|
||||||
|
resp = _put(client, actions=[
|
||||||
|
{"op": "sheet_add", "name": "Extra", "at": 0},
|
||||||
|
{"op": "sheet_rename", "from": "Vide", "to": "Renommée"},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
assert wb.sheetnames[0] == "Extra" # inserted at position 0
|
||||||
|
assert "Renommée" in wb.sheetnames and "Vide" not in wb.sheetnames
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
resp = _put(client, actions=[{"op": "sheet_delete", "name": "Extra"}])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
assert "Extra" not in wb.sheetnames
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
def test_delete_last_sheet_refused(self, client, test_vault_dir):
|
||||||
|
(Path(test_vault_dir) / "solo.xlsx").write_bytes(book_bytes("Solo"))
|
||||||
|
resp = _put(client, path="solo.xlsx", actions=[
|
||||||
|
{"op": "sheet_delete", "name": "Solo"},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_duplicate_copies_values(self, client, book):
|
||||||
|
resp = _put(client, actions=[
|
||||||
|
{"op": "sheet_duplicate", "name": "Data", "as": "Data copie"},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
assert wb["Data copie"]["A1"].value == "Nom"
|
||||||
|
assert wb["Data copie"]["B3"].value == 2
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
def book_bytes(sheet_name: str) -> bytes:
|
||||||
|
import io
|
||||||
|
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
wb.active.title = sheet_name
|
||||||
|
buf = io.BytesIO()
|
||||||
|
wb.save(buf)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
class TestRowColOps:
|
||||||
|
def test_row_insert_shifts_and_delete_removes(self, client, book):
|
||||||
|
resp = _put(client, actions=[
|
||||||
|
{"op": "row_insert", "sheet": "Data", "at": 2, "count": 1},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
ws = wb["Data"]
|
||||||
|
assert ws["A2"].value is None # the new blank row
|
||||||
|
assert ws["A3"].value == "a" # shifted down
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
resp = _put(client, actions=[
|
||||||
|
{"op": "row_delete", "sheet": "Data", "at": 2, "count": 1},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 200
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
assert wb["Data"]["A2"].value == "a"
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
def test_col_insert_and_delete(self, client, book):
|
||||||
|
assert _put(client, actions=[
|
||||||
|
{"op": "col_insert", "sheet": "Data", "at": 2},
|
||||||
|
]).status_code == 200
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
assert wb["Data"]["B1"].value is None
|
||||||
|
assert wb["Data"]["C1"].value == "Valeur"
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
assert _put(client, actions=[
|
||||||
|
{"op": "col_delete", "sheet": "Data", "at": 2},
|
||||||
|
]).status_code == 200
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
assert wb["Data"]["B1"].value == "Valeur"
|
||||||
|
wb.close()
|
||||||
|
|
||||||
|
|
||||||
|
class TestGuards:
|
||||||
|
def test_unknown_sheet_is_400(self, client, book):
|
||||||
|
resp = _put(client, actions=[{"op": "row_insert", "sheet": "Nope", "at": 1}])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_unknown_op_is_400(self, client, book):
|
||||||
|
resp = _put(client, actions=[{"op": "sheet_explode", "name": "X"}])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_bad_position_is_400(self, client, book):
|
||||||
|
resp = _put(client, actions=[
|
||||||
|
{"op": "row_insert", "sheet": "Data", "at": "deux"},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_empty_actions_is_400(self, client, book):
|
||||||
|
assert _put(client, actions=[]).status_code == 400
|
||||||
|
|
||||||
|
def test_lossy_workbook_refused_without_force(self, client, test_vault_dir):
|
||||||
|
"""Same 409 gate as the cell edits (A1)."""
|
||||||
|
path = Path(test_vault_dir) / "lossy-struct.xlsx"
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
wb.active.title = "S"
|
||||||
|
wb["S"]["A1"] = "=A2" # no cached value -> add one via the raw XML
|
||||||
|
wb.save(path)
|
||||||
|
with zipfile.ZipFile(path) as zf:
|
||||||
|
items = {n: zf.read(n) for n in zf.namelist()}
|
||||||
|
sheet = next(n for n in items if n.startswith("xl/worksheets/sheet"))
|
||||||
|
items[sheet] = items[sheet].decode("utf-8").replace(
|
||||||
|
"<f>A2</f>", "<f>A2</f><v>7</v>"
|
||||||
|
).encode("utf-8")
|
||||||
|
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||||
|
for name, blob in items.items():
|
||||||
|
zf.writestr(name, blob)
|
||||||
|
|
||||||
|
resp = _put(client, path="lossy-struct.xlsx", actions=[
|
||||||
|
{"op": "sheet_add", "name": "X"},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 409
|
||||||
|
assert resp.json()["code"] == "xlsx_lossy_content"
|
||||||
|
|
||||||
|
resp = _put(client, path="lossy-struct.xlsx", actions=[
|
||||||
|
{"op": "sheet_add", "name": "X"},
|
||||||
|
], force=True)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
def test_atomicity_one_bad_action_writes_nothing(self, client, book):
|
||||||
|
"""A batch with a valid action followed by a bad one writes nothing."""
|
||||||
|
resp = _put(client, actions=[
|
||||||
|
{"op": "sheet_add", "name": "Temp"},
|
||||||
|
{"op": "sheet_delete", "name": "Inexistante"},
|
||||||
|
])
|
||||||
|
assert resp.status_code == 400
|
||||||
|
wb = openpyxl.load_workbook(book)
|
||||||
|
assert "Temp" not in wb.sheetnames
|
||||||
|
wb.close()
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
"""Style metadata in the .xlsx viewer (#153 A15): bold, colors, number formats,
|
||||||
|
merged ranges and freeze panes — plus the endpoint wiring."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
openpyxl = pytest.importorskip("openpyxl")
|
||||||
|
|
||||||
|
VAULT = "TestVault"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def styled_book(test_vault_dir: str) -> str:
|
||||||
|
"""A workbook with a bold red-font header, a filled cell, a merge and a freeze."""
|
||||||
|
from openpyxl.styles import Font, PatternFill
|
||||||
|
|
||||||
|
path = Path(test_vault_dir) / "styles.xlsx"
|
||||||
|
wb = openpyxl.Workbook()
|
||||||
|
ws = wb.active
|
||||||
|
ws.title = "Data"
|
||||||
|
ws["A1"] = "En-tête"
|
||||||
|
ws["A1"].font = Font(bold=True, italic=True, color="FFCC0000")
|
||||||
|
ws["B1"] = "Total"
|
||||||
|
ws["B1"].fill = PatternFill("solid", fgColor="FFFFEE99")
|
||||||
|
ws["B2"] = 1234.5
|
||||||
|
ws["B2"].number_format = "#,##0.00"
|
||||||
|
ws["C3"] = "fusionnée"
|
||||||
|
ws.merge_cells("C3:D3")
|
||||||
|
ws["C3"].value = "fusionnée"
|
||||||
|
ws.freeze_panes = "A2"
|
||||||
|
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
|
||||||
|
|
||||||
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||||
|
a1 = styles["A1"]
|
||||||
|
assert "font-weight:600" in a1["style"]
|
||||||
|
assert "font-style:italic" in a1["style"]
|
||||||
|
assert a1["style"].startswith("color:#cc0000")
|
||||||
|
|
||||||
|
def test_background_fill_is_read(self, styled_book):
|
||||||
|
from backend.xlsx_reader import read_sheet_styles
|
||||||
|
|
||||||
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||||
|
assert "background:#ffee99" in styles["B1"]["style"]
|
||||||
|
|
||||||
|
def test_number_format_is_signalled(self, styled_book):
|
||||||
|
from backend.xlsx_reader import read_sheet_styles
|
||||||
|
|
||||||
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||||
|
assert "font-family" in styles["B2"]["style"]
|
||||||
|
|
||||||
|
def test_plain_cell_has_no_entry(self, styled_book):
|
||||||
|
from backend.xlsx_reader import read_sheet_styles
|
||||||
|
|
||||||
|
styles = read_sheet_styles(Path(styled_book), "Data")
|
||||||
|
assert "A2" not in styles # untouched cell
|
||||||
|
|
||||||
|
def test_merges_and_freeze(self, styled_book):
|
||||||
|
from backend.xlsx_reader import read_sheet_freeze, read_sheet_merges
|
||||||
|
|
||||||
|
assert read_sheet_merges(Path(styled_book), "Data") == ["C3:D3"]
|
||||||
|
assert read_sheet_freeze(Path(styled_book), "Data") == "A2"
|
||||||
|
|
||||||
|
def test_unknown_sheet_yields_empty(self, styled_book):
|
||||||
|
from backend.xlsx_reader import read_sheet_styles
|
||||||
|
|
||||||
|
assert read_sheet_styles(Path(styled_book), "Nope") == {}
|
||||||
|
|
||||||
|
def test_broken_file_yields_empty(self, test_vault_dir):
|
||||||
|
from backend.xlsx_reader import read_sheet_styles
|
||||||
|
|
||||||
|
bad = Path(test_vault_dir) / "broken-styles.xlsx"
|
||||||
|
bad.write_bytes(b"not a zip")
|
||||||
|
assert read_sheet_styles(bad, "Data") == {}
|
||||||
|
|
||||||
|
|
||||||
|
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"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
sheet = resp.json()["xlsx_sheets"][0]
|
||||||
|
assert sheet["styles"]["A1"].startswith("color:#cc0000")
|
||||||
|
assert sheet["aligns"] == {} or isinstance(sheet["aligns"], dict)
|
||||||
|
assert sheet["merges"] == ["C3:D3"]
|
||||||
|
assert sheet["freeze"] == "A2"
|
||||||
|
|
||||||
|
def test_rendered_html_carries_the_inline_style(self, client, styled_book):
|
||||||
|
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
|
||||||
|
html = resp.json()["xlsx_sheets"][0]["html"]
|
||||||
|
assert 'data-cell="A1" style="' in html
|
||||||
|
assert "font-weight:600" in html
|
||||||
Reference in New Issue
Block a user