Compare commits

...
5 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
26 changed files with 2213 additions and 35 deletions
+29 -4
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.31.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.36.0**.
---
@@ -14,6 +14,26 @@ 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
@@ -37,9 +57,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
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. Le défilement virtuel côté UI
reste à faire ; l'endpoint rend les lignes au-delà du plafond déjà
accessibles aux clients API.
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é.
---
+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.31.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.31.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.31.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.31.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.31.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.36.0).
---
*Project: ObsiGate | Version: 2.31.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.31.0
2.36.0
+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.
+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,
}
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.31.0"
version = "2.36.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.31.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.31.0",
"version": "2.36.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+5 -3
View File
@@ -196,9 +196,11 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=
### Limites
- L'affichage intégré reste plafonné à **500 lignes × 40 colonnes** par
feuille (le défilement automatique au-delà est en préparation) ; les
lignes cachées restent accessibles via l'endpoint ci-dessus.
- 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.
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
+3 -3
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.31.0 | **Dernière mise à jour :** 2026-09-28
> **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), **A8/A9 livrés le 2026-09-28** (avec BUG-090, défilement virtuel A9bis à venir), reste A6-A7 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,
@@ -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 ; P1 ✅ A5, A8-A10, A12 — reste A6-A7 ; A13-A17 2-4 j) | 3-5 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** |
---
+14 -4
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), **A8/A9 livrés le 2026-09-28** (avec BUG-090), reste A6-A7 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.**
@@ -167,9 +167,18 @@ couverture) · effort en jours-homme de développement + tests.
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 ». *Reste :* défilement virtuel côté UI + bouton «
charger tout » (le viewer garde son rendu complet ≤ 500×40, mais le bandeau A8 dit la
vérité) ; suivi dans la Roadmap.
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
@@ -223,3 +232,4 @@ couverture) · effort en jours-homme de développement + tests.
| 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',
]);
/**
+503 -7
View File
@@ -1074,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>`;
@@ -1085,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;
@@ -1177,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) {
@@ -1194,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();
}
+39
View File
@@ -1833,6 +1833,41 @@
"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",
@@ -2005,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}",
+39
View File
@@ -1833,6 +1833,41 @@
"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",
@@ -2005,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}",
+154
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;
@@ -10996,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);
}
@@ -11089,6 +11219,30 @@ body.desktop-mode .editor-container {
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.31.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": {
+45
View File
@@ -210,4 +210,49 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
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);
});
});
+365 -3
View File
@@ -8,6 +8,15 @@
* 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
*/
@@ -36,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;
@@ -90,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}`);
@@ -113,10 +127,16 @@ 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>";
// 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 = "";
@@ -130,6 +150,13 @@ function mount({ lossy = [], sheet = {} } = {}) {
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}"]`);
@@ -312,6 +339,341 @@ await test("a payload without the dimensions shows no notice", () => {
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()