Compare commits

...
6 Commits
Author SHA1 Message Date
bruno d5c528fead feat: structure des classeurs Excel editable depuis la visionneuse #153
A14 : service mutate_xlsx_structure (batch ordonné d'actions
sheet_add/sheet_rename/sheet_delete/sheet_duplicate et
row/col_insert/delete, une seule réécriture verrouillée et atomique,
409 lossy sans force, backup, dernier sheet protégé) ; endpoint
PUT /api/file/{vault}/xlsx/structure (1-50 actions/requête) ;
menu « structure » dans la visionneuse : ajout/renommage/duplication/
suppression de feuilles et insertion/suppression de ligne/colonne à
partir de la cellule active, confirmations explicites pour les
destructions, re-rendu serveur après succès, reprise force après
confirmation 409.

Vérifié : test_xlsx_structure.py 11 (ops, gardes, atomicité du batch,
409 lossy + force) + contre-preuve (garde dernier sheet neutralisée ->
1 échec), xlsx-viewer.test.mjs 33/33 (3 nouveaux), ruff 0, mypy 0,
i18n parity, validate-imports.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:53:15 -04:00
bruno 38f39a10ae feat: tri, filtre, recherche et export CSV dans la visionneuse XLSX #153
A13 (affichage seul, le classeur n'est jamais réécrit) : clic sur un
en-tête pour trier la colonne (asc/desc, numérique si toutes les valeurs
le sont, localeCompare fr sinon — les cellules modifiées suivent leur
ligne) ; l'unique champ de recherche filtre les lignes ET surligne les
occurrences (marque <mark>, navigation ↑/↓/Entrée, compteur i18n,
insensible à la casse par défaut, bouton Aa) ; bouton « Réinitialiser »
qui re-rend le fichier (vérité serveur) ; export CSV de la feuille
visible (BOM UTF-8, échappement ;/" et retrait des ombres de valeurs
calculées).

Vérifié : xlsx-viewer.test.mjs 30/30 (5 nouveaux) + contre-preuves
(tri neutralisé -> 1 échec, filtre neutralisé -> 1 échec), validate-imports,
i18n parity (xlsx.find_*, xlsx.sort_*, xlsx.csv_export).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:28:09 -04:00
bruno 48e023ba25 feat: navigation clavier et barre de formule dans la visionneuse XLSX #153
A7 : barre de formule sous la barre d'outils (nom de cellule active A1 +
miroir du contenu, édition live, Entrée valide, Échap annule puis
refocalise) ; flèches et Tab déplacent la cellule active dans les 4
directions (Shift inverse Tab), outline persistant sur la cellule active
quand le focus passe à la barre ; Maj+Entrée ne valide plus (réservé au
multiligne A7+). td.tabIndex=0 pour la focalisation clavier.

Vérifié : xlsx-viewer.test.mjs 25/25 (6 nouveaux), E2E 9/9 (barre +
flèches + Échap sur fixture réelle), validate-imports, i18n parity
(xlsx.formula_bar_placeholder, xlsx.active_cell).

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:18:24 -04:00
bruno 011ec84f23 feat: outils IA de lecture et d'edition des classeurs existants #153
A6 : list_xlsx_sheets (noms + dimensions + truncated), xlsx_to_markdown
(table bornee 100x20 pour le contexte LLM), update_xlsx_cells et
append_xlsx_rows (WRITE + confirmation, via edit_xlsx_cells : verrou,
ecriture atomique, neutralisation des formules, 409 lossy). Les lignes
ajoutees sont coercees comme dans le viewer (A10). Labels de step
ai.step.xlsx_* FR/EN ; update_xlsx_cells/append_xlsx_rows branches sur
MUTATING_TOOLS et FILE_WRITE_TOOLS (refresh viewer via obsigate:file-written).

Tests : test_spreadsheet_tools.py 17 (risque, confirmation, persistance,
garde formule heritee, coercion) + contre-preuve (cells vide -> 2 echecs).
ruff 0, mypy 0, i18n parity, validate-imports.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 12:05:16 -04:00
bruno 472ea9d309 feat: chargement à la demande des lignes cachées des tableurs #153
CI / lint (push) Successful in 2m24s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m0s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 15m34s
A9bis : sous une feuille tronquée, un pied de page « N lignes affichées
sur M · Charger la suite » fetch la fenêtre suivante (limit=500) au clic
ou à l'approche du bas du tableau (sentinelle de défilement, marge 120px).
Les lignes ajoutées passent par le même pipeline d'édition que le rendu
initial (setupCell factorisé) : éditables et sauvegardables immédiatement.
Fetch échoué → bouton restauré (retry) + toast ; feuille complète → pied
de page masqué (class done).

Vérifié : xlsx-viewer.test.mjs 19/19 (5 nouveaux, contre-preuve
wireLazyRows désactivé → 5 échecs), E2E 8/8 (A520 visible et éditable
après clic), validate-imports 40 modules, unit.test.mjs 12/12, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 11:52:42 -04:00
bruno 6ba04c4381 feat: troncature annoncee et lecture par fenetres des tableurs #153
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m1s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 14m47s
- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols,
  max_rows/max_cols et truncated ; la visionneuse affiche un bandeau
  « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la
  ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne).
- #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une
  fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de
  pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx.
- Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec
  contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture
  sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped,
  ruff/mypy 0, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 10:38:24 -04:00
33 changed files with 2914 additions and 57 deletions
+55 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.30.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.36.0**.
---
@@ -14,6 +14,60 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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.31.0] — 2026-09-28
### Correction
- **BUG-090 — troncature silencieuse d'une feuille `.xlsx` au-delà de
500 lignes × 40 colonnes.** `render_sheets()` renvoie les dimensions
déclarées par la feuille (`total_rows`/`total_cols`), les plafonds du
moteur (`max_rows`/`max_cols`) et un flag `truncated` : la visionneuse
affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 »
(i18n FR/EN) au lieu de présenter une table courte comme complète. La
ligne d'en-têtes est désormais figée au défilement vertical (`thead`
sticky, `top: auto` sur les numéros de ligne pour éviter leur
empilement en haut à gauche). *#153 A8/R5.*
### Ajouté
- **#153 A9 — chargement paresseux d'une feuille par fenêtres.**
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` renvoie un
bloc de lignes (`XlsxSheetWindowResponse`, plafond 1 000 lignes par
requête, `has_more` de pagination) avec les **vraies** coordonnées A1
et numéros de ligne de la feuille — une fenêtre se comporte exactement
comme le rendu complet. Erreurs typées : 404 feuille inconnue, 415
fichier non-`.xlsx`. La lecture des valeurs calculées en cache (#153
A12) s'applique aussi aux fenêtres.
- **#153 A9bis — « Charger la suite » sous une feuille tronquée.** Un
pied de page annonce la progression et fetch la fenêtre suivante au
clic ou à l'approche du bas du tableau (sentinelle de défilement).
Les lignes ajoutées passent par le même pipeline d'édition que le
rendu initial : éditables et sauvegardables immédiatement. Un fetch
échoué restore le bouton (retry possible) ; feuille complète → pied
de page masqué.
---
## [2.30.0] — 2026-09-27
### Correction
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.30.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.36.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.30.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.36.0).
---
*Projet : ObsiGate | Version : 2.30.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.36.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.30.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.36.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.30.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.36.0).
---
*Project: ObsiGate | Version: 2.30.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.30.0
2.36.0
+20
View File
@@ -185,6 +185,26 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
},
# GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI
# invalide) — les paramètres sont documentés par leurs Query().
("get", "/api/file/{vault_name}/xlsx/sheet"): {
"response": {
"vault": "TestVault",
"path": "data/budget.xlsx",
"sheet": "Budget",
"offset": 0,
"limit": 200,
"rows": 2,
"cols": 2,
"total_rows": 640,
"total_cols": 12,
"max_rows": 500,
"max_cols": 40,
"truncated": True,
"has_more": True,
"html": "<table>…</table>",
},
},
("post", "/api/search/replace"): {
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
+66
View File
@@ -38,6 +38,7 @@ from backend.schemas import (
BrowseResponse,
FileContentResponse,
FileRawResponse,
XlsxSheetWindowResponse,
)
from backend.services.files import read_raw_file
from backend.services.paths import resolve_safe_path
@@ -178,6 +179,71 @@ async def api_file_backlinks(
}
@router.get(
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
)
def api_file_xlsx_sheet(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx file"),
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
limit: int = Query(
200, ge=1, le=1000, description="Rows to return (server-capped)"
),
current_user=Depends(require_auth),
):
"""Return a window of rows of one sheet of an .xlsx workbook (#153 A9).
Backs the viewer's lazy loading: instead of every sheet in a single JSON
payload, the client asks for the block it is about to display. The row
numbers and the ``data-cell`` references are the real A1 coordinates of the
sheet, so a window behaves like the full render (editing a cell in it
targets the right cell).
The response also carries ``total_rows``/``total_cols`` and the ``truncated``
flag, so the client can say what is hidden behind the 500x40 render caps
instead of silently hiding it.
Args:
vault_name: Name of the vault.
path: Relative path of the .xlsx file within the vault.
sheet: Sheet name; **404** if the workbook has no such sheet.
offset: 0-based index of the first row to return.
limit: Rows to return, capped server-side at 1000.
Returns:
``XlsxSheetWindowResponse`` with the rendered ``html`` of the window.
Raises:
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
415 (not an .xlsx file), 500 (unreadable workbook).
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
file_path = resolve_safe_path(Path(vault_data["path"]), path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".xlsx":
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
from backend.xlsx_reader import read_sheet_window
try:
window = read_sheet_window(file_path, sheet, offset=offset, limit=limit)
except Exception as e:
logger.error(f"XLSX sheet read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
if window is None:
raise HTTPException(status_code=404, detail=f"Feuille introuvable: {sheet}")
return {"vault": vault_name, "path": path, **window}
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.
+58
View File
@@ -64,6 +64,9 @@ from backend.services.mutations import (
from backend.services.mutations import (
move_path as service_move_path,
)
from backend.services.mutations import (
mutate_xlsx_structure as service_mutate_xlsx_structure,
)
from backend.services.mutations import (
rename_directory as service_rename_directory,
)
@@ -174,6 +177,61 @@ def api_file_xlsx_save(
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)
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.
+32 -1
View File
@@ -286,7 +286,12 @@ class FileContentResponse(BaseModel):
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")
xlsx_sheets: list[dict[str, Any]] | None = Field(
default=None, description="Rendered xlsx sheets [{name, html}]"
default=None,
description=(
"Rendered xlsx sheets [{name, html, rows, cols, total_rows, "
"total_cols, max_rows, max_cols, truncated}] — `truncated` is true "
"when the sheet exceeds the 500x40 render caps (#153 A8)"
),
)
xlsx_lossy_features: list[str] | None = Field(
default=None,
@@ -305,6 +310,32 @@ class FileContentResponse(BaseModel):
image_mime: str | None = Field(default=None, description="MIME type for image files")
class XlsxSheetWindowResponse(BaseModel):
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
Served by ``GET /api/file/{vault_name}/xlsx/sheet``; the row numbers and
the ``data-cell`` references in ``html`` are the real A1 coordinates of the
sheet, whatever the window.
"""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
sheet: str = Field(description="Sheet name (as shown in the tab)")
offset: int = Field(description="0-based index of the first returned row")
limit: int = Field(description="Maximum number of rows returned (capped server-side)")
rows: int = Field(description="Rows actually returned in this window")
cols: int = Field(description="Columns of the rendered window")
total_rows: int = Field(description="Rows the sheet declares")
total_cols: int = Field(description="Columns the sheet declares")
max_rows: int = Field(description="Row cap of the renderer (500) — the coverage of this window")
max_cols: int = Field(description="Column cap of the renderer (40)")
truncated: bool = Field(
description="True when the sheet exceeds the 500x40 render caps"
)
has_more: bool = Field(description="True when rows remain after this window")
html: str = Field(description="Rendered HTML table for the window")
class FileRawResponse(BaseModel):
"""Raw text content of a file."""
+187
View File
@@ -455,6 +455,193 @@ 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 append_to_file(
vault_name: str,
path: str,
+1
View File
@@ -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 documents as _documents # noqa: F401 (registers document 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.context import (
ToolConfirmationRequired,
+4
View File
@@ -52,6 +52,10 @@ _STEP_LABELS: dict[str, tuple[str, str | None]] = {
"git_search_issues": ("git_issues", "query"),
"git_get_file": ("git_file", "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_csv": ("csv_create", "path"),
"create_pdf": ("pdf_create", "path"),
+55
View File
@@ -315,6 +315,61 @@ class DocxInput(BaseModel):
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):
"""Create a .csv file in a vault from rows of cells."""
+286
View File
@@ -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,
}
+145 -8
View File
@@ -28,6 +28,12 @@ logger = logging.getLogger("obsigate.xlsx_reader")
MAX_ROWS = 500
MAX_COLS = 40
# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet
# loading). The endpoint is bounded so a single request can never ask for the
# whole workbook back in one JSON payload; the UI pages through the rest.
MAX_WINDOW_ROWS = 1_000
DEFAULT_WINDOW_ROWS = 200
# #153 A1 — workbook parts openpyxl does not re-serialize on load+save.
# Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables
# DO survive the round-trip, so they are deliberately absent from this map.
@@ -98,7 +104,11 @@ def _cell_cached(cached: list[list[str]] | None, r: int, c: int) -> str:
return row[c] if c < len(row) else ""
def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
def _table(
grid: list[list[str]],
cached: list[list[str]] | None = None,
row_offset: int = 0,
) -> str:
"""Render a grid as an HTML table.
``cached`` is the same grid read with ``data_only=True`` (#153 A12): where a
@@ -107,6 +117,10 @@ def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
number Excel last calculated instead of only the formula text. The span
carries ``data-cached-value`` and is titled client-side from
``xlsx.cached_value_title`` — the backend never emits UI text.
``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
coordinates of the sheet, not of the window.
"""
if not grid:
return "<p><em>Feuille vide</em></p>"
@@ -119,7 +133,7 @@ def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
]
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(grid, start=1):
for r, row in enumerate(grid, start=row_offset + 1):
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
for c, val in enumerate(row, start=1):
ref = f"{get_column_letter(c)}{r}"
@@ -191,19 +205,24 @@ def inspect_workbook(file_path: Path) -> list[str]:
return []
def render_sheets(file_path: Path) -> list[dict[str, str]]:
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``.
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
Reads the workbook twice: once with ``data_only=False`` for the formulas
(what the user must edit) and, when any formula carries a cached result
(#153 A12), once with ``data_only=True`` to show what Excel last computed.
The second pass is skipped entirely when the archive holds no cached value,
so the common case still costs a single load.
``total_rows``/``total_cols`` are the dimensions the sheet declares and
``truncated`` says whether the hard caps actually cut it (#153 A8) — the
viewer needs both to stop silently hiding the tail of a sheet.
"""
wb = load_workbook(str(file_path), read_only=True, data_only=False)
try:
formulas = [_sheet_grid(ws) for ws in wb.worksheets]
titles = [ws.title for ws in wb.worksheets]
extents = [_sheet_extent(ws) for ws in wb.worksheets]
finally:
wb.close()
@@ -219,16 +238,134 @@ def render_sheets(file_path: Path) -> list[dict[str, str]]:
# shift every cached value left of its formula. Indexing it
# positionally against the untrimmed grid keeps the two aligned.
shadow = cached[i] if cached is not None and i < len(cached) else None
sheets.append({"name": title, "html": _table(grid, shadow)})
total_rows, total_cols = extents[i]
sheets.append(
{
"name": title,
"html": _table(grid, shadow),
"rows": len(grid),
"cols": max((len(r) for r in grid), default=0),
"total_rows": total_rows,
"total_cols": total_cols,
# Coverage, not display size: `rows`/`cols` are post-trim (a
# sheet of 3 filled cells in a 500-row block renders 1x1), and
# the client must announce the cap it stopped at, not how many
# cells happen to be non-empty.
"max_rows": MAX_ROWS,
"max_cols": MAX_COLS,
# A sheet is truncated when the caps, not the trailing blanks,
# decided its shape: comparing against the *rendered* size would
# flag every sheet carrying a few empty formatted rows.
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
}
)
return sheets
def _sheet_grid(ws: Any) -> list[list[str]]:
"""Read one worksheet into a grid of formatted strings, bounded by the caps."""
def read_sheet_window(
file_path: Path,
sheet: str,
offset: int = 0,
limit: int = DEFAULT_WINDOW_ROWS,
) -> dict[str, Any] | None:
"""Return a window of rows of one sheet, or ``None`` if the sheet is unknown.
Backs the lazy per-sheet loading of #153 A9: the viewer asks for the rows
it is about to display instead of shipping every sheet in the initial file
payload. ``offset`` is 0-based; the row numbers and the ``data-cell``
references in the returned ``html`` are the real A1 coordinates of the
sheet, so a window is indistinguishable from a full render.
``limit`` is clamped to :data:`MAX_WINDOW_ROWS`. Raises nothing: an unknown
sheet yields ``None`` and a broken workbook propagates the caller's usual
500.
"""
offset = max(int(offset), 0)
limit = min(max(int(limit), 1), MAX_WINDOW_ROWS)
wb = load_workbook(str(file_path), read_only=True, data_only=False)
try:
if sheet not in wb.sheetnames:
return None
ws = wb[sheet]
total_rows, total_cols = _sheet_extent(ws)
grid = _trim(
_sheet_grid(ws, min_row=offset + 1, max_row=offset + limit)
)
finally:
wb.close()
shadow: list[list[str]] | None = None
# Same A12 rule as the full render: the second read only happens when the
# archive really holds cached results.
if _has_cached_values(file_path):
shadow = _read_cached_window(file_path, sheet, offset, limit)
return {
"sheet": sheet,
"offset": offset,
"limit": limit,
"rows": len(grid),
"cols": max((len(r) for r in grid), default=0),
"total_rows": total_rows,
"total_cols": total_cols,
"max_rows": MAX_ROWS,
"max_cols": MAX_COLS,
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
"has_more": offset + len(grid) < total_rows,
"html": _table(grid, shadow, row_offset=offset),
}
def _read_cached_window(
file_path: Path, sheet: str, offset: int, limit: int
) -> list[list[str]] | None:
"""``data_only=True`` grid for one window, or ``None`` if unavailable.
Best effort like :func:`_read_cached_grids`: a workbook Excel opens but
openpyxl cannot re-read must still display (formulas only).
"""
try:
wb = load_workbook(str(file_path), read_only=True, data_only=True)
except Exception:
return None
try:
if sheet not in wb.sheetnames:
return None
return _sheet_grid(
wb[sheet], min_row=offset + 1, max_row=offset + limit
)
except Exception:
logger.debug("xlsx cached window unavailable", exc_info=True)
return None
finally:
wb.close()
def _sheet_extent(ws: Any) -> tuple[int, int]:
"""Rows and columns the worksheet declares, never negative.
``max_row``/``max_column`` come from the sheet's dimension record; a
hand-edited file may omit it, hence the defensive coercion.
"""
try:
rows = max(int(getattr(ws, "max_row", 0) or 0), 0)
except (TypeError, ValueError):
rows = 0
try:
cols = max(int(getattr(ws, "max_column", 0) or 0), 0)
except (TypeError, ValueError):
cols = 0
return rows, cols
def _sheet_grid(
ws: Any, min_row: int = 1, max_row: int = MAX_ROWS, max_col: int = MAX_COLS
) -> list[list[str]]:
"""Read a worksheet window into a grid of formatted strings, bounded by the caps."""
return [
[_fmt(v) for v in row]
for row in ws.iter_rows(
min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True
min_row=min_row, max_row=max_row, max_col=max_col, values_only=True
)
]
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.30.0"
version = "2.36.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.30.0"
version = "2.36.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.30.0",
"version": "2.36.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+29 -4
View File
@@ -171,13 +171,38 @@ curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx"
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
**409** `conflict` au lieu d'écraser la première.
### Feuilles volumineuses et lecture par fenêtres
Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille. Quand
une feuille dépasse ce plafond, un bandeau **« Feuille tronquée »**
l'annonce explicitement (par exemple « 500 lignes affichées sur 520 »)
au lieu de présenter une table courte comme complète — le classeur,
lui, n'est jamais modifié. La ligne d'en-têtes de colonnes reste
visible pendant le défilement vertical.
Côté API, `GET /api/file/{vault}/xlsx/sheet` sert une feuille **par
fenêtres de lignes**, y compris au-delà du plafond d'affichage — les
coordonnées A1 renvoyées sont celles de la feuille réelle :
```bash
curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=Budget&offset=500&limit=200"
```
- `offset` : première ligne renvoyée (0-based) ; `limit` : nombre de
lignes (1 à 1 000 par requête).
- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer.
- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier
n'est pas un `.xlsx`.
### Limites
- Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille, sans
pagination : au-delà, le contenu n'est pas affiché (et non éditable).
- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ;
sous une feuille plus grande, le bouton **« Charger la suite »** (ou le
défilement vers le bas du tableau) ajoute les lignes suivantes par
fenêtres de 500 — elles deviennent aussitôt éditables et
sauvegardables.
- Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas
rendus ; le contenu des tableurs n'est pas non plus indexé pour la
recherche (contrairement aux PDF).
rendus.
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
---
+2
View File
@@ -196,6 +196,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| *BUG-087* | Édition d'un `.xlsx` concurrente (deux onglets, agent IA + viewer) : read-modify-write sans verrou, le dernier écrivain gagne silencieusement | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::_xlsx_write_lock` | Deux `PUT xlsx/save` simultanés sur le même fichier → une écriture est écrasée sans trace | Verrou par chemin (registre + garde, timeout 15 s) autour du cycle load → edit → `os.replace` ; attente dépassée → **409** `conflict`. L'endpoint est devenu `def` (sync) pour que l'attente s'exécute dans le threadpool et ne bloque pas la boucle d'événements. Test : `TestXlsxWriteLock` (2) | #153 A3. Verrou en mémoire, par processus : protège les cas d'un même serveur (le cas desktop/Tauri). 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-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 |
### TODOs techniques (améliorations / nouvelles tâches)
@@ -213,6 +214,7 @@ 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 |
|---|---|---|---|---|---|
| 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-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) |
| *(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) |
+5 -5
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.30.0 | **Dernière mise à jour :** 2026-09-27
> **Version :** 2.36.0 | **Dernière mise à jour :** 2026-09-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -47,7 +47,7 @@
### 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 :** 🟡
- **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), reste A6-A9 puis A13-A17
- **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
- **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
valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure,
@@ -74,8 +74,8 @@
- [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`
- [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage)
- [ ] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse)
- [ ] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
- [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] **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] **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)
@@ -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 |
| ⚪ 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/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; A5-A12 4-6 j, A13-A17 2-4 j) | 6-11 jours restants |
| ⚪ 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 |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
---
+36 -11
View File
@@ -3,7 +3,7 @@
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
> [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), reste A6-A9 puis A13-A17
> **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
> **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
> l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
@@ -15,8 +15,9 @@
| Couche | Fichier | Rôle |
|---|---|---|
| Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) |
| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html}]` |
| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets` |
| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html, rows, cols, total_*, max_*, truncated}]` |
| Endpoint fenêtre | `backend/routers/files_read.py` | `GET /api/file/{vault}/xlsx/sheet?path=&sheet=&offset=&limit=` (#153 A9) — une fenêtre de lignes, vraies coordonnées A1 |
| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets`, `XlsxSheetWindowResponse` |
| Écriture | `backend/services/mutations.py:227-320` | `edit_xlsx_cells()` (backup, refs A1 validées, coercion `str`→`int`/`float`) |
| Endpoint écriture | `backend/routers/files_write.py:116-148` | `PUT /api/file/{vault}/xlsx/save` (1 à 500 cellules / requête) |
| Documentation API | `backend/openapi_docs.py:184-187` | exemple d'appel `xlsx/save` |
@@ -105,7 +106,7 @@ restent à faire (A7).
| R2 | Écriture non atomique (`wb.save()` en place) → classeur corrompu si crash | `mutations.edit_xlsx_cells` | **A2** — `.tmp` + `os.replace` | 🟢 livré (BUG-086) |
| R3 | Concurrence : deux éditions (onglets, watcher + IA) → dernier écrivain gagne | `mutations.edit_xlsx_cells` | **A3** — verrou par chemin, **409** `conflict` | 🟢 livré (BUG-087) |
| R4 | **Injection de formule** : une saisie `=cmd\|…`, `=HYPERLINK(…)` est stockée comme formule par openpyxl → DDE à l'ouverture dans Excel | `mutations._write_cell` | **A4** — forçage texte (`data_type="s"`), opt-in `allow_formula` | 🟢 livré (BUG-088) |
| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | ⚪ à faire |
| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | 🟢 bandeau + dimensions exposées (BUG-090) ; le chargement paresseux par fenêtres sert les lignes au-delà du plafond |
## 5. Backlog #153 — sous-tâches
@@ -149,13 +150,35 @@ couverture) · effort en jours-homme de développement + tests.
- [ ] **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
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`).
- [ ] **A8 — `thead` sticky + indicateur de troncature (R5).** Ligne d'en-têtes figlée au
défilement vertical ; bandeau « feuille tronquée à 500 lignes × 40 colonnes » ; libellés
FR/EN.
- [ ] **A9 — Chargement paresseux par feuille (supprime le plafond).** Endpoint
`GET /api/file/{vault}/xlsx/sheet?sheet=N&offset=&limit=` (`response_model` +
`backend/openapi_docs.py`), rendu à la demande avec défilement virtuel, bouton « charger
tout ».
- [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
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
`total_rows`/`total_cols` (dimensions déclarées), `max_rows`/`max_cols` (plafonds) et
`truncated` — le bandeau « feuille tronquée » annonce le **plafond atteint** et non la
taille élaguée (une feuille creuse rend 1×1 tout en couvrant 500 lignes) ; libellés
`xlsx.truncated_*` FR/EN. *Vérifié :* `TestXlsxTruncationNotice` (4), `xlsx-viewer.test.mjs`
(4 nouveaux), E2E sur `test_vault/sample-xlsx-large.xlsx` (520 lignes).
- [x] **A9 — Chargement paresseux par feuille (côté API).** Endpoint
`GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`,
exemple dans `backend/openapi_docs.py`) : une fenêtre de 1 à 1 000 lignes (plafond
`MAX_WINDOW_ROWS`, `limit>1000` → 422), `has_more` pour paginer, valeurs calculées A12
incluses. Les numéros de ligne et `data-cell` restent les coordonnées A1 réelles de la
feuille (`_table(..., row_offset=offset)`) : une fenêtre est indistinguishable d'un rendu
complet et une édition dans la fenêtre cible la bonne cellule. Erreurs : 404 feuille
inconnue / fichier absent, 415 non-`.xlsx`. *Vérifié :* `TestXlsxSheetWindow` (11),
**contre-preuve** (neutraliser l'offset → 3 tests échouent), E2E « l'endpoint de fenêtre
sert les lignes au-delà du plafond ».
- [x] **A9bis — Chargement à la demande côté UI.** Sous une feuille tronquée, un pied de page
« N lignes affichées sur M · Charger la suite » apparaît : cliquer — ou approcher du bas
du tableau (sentinelle de défilement, marge 120 px) — fetch la fenêtre suivante
(`limit=500`) et l'insère dans la table. Les lignes ajoutées passent par le **même**
pipeline d'édition que le rendu initial (`setupCell` factorisé : contenteditable, dirty,
Échap, collage monoligne, info-bulle valeurs calculées) et sont donc sauvegardables
immédiatement. Un fetch échoué restore le libellé du pied de page (retry possible) et
toast l'erreur ; feuille complète → pied de page masqué (`class="done"`).
*Vérifié :* `xlsx-viewer.test.mjs` 19/19 (5 nouveaux), **contre-preuve** (désactiver
`wireLazyRows` → 5 tests échouent), E2E « le bouton charger la suite ajoute les lignes
cachées » sur `sample-xlsx-large.xlsx` (A520 visible et éditable après clic).
- [x] **A10 — Types et formats de saisie.** `_coerce_xlsx_value()` reconnait les booléens
(`true`/`vrai`/`oui`/`yes` et leurs négatifs) et les dates FR `JJ/MM/AAAA` (+ `HH:MM`),
jour-first comme Excel en locale française : `01/02/2026` = 1ᵉʳ février. Une saisie
@@ -208,3 +231,5 @@ couverture) · effort en jours-homme de développement + tests.
| 2026-09-27 | Périmètre de perte **remesuré** sur openpyxl 3.1.5 : graphiques / images / TCD sont préservés, seules les valeurs en cache et quelques parties exotiques sont perdues |
| 2026-09-27 | **P0 livré** (BUG-085 → BUG-088) : `xlsx_lossy_features` + 409 `xlsx_lossy_content`, écriture atomique, verrou par fichier, formules stockées en texte par défaut |
| 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 | **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) |
+2
View File
@@ -60,12 +60,14 @@ const MUTATING_TOOLS = new Set([
'rename_file', 'rename_directory', 'move_path', 'replace_in_files',
'delete_file', 'delete_directory', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
'update_xlsx_cells', 'append_xlsx_rows',
]);
// Subset carrying a concrete `vault` + `path`: the displayed document is
// reloaded from disk so an open viewer/editor reflects the agent's write.
const FILE_WRITE_TOOLS = new Set([
'edit_file', 'append_to_file', 'create_file', 'restore_backup',
'create_xlsx', 'create_docx', 'create_csv', 'create_pdf',
'update_xlsx_cells', 'append_xlsx_rows',
]);
/**
+533 -8
View File
@@ -1005,6 +1005,35 @@ export function renderVideoViewer(area, data) {
// confirmation before retrying with `force: true`. A value starting with "=" or
// "@" is stored as text unless the user turns the formula toggle on, so a typed
// `=cmd|…` cannot execute when the file is later opened in Excel.
// #153 A8 — a sheet bigger than the render caps used to be silently cut: the
// user saw a short table and no way to tell the rest of the workbook still
// existed. The backend now reports the real dimensions of every sheet, so the
// note states exactly what is hidden (and that those cells are not editable
// here — the workbook itself is untouched). A payload without those fields
// (older cache) simply shows no note.
function truncationNote(sheet) {
// The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim
// (a sparse sheet renders 1x1) while the note must say how far the view
// reaches.
const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 };
const reasons = [];
if (Number(sheet.total_rows) > cap.rows) {
reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) }));
}
if (Number(sheet.total_cols) > cap.cols) {
reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) }));
}
if (!reasons.length) return "";
return `<div class="xlsx-truncated" role="note">
<i data-lucide="scissors" class="xlsx-truncated-icon"></i>
<div class="xlsx-warning-body">
<strong>${escapeHtml(t("xlsx.truncated_title"))}</strong>
<span>${escapeHtml(reasons.join(" "))}</span>
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.truncated_hint"))}</span>
</div>
</div>`;
}
export function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const lossy = data.xlsx_lossy_features || [];
@@ -1019,7 +1048,7 @@ export function renderXlsxViewer(area, data) {
).join("")}</div>`
: "";
const panels = sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
).join("");
const lossWarning = lossy.length
? `<div class="xlsx-warning" role="note">
@@ -1045,9 +1074,32 @@ export function renderXlsxViewer(area, data) {
<button class="btn-action" id="xlsx-download-btn">
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
</button>
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
</button>
<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>
</div>
${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>`;
@@ -1056,36 +1108,228 @@ export function renderXlsxViewer(area, data) {
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; };
// #153 A12 — the backend marks the last value Excel computed; the wording is
// translated here so the tooltip follows the UI language.
area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => {
el.title = t("xlsx.cached_value_title");
// #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is
// fetched window by window from GET …/xlsx/sheet when the user reaches the
// end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ».
// Appended rows reuse the exact same edit pipeline as the initial render.
const wireLazyRows = (panel) => {
const meta = sheets[Number(panel.dataset.sheet)] || {};
if (!meta.truncated) return;
const wrapper = panel.querySelector(".csv-table-wrapper");
const table = panel.querySelector(".xlsx-table tbody");
if (!wrapper || !table) return;
let offset = Number(meta.rows) || 0;
const total = Number(meta.total_rows) || 0;
let loading = false;
let done = offset >= total;
const foot = document.createElement("div");
foot.className = "xlsx-load-more";
const refreshFoot = () => {
foot.textContent = done
? ""
: `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`;
foot.classList.toggle("done", done);
};
refreshFoot();
foot.addEventListener("click", () => { if (!done) loadMore(); });
wrapper.insertAdjacentElement("afterend", foot);
const appendWindow = (win) => {
const doc = new DOMParser().parseFromString(`<table>${win.html}</table>`, "text/html");
// No `tbody` selector: the fragment embeds its own wrapper div, so the
// parse yields bare `<tr>` inside `<table>` (the rows we want) — while
// any `tbody` in the fragment belongs to the *embedded* wrapper table.
doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr));
offset = win.offset + win.rows;
done = !win.has_more;
refreshFoot();
// The new rows must behave like the initial ones: contenteditable,
// dirty tracking, cached-value tooltip, icons in the fresh footnote.
panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell);
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
safeCreateIcons();
};
const loadMore = async () => {
if (loading || done) return;
loading = true;
const label = foot.textContent;
foot.textContent = t("xlsx.loading_more");
try {
const win = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/sheet?path=${encodeURIComponent(data.path)}` +
`&sheet=${encodeURIComponent(sheets[Number(panel.dataset.sheet)].name)}` +
`&offset=${offset}&limit=500`,
);
appendWindow(win);
} catch (err) {
foot.textContent = label; // restore: a failed fetch must not eat the button
showToast(`${t("xlsx.load_error")}: ${err.message || err}`, "error");
} finally {
loading = false;
}
};
// Scroll sentinel: near the bottom of the wrapper, pull the next window.
wrapper.addEventListener("scroll", () => {
if (loading || done) return;
if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore();
});
};
// ── #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();
}
});
// Editable cells: Enter blurs, Escape reverts, paste stays single-line.
area.querySelectorAll(".xlsx-table td").forEach((td) => {
const setupCell = (td) => {
td.contentEditable = "true";
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.addEventListener("input", () => {
td.classList.add("xlsx-dirty");
syncFormulaBar(td);
refreshSaveState();
});
td.addEventListener("focus", () => setActiveCell(td));
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") {
td.textContent = td.dataset.orig;
td.classList.remove("xlsx-dirty");
syncFormulaBar(td);
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) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " ");
document.execCommand("insertText", false, text);
});
};
// #153 A12 — the backend marks the last value Excel computed; the wording is
// translated here so the tooltip follows the UI language.
area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => {
el.title = t("xlsx.cached_value_title");
});
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
panelEls.forEach(wireLazyRows);
// The formula bar starts disabled: nothing is selected yet.
setActiveCell(null);
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
tab.addEventListener("click", () => {
const idx = tab.dataset.sheet;
@@ -1148,6 +1392,7 @@ export function renderXlsxViewer(area, data) {
td.classList.remove("xlsx-dirty");
td.dataset.orig = td.textContent;
});
if (activeTd) syncFormulaBar(activeTd);
refreshSaveState();
showToast(t("editor.saved"), "success");
} catch (err) {
@@ -1165,6 +1410,286 @@ export function renderXlsxViewer(area, data) {
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.
area.querySelector("#xlsx-structure-btn").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();
}
+43
View File
@@ -1829,6 +1829,45 @@
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.cached_value_title": "Last value calculated by Excel",
"xlsx.truncated_title": "Truncated sheet",
"xlsx.truncated_rows": "{shown} of {total} rows displayed.",
"xlsx.truncated_cols": "{shown} of {total} columns displayed.",
"xlsx.truncated_hint": "Cells outside the displayed area cannot be edited here; the workbook is unchanged.",
"xlsx.load_more": "Load more",
"xlsx.loading_more": "Loading…",
"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.feature_cached_values": "cached values",
"xlsx.feature_slicers": "slicers and timelines",
"xlsx.feature_form_controls": "form controls",
@@ -2001,6 +2040,10 @@
"ai.step.git_issues": "Searched issues: {value}",
"ai.step.git_file": "Read a repo file: {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.csv_create": "CSV file proposed: {value}",
"ai.step.pdf_create": "PDF document proposed: {value}",
+43
View File
@@ -1829,6 +1829,45 @@
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
"xlsx.truncated_title": "Feuille tronquée",
"xlsx.truncated_rows": "{shown} lignes affichées sur {total}.",
"xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.",
"xlsx.truncated_hint": "Les cellules hors de l'affichage ne sont pas éditables ici ; le classeur n'est pas modifié.",
"xlsx.load_more": "Charger la suite",
"xlsx.loading_more": "Chargement…",
"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.feature_cached_values": "valeurs calculées",
"xlsx.feature_slicers": "segments et chronologies",
"xlsx.feature_form_controls": "contrôles de formulaire",
@@ -2001,6 +2040,10 @@
"ai.step.git_issues": "Issues recherchées : {value}",
"ai.step.git_file": "Fichier de dépôt lu : {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.csv_create": "Fichier CSV proposé : {value}",
"ai.step.pdf_create": "Document PDF proposé : {value}",
+194 -2
View File
@@ -10931,6 +10931,7 @@ body.desktop-mode .editor-container {
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
position: relative; /* anchors the A14 structure menu */
}
.xlsx-toolbar-actions {
margin-left: auto;
@@ -10967,12 +10968,25 @@ body.desktop-mode .editor-container {
border-right: 1px solid var(--border-light, var(--border));
position: sticky;
left: 0;
z-index: 1;
/* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
at `top: 0`, so a row number left sticky on both axes piles up in the
top-left corner instead of tracking its own row. */
top: auto;
z-index: 2;
}
.xlsx-table th.xlsx-corner {
left: 0;
top: 0;
z-index: 2;
z-index: 4;
}
/* #153 A8 — the column headers stay visible while the sheet scrolls down.
Declared explicitly (and not inherited from `.csv-table th`) so the stacking
order is intentional: thead (3) < row numbers (2) < corner (4). */
.xlsx-table thead th {
position: sticky;
top: 0;
z-index: 3;
background: var(--surface);
}
.xlsx-table td[contenteditable] {
cursor: text;
@@ -10983,6 +10997,135 @@ body.desktop-mode .editor-container {
outline: 2px solid var(--accent, #4a90d9);
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 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);
}
.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 {
background: rgba(255, 196, 0, 0.18);
}
@@ -11051,6 +11194,55 @@ body.desktop-mode .editor-container {
color: var(--text-secondary);
opacity: 0.85;
}
/* #153 A8 — "feuille tronquée" notice. Deliberately NOT the `.xlsx-warning`
look: that one is a data-loss alert, this one only says part of the sheet is
out of view. */
.xlsx-truncated {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 8px 10px;
margin-bottom: 8px;
border: 1px solid var(--border);
border-left: 3px solid var(--accent, #4a90d9);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.82rem;
line-height: 1.45;
}
.xlsx-truncated-icon {
width: 16px;
height: 16px;
flex: 0 0 auto;
margin-top: 1px;
color: var(--accent, #4a90d9);
}
/* #153 A9bis — “charger la suite” footnote under a truncated sheet. Also the
scroll sentinel target: clickable whole, disabled look once the sheet is
fully loaded. */
.xlsx-load-more {
display: block;
margin: 6px 0 10px;
padding: 6px 12px;
border: 1px dashed var(--border);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 0.82rem;
text-align: center;
cursor: pointer;
user-select: none;
}
.xlsx-load-more:hover {
border-color: var(--accent, #4a90d9);
color: var(--text-primary);
}
.xlsx-load-more.done {
display: none;
}
.xlsx-formula-toggle {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-weight: 600;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.30.0",
"version": "2.36.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
Binary file not shown.
+116 -1
View File
@@ -14,6 +14,12 @@
* - the f(x) toggle is off by default, so "=B1*3" is stored as text ;
* - the value Excel last computed is shown under the formula (#153 A12).
*
* Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) :
* - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ;
* - the column headers stay pinned while the sheet scrolls (#153 A8) ;
* - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
* with the real A1 coordinates (#153 A9).
*
* The fixture is restored byte-for-byte in `afterAll` so a local run never
* dirties the working copy.
*
@@ -27,6 +33,10 @@ import path from 'node:path';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const VAULT = 'TestVault';
const FIXTURE = 'sample-xlsx-lossy.xlsx';
// #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so
// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
// committed next to the other fixture; nothing in the suite writes to it.
const LARGE = 'sample-xlsx-large.xlsx';
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
@@ -45,7 +55,11 @@ async function login(page) {
}
async function openFixture(page) {
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`);
return openXlsx(page, FIXTURE);
}
async function openXlsx(page, file) {
const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
@@ -141,3 +155,104 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
});
// ── A8 — troncature annoncée + en-têtes figés ───────────────────────────────
test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const note = page.locator('#content-area .xlsx-truncated');
await expect(note).toBeVisible();
// Libellé traduit (jamais de texte UI backend, jamais de couleur en dur).
await expect(note).toContainText('Feuille tronquée');
await expect(note).toContainText('500 lignes affichées sur 520');
// La dernière ligne rendue est la 500e ; les suivantes ne sont pas là.
await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1);
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
});
test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const header = page.locator('#content-area .xlsx-table thead th').nth(1);
const before = await header.boundingBox();
await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; });
await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 })
.toBeLessThanOrEqual(before.y + 1);
// Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto`
// de A8) et la première ligne de données reste lisible sous l'en-tête.
const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox();
const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox();
expect(second.y - first.y).toBeGreaterThan(4);
});
test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
const res = await page.request.get(
`${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50`
);
expect(res.status()).toBe(200);
const win = await res.json();
expect(win.total_rows).toBe(520);
expect(win.offset).toBe(500);
expect(win.has_more).toBe(false);
// Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre :
// la ligne 520 est servie comme A520, pas comme A20.
expect(win.html).toContain('data-cell="A520"');
expect(win.html).toContain('Operation 519');
expect(win.html).not.toContain('data-cell="A1"');
});
test('le bouton « charger la suite » ajoute les lignes cachées (#153 A9bis)', async ({ page }) => {
await login(page);
await openXlsx(page, LARGE);
// La ligne 500 est la dernière rendue ; le pied de page l'annonce.
const foot = page.locator('#content-area .xlsx-load-more');
await expect(foot).toBeVisible();
await expect(foot).toContainText('Charger la suite');
await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
// Un clic fetch la suite (offset 500, 20 lignes) et l'insère dans la table.
await foot.click();
await expect(page.locator('#content-area td[data-cell="A520"]')).toBeVisible({ timeout: 10000 });
// Une ligne nouvellement arrivée est éditable comme les autres.
const cell = page.locator('#content-area td[data-cell="A520"]');
await cell.click();
await expect(cell).toBeFocused();
// Tout est chargé → le pied de page est masqué.
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);
});
});
+411 -5
View File
@@ -7,6 +7,16 @@
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
* retries with `force: true` (or gives up when refused);
* - A4 : the f(x) toggle flips `allow_formula` in the save payload.
* - A8 : a sheet bigger than the render caps shows the truncation notice.
* - A9bis : the tail of a truncated sheet is fetched window by window from
* GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are
* 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
*/
@@ -35,6 +45,7 @@ const dom = new JSDOM(
const w = dom.window;
globalThis.window = w;
globalThis.document = w.document;
globalThis.DOMParser = w.DOMParser;
globalThis.HTMLElement = w.HTMLElement;
globalThis.Element = w.Element;
globalThis.Node = w.Node;
@@ -89,7 +100,11 @@ async function test(name, fn) {
confirmCalls = 0;
confirmPrompts = [];
confirmAnswer = true;
nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } });
apiQueue = [];
nextResponse = () => {
if (apiQueue.length) return apiQueue.shift();
return { ok: true, status: 200, body: { status: "ok" } };
};
try {
await fn();
console.log(` ✓ ${name}`);
@@ -112,23 +127,36 @@ await initI18n();
const sheetHtml = (value) =>
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
'<thead><tr><th class="xlsx-corner"></th><th>A</th></tr></thead><tbody>' +
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
'<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><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>";
function mount({ lossy = [] } = {}) {
// The JSDOM fetch double serves locale files; everything else is a recorded
// API call answered by `nextResponse`. `apiQueue` lets a test script the
// successive windows a lazy-loading sheet will request.
let apiQueue = [];
function mount({ lossy = [], sheet = {} } = {}) {
const area = document.getElementById("content-area");
area.innerHTML = "";
renderXlsxViewer(area, {
vault: "V",
path: "data.xlsx",
is_xlsx: true,
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
xlsx_lossy_features: lossy,
});
return area;
}
/** Mount a sheet flagged truncated so wireLazyRows() arms the footnote. */
function mountTruncated({ total = 520, rows = 1 } = {}) {
return mount({
sheet: { rows, cols: 3, total_rows: total, total_cols: 3, max_rows: 500, max_cols: 40, truncated: total > 500 },
});
}
/** Mark a cell dirty the way a user edit would. */
function editCell(area, ref, text) {
const td = area.querySelector(`td[data-cell="${ref}"]`);
@@ -268,6 +296,384 @@ await test("a non-409 failure is not retried", async () => {
assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1);
});
// ── A8 — truncation notice ───────────────────────────────────────────────────
await test("no notice when the sheet fits within the render caps", () => {
const area = mount({
sheet: { rows: 500, cols: 40, total_rows: 500, total_cols: 40, max_rows: 500, max_cols: 40, truncated: false },
});
assert.equal(area.querySelector(".xlsx-truncated"), null);
});
await test("notice states the cap and the real size of a truncated sheet", () => {
const area = mount({
sheet: { rows: 500, cols: 12, total_rows: 1200, total_cols: 12, max_rows: 500, max_cols: 40, truncated: true },
});
const note = area.querySelector(".xlsx-truncated");
assert.ok(note, "notice absent");
const txt = note.textContent;
assert.ok(txt.includes(FR["xlsx.truncated_title"]), txt);
// {shown} is the CAP (500), not the post-trim row count: a sparse sheet
// renders 1 row but the view still reaches 500 of them.
const expected = FR["xlsx.truncated_rows"].replace("{shown}", "500").replace("{total}", "1200");
assert.ok(txt.includes(expected), `${txt} !includes ${expected}`);
// Nothing to say about the columns here (12 < 40).
assert.ok(!txt.includes(FR["xlsx.truncated_cols"]), txt);
});
await test("notice mentions both axes when rows AND columns overflow", () => {
const area = mount({
sheet: { rows: 1, cols: 40, total_rows: 501, total_cols: 45, max_rows: 500, max_cols: 40, truncated: true },
});
const txt = area.querySelector(".xlsx-truncated").textContent;
assert.ok(
txt.includes(FR["xlsx.truncated_cols"].replace("{shown}", "40").replace("{total}", "45")),
txt
);
});
await test("a payload without the dimensions shows no notice", () => {
// Backward compatibility: an older cached response must not produce "NaN".
const area = mount({ sheet: { name: "Feuille1" } });
assert.equal(area.querySelector(".xlsx-truncated"), null);
assert.ok(!area.textContent.includes("NaN"));
});
// ── A9bis — lazy loading of the truncated tail ──────────────────────────────
const windowHtml = (from, to) =>
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
"<tbody>" +
Array.from({ length: to - from + 1 }, (_, i) => {
const r = from + i;
return `<tr><th class="xlsx-rownum">${r}</th><td data-cell="A${r}">Ligne ${r}</td></tr>`;
}).join("") +
"</tbody></table></div>";
await test("a truncated sheet gets a load-more footnote, a normal one does not", () => {
const truncated = mountTruncated();
assert.ok(truncated.querySelector(".xlsx-load-more"), "footnote absent");
assert.ok(truncated.querySelector(".xlsx-load-more").textContent.includes(FR["xlsx.load_more"]));
const plain = mount({ sheet: { rows: 10, cols: 2, total_rows: 10, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false } });
assert.equal(plain.querySelector(".xlsx-load-more"), null);
});
await test("clicking the footnote fetches the next window with the right query", async () => {
const area = mountTruncated();
apiQueue.push({
ok: true,
status: 200,
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 2, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 3) },
});
area.querySelector(".xlsx-load-more").click();
await new Promise((r) => setTimeout(r, 5));
assert.equal(calls.length, 1);
assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/sheet\?path=data\.xlsx&sheet=Feuille1&offset=1&limit=500/);
// Rows 2 and 3 landed in the table with their real coordinates.
assert.ok(area.querySelector('td[data-cell="A2"]'));
assert.ok(area.querySelector('td[data-cell="A3"]'));
assert.ok(area.querySelector('th.xlsx-rownum') && area.textContent.includes("Ligne 3"));
// Everything loaded → the footnote is hidden (kept in the DOM, class `done`).
const foot = area.querySelector(".xlsx-load-more");
assert.ok(foot, "the footnote element survives");
assert.equal(foot.classList.contains("done"), true);
assert.ok(!foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
});
await test("appended rows are editable and tracked as dirty", async () => {
const area = mountTruncated();
apiQueue.push({
ok: true,
status: 200,
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
});
area.querySelector(".xlsx-load-more").click();
await new Promise((r) => setTimeout(r, 5));
editCell(area, "A2", "modifié");
area.querySelector("#xlsx-save-btn").click();
await new Promise((r) => setTimeout(r, 5));
assert.equal(calls.length, 2, "the save went out");
assert.equal(calls[1].url.includes("/xlsx/save"), true);
assert.deepEqual(calls[1].body.cells, { A2: "modifié" });
assert.equal(calls[1].body.sheet, "Feuille1");
});
await test("a failed window fetch keeps the footnote and shows an error toast", async () => {
const area = mountTruncated();
apiQueue.push({ ok: false, status: 500, body: { detail: "boom" } });
area.querySelector(".xlsx-load-more").click();
await new Promise((r) => setTimeout(r, 5));
const foot = area.querySelector(".xlsx-load-more");
assert.ok(foot, "footnote must survive a failed fetch");
assert.ok(foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent);
// `api()` itself toasts the failure (shared behaviour, asserted in E2E);
// here we assert the local consequence: the footnote keeps its label.
// Retrying works once the server answers again.
apiQueue.push({
ok: true,
status: 200,
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) },
});
foot.click();
await new Promise((r) => setTimeout(r, 5));
assert.ok(area.querySelector('td[data-cell="A2"]'));
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);
});
await test("two windows in a row walk the whole sheet", async () => {
const area = mountTruncated({ total: 1200 });
apiQueue.push({
ok: true,
status: 200,
body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 500, total_rows: 1200, truncated: true, has_more: true, html: windowHtml(2, 501) },
});
area.querySelector(".xlsx-load-more").click();
await new Promise((r) => setTimeout(r, 5));
assert.ok(area.querySelector('td[data-cell="A501"]'));
// The footnote still shows, with the updated progress.
let foot = area.querySelector(".xlsx-load-more");
assert.ok(foot, "more rows remain");
assert.ok(foot.textContent.includes("501"), foot.textContent);
apiQueue.push({
ok: true,
status: 200,
body: { sheet: "Feuille1", offset: 501, limit: 500, rows: 200, total_rows: 1200, truncated: true, has_more: false, html: windowHtml(502, 701) },
});
foot.click();
await new Promise((r) => setTimeout(r, 5));
assert.ok(area.querySelector('td[data-cell="A701"]'));
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
});
// ── Report ──────────────────────────────────────────────────────────────────
console.log(`\n${passCount}/${testCount} tests passed\n`);
process.exit(passCount === testCount ? 0 : 1);
+225
View File
@@ -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": [],
})
+188
View File
@@ -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()
+167
View File
@@ -519,6 +519,173 @@ class TestXlsxWriteLock:
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "premier"
@pytest.fixture
def wide_xlsx(test_vault_dir: str) -> str:
"""Workbook whose sheet exceeds BOTH render caps (501 rows x 45 cols).
Sparse on purpose: a cell in A501 and one in AS1 are enough for openpyxl
to declare those dimensions, without writing 20 000 cells to disk.
"""
from openpyxl import Workbook
path = Path(test_vault_dir) / "grand.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "tête"
ws["A501"] = "dernière ligne"
ws["AS1"] = "colonne 45"
wb.save(path)
return str(path)
@pytest.fixture
def edge_xlsx(test_vault_dir: str) -> str:
"""Sheet exactly on the caps (500 rows x 40 cols) — must NOT be truncated."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "limite.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "bord"
ws["A500"] = "ligne 500"
ws["AN1"] = "colonne 40"
wb.save(path)
return str(path)
# ── #153 A8 — silent truncation made visible ─────────────────────────────
class TestXlsxTruncationNotice:
"""A sheet bigger than the caps must SAY so instead of looking complete."""
def test_render_reports_the_real_dimensions(self, client, wide_xlsx):
resp = client.get(f"/api/file/{VAULT}", params={"path": "grand.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert (sheet["total_rows"], sheet["total_cols"]) == (501, 45)
assert sheet["truncated"] is True
# The caps are the coverage the banner announces — `rows`/`cols` are
# post-trim and would understate it on a sparse sheet.
assert (sheet["max_rows"], sheet["max_cols"]) == (500, 40)
assert (sheet["rows"], sheet["cols"]) == (1, 1) # only 3 filled cells
def test_a_sheet_on_the_caps_is_not_flagged(self, client, edge_xlsx):
"""Boundary: 500x40 is exactly what the renderer supports."""
resp = client.get(f"/api/file/{VAULT}", params={"path": "limite.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert sheet["truncated"] is False
assert (sheet["total_rows"], sheet["total_cols"]) == (500, 40)
def test_a_normal_sheet_is_not_flagged(self, client, xlsx_file):
resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"})
assert all(not s["truncated"] for s in resp.json()["xlsx_sheets"])
def test_blank_tail_is_not_reported_as_truncation(self, client, test_vault_dir):
"""A sheet with empty rows below its data fits in the caps."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "blanc.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "seule ligne"
ws["A300"] = None # formatted-but-empty row inside the caps
wb.save(path)
resp = client.get(f"/api/file/{VAULT}", params={"path": "blanc.xlsx"})
sheet = resp.json()["xlsx_sheets"][0]
assert sheet["truncated"] is False
assert sheet["rows"] == 1 # trailing blanks dropped by _trim
# ── #153 A9 — lazy per-sheet loading ─────────────────────────────────────
class TestXlsxSheetWindow:
"""GET /api/file/{vault}/xlsx/sheet — one window of one sheet."""
def _get(self, client, path="budget.xlsx", **params):
return client.get(
f"/api/file/{VAULT}/xlsx/sheet", params={"path": path, **params}
)
def test_window_returns_rows_and_totals(self, client, xlsx_file):
resp = self._get(client, sheet="Budget", offset=0, limit=10)
assert resp.status_code == 200
data = resp.json()
assert data["sheet"] == "Budget"
assert (data["offset"], data["limit"]) == (0, 10)
assert data["rows"] == 2 and data["cols"] == 2
assert data["total_rows"] == 2 and data["truncated"] is False
assert (data["max_rows"], data["max_cols"]) == (500, 40)
assert data["has_more"] is False
assert 'data-cell="A1"' in data["html"]
def test_window_keeps_the_real_a1_coordinates(self, client, xlsx_file):
"""A window must be indistinguishable from a full render: the A1
references and the row numbers have to be the sheet's, not the
window's, or an edit would land on the wrong cell. The CONTENT matters
as much as the label — row 2 of "Budget" is "Total", not "Poste"."""
data = self._get(client, sheet="Budget", offset=1, limit=1).json()
assert data["rows"] == 1
assert 'data-cell="A2"' in data["html"]
assert 'data-cell="A1"' not in data["html"]
assert "<th class=\"xlsx-rownum\">2</th>" in data["html"]
assert "Total" in data["html"] and "Poste" not in data["html"]
def test_window_offsets_walk_the_whole_sheet(self, client, wide_xlsx):
first = self._get(client, path="grand.xlsx", sheet="Data", offset=0, limit=10).json()
last = self._get(client, path="grand.xlsx", sheet="Data", offset=500, limit=10).json()
assert first["truncated"] is True and first["has_more"] is True
assert 'data-cell="A501"' in last["html"] # the row the caps used to hide
assert "dernière ligne" in last["html"]
assert "dernière ligne" not in first["html"]
assert last["rows"] == 1 and last["has_more"] is False
def test_offset_past_the_end_is_empty_not_an_error(self, client, xlsx_file):
resp = self._get(client, sheet="Budget", offset=9999, limit=10)
assert resp.status_code == 200
data = resp.json()
assert data["rows"] == 0
assert data["has_more"] is False
def test_cached_result_survives_the_window(self, client, lossy_xlsx):
"""A12 must not be lost on the lazy path."""
data = self._get(client, path="risky.xlsx", sheet="Data", offset=0, limit=10).json()
assert "xlsx-cached" in data["html"]
def test_unknown_sheet_is_404(self, client, xlsx_file):
resp = self._get(client, sheet="Nope")
assert resp.status_code == 404
assert "Feuille introuvable" in resp.json()["detail"]
def test_missing_file_is_404(self, client, test_vault_dir):
assert self._get(client, path="absent.xlsx", sheet="Budget").status_code == 404
def test_non_xlsx_file_is_415(self, client, test_vault_dir):
(Path(test_vault_dir) / "note.md").write_text("# hi", encoding="utf-8")
resp = self._get(client, path="note.md", sheet="Budget")
assert resp.status_code == 415
def test_limit_above_the_server_cap_is_rejected(self, client, xlsx_file):
"""The cap is a contract, not a silent truncation of the request."""
assert self._get(client, sheet="Budget", limit=100_000).status_code == 422
def test_reader_clamps_a_hostile_limit(self, xlsx_file):
"""Belt and braces: the reader caps too, whoever calls it."""
from backend.xlsx_reader import MAX_WINDOW_ROWS, read_sheet_window
window = read_sheet_window(Path(xlsx_file), "Budget", offset=0, limit=10**9)
assert window["limit"] == MAX_WINDOW_ROWS
def test_reader_rejects_a_negative_offset(self, xlsx_file):
from backend.xlsx_reader import read_sheet_window
window = read_sheet_window(Path(xlsx_file), "Budget", offset=-5, limit=10)
assert window["offset"] == 0
# ── #153 A4 — formula injection ──────────────────────────────────────────