feat: completude de l'editeur Excel - mise en forme, sortie, concurrence, cache, outils IA #156
CI / lint (push) Successful in 2m36s
CI / security (push) Successful in 1m49s
CI / test (push) Successful in 4m33s
CI / build (push) Successful in 2m39s
CI / e2e (push) Successful in 15m45s

L'editeur lisait les styles mais ne les ecrivait pas, exportait la feuille
entiere et laissait le dernier-ecrivain gagner entre processus.

- A8 : bouton Mise en forme (gras/italique/souligne, alignements, couleurs via
  selecteur natif, formats de nombre, fusion, volets figes, largeur/hauteur) et
  nouvelle route PUT .../xlsx/style (verrou, backup, swap atomique, garde de
  perte, If-Match) ; A9 : decision "pas de moteur de formule" annoncee dans
  l'UI ; A10 : undo/redo unifie, piles par fichier conservees au re-rendu
- A11 : export de la selection + Markdown/HTML/impression et recherche sur
  toutes les feuilles ; A12 : concurrence optimiste (If-Match -> 409 reparable,
  retry qui relit) ; A13 : cache des metadonnees par (chemin, mtime, taille)
- A14 : outils IA .xlsm/.csv + search_workbook, analyze_range,
  edit_xlsx_structure
- tests : test_xlsx_styles.py (17), test_spreadsheet_tools.py (34),
  TestOptimisticConcurrency/TestMetaCache, JSDOM xlsx-viewer 107/107
This commit is contained in:
2026-09-30 07:04:47 -04:00
parent 1bacfd69d9
commit c5c225a68e
32 changed files with 4615 additions and 302 deletions
+17 -5
View File
@@ -42,6 +42,7 @@ from backend.schemas import (
XlsxSheetWindowResponse,
)
from backend.services.files import read_raw_file
from backend.services.mutations import file_revision
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory, get_vault_root
@@ -226,7 +227,7 @@ def api_file_xlsx_dashboard(
)
def api_file_xlsx_sheet(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx file"),
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
limit: int = Query(
@@ -234,7 +235,7 @@ def api_file_xlsx_sheet(
),
current_user=Depends(require_auth),
):
"""Return a window of rows of one sheet of an .xlsx workbook (#153 A9).
"""Return a window of rows of one sheet of an .xlsx/.xlsm workbook (#153 A9).
Backs the viewer's lazy loading: instead of every sheet in a single JSON
payload, the client asks for the block it is about to display. The row
@@ -258,7 +259,7 @@ def api_file_xlsx_sheet(
Raises:
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
415 (not an .xlsx file), 500 (unreadable workbook).
415 (not an .xlsx/.xlsm file), 500 (unreadable workbook).
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
@@ -269,8 +270,12 @@ def api_file_xlsx_sheet(
file_path = resolve_safe_path(Path(vault_data["path"]), path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".xlsx":
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
# BUG-097 — a .xlsm rides the same editable viewer (and its lazy loading),
# so its row windows must be servable too; other formats stay refused.
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
raise HTTPException(
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
)
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
from backend.xlsx_reader import read_sheet_window
@@ -368,6 +373,10 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
# #156-A12 — optimistic-concurrency token: the viewer sends it
# back as `if_match` so another writer cannot be overwritten in
# silence (409 `conflict` instead).
"xlsx_revision": file_revision(file_path),
# #153 A1 — parts a save would drop; the viewer warns and asks
# for an explicit confirmation before forcing the write.
"xlsx_lossy_features": inspect_workbook(file_path),
@@ -507,6 +516,7 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"is_markdown": False,
"is_xlsx": True,
"xlsx_sheets": sheets,
"xlsx_revision": file_revision(file_path),
# Macros are NOT lossy for .xlsm: keep_vba re-serializes them
# (an empty LOSSY probe is what makes the save gate pass).
"xlsx_lossy_features": [],
@@ -554,6 +564,8 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
"title": file_path.name, "tags": [], "frontmatter": {},
"html": html, "raw_length": len(raw), "extension": ext,
"is_markdown": False, "is_csv": True,
# #156-A12 — same stale-write guard as the workbooks.
"xlsx_revision": file_revision(file_path),
}
# === JSON: syntax-highlighted display ===
+109 -10
View File
@@ -67,6 +67,9 @@ from backend.services.mutations import (
from backend.services.mutations import (
mutate_xlsx_structure as service_mutate_xlsx_structure,
)
from backend.services.mutations import (
mutate_xlsx_style as service_mutate_xlsx_style,
)
from backend.services.mutations import (
rename_directory as service_rename_directory,
)
@@ -127,7 +130,7 @@ def api_file_xlsx_save(
...,
description=(
'{"sheet": str, "cells": {"A1": value}, '
'"allow_formula": false, "force": false}'
'"allow_formula": false, "force": false, "if_match": str}'
),
),
current_user=Depends(require_auth),
@@ -144,6 +147,10 @@ def api_file_xlsx_save(
(slicers, form controls, connections, custom XML, signature, cached formula
results). Without it the call fails **409** ``xlsx_lossy_content`` and the
client asks the user to confirm (#153 A1).
* ``if_match`` — revision token returned by the read (#156-A12). When it no
longer matches the file on disk the write is refused with **409**
``conflict`` (``reason=stale_revision``) instead of overwriting a change
made by another writer. Omitted: last writer wins (curl, AI tools).
A backup is created before the workbook is rewritten, and the new archive
swaps in atomically. Declared as a sync endpoint on purpose: the openpyxl
@@ -168,16 +175,22 @@ def api_file_xlsx_save(
if not isinstance(raw, bool):
raise HTTPException(status_code=400, detail=f"Flag invalide: {name}")
flags[name] = raw
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_edit_xlsx_cells(
vault_name, path, sheet, cells, **flags
vault_name, path, sheet, cells, expected_revision=if_match, **flags
)
log_file_save(
current_user["username"], vault_name, path,
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": result["size"], "revision": result.get("revision"),
}
@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse)
@@ -186,7 +199,11 @@ def api_file_csv_save(
path: str = Query(..., description="Relative path to the .csv file"),
body: dict = Body(
...,
description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)',
description=(
'{"cells": {"A1": value}, "if_match": str} — A1-addressed text '
'edits (#153 A16). `if_match` is the revision token of the read '
'(#156-A12): a stale token fails with 409 `conflict`.'
),
),
current_user=Depends(require_auth),
):
@@ -195,6 +212,9 @@ def api_file_csv_save(
The grid is re-parsed with :mod:`csv`, patched and re-serialized
(RFC 4180 quoting). References beyond the extent grow the grid. Values
are stored verbatim as text — a CSV has no formula engine.
``if_match`` (optional, #156-A12) is the revision the client read: when the
file changed in the meantime the write is refused with **409** ``conflict``.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
@@ -206,13 +226,20 @@ def api_file_csv_save(
if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))):
raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}")
result = service_save_csv_cells(vault_name, path, cells)
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_save_csv_cells(vault_name, path, cells, expected_revision=if_match)
log_file_save(
current_user["username"], vault_name, path,
sum(len(str(v)) for v in cells.values()),
current_user.get("_request_ip", "unknown"),
)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": result["size"], "revision": result.get("revision"),
}
@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
@@ -224,7 +251,7 @@ def api_file_xlsx_structure(
description=(
'{"actions": [{"op": "sheet_add", "name": "X"}, '
'{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
'"force": false}'
'"force": false, "if_match": str}'
),
),
current_user=Depends(require_auth),
@@ -239,7 +266,9 @@ def api_file_xlsx_structure(
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
workbook carries features openpyxl cannot rewrite (same gate as the cell
edits). A backup is created before the archive is replaced.
edits). A backup is created before the archive is replaced. The optional
``if_match`` revision (#156-A12) refuses a structural rewrite on a file that
changed since it was read (**409** ``conflict``).
Args:
vault_name: Name of the vault.
@@ -258,16 +287,86 @@ def api_file_xlsx_structure(
raw_force = body.get("force", False)
if not isinstance(raw_force, bool):
raise HTTPException(status_code=400, detail="Flag invalide: force")
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_mutate_xlsx_structure(
vault_name, path, actions, force=raw_force
vault_name, path, actions, force=raw_force, expected_revision=if_match
)
log_file_save(
current_user["username"], vault_name, path,
len(actions),
current_user.get("_request_ip", "unknown"),
)
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"])}
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": len(result["applied"]), "revision": result.get("revision"),
}
@router.put("/api/file/{vault_name}/xlsx/style", response_model=FileSaveResponse)
def api_file_xlsx_style(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx/.xlsm file"),
body: dict = Body(
...,
description=(
'{"ops": [{"op": "cell", "sheet": "X", "range": "A1:B2", '
'"style": {"bold": true, "fill_color": "#ffe08a"}}], '
'"force": false, "if_match": str}'
),
),
current_user=Depends(require_auth),
):
"""Write formatting on an .xlsx/.xlsm workbook (#156-A8).
``ops`` is an ordered list applied in one locked, atomic rewrite:
``cell`` (``sheet``, ``range``/``cell``, ``style`` with ``bold``,
``italic``, ``underline``, ``font_color``/``fill_color`` as ``#rrggbb``,
``align`` and ``number_format``), ``merge``/``unmerge`` (``range``),
``col_width`` (``col``, ``width``), ``row_height`` (``row``, ``height``)
and ``freeze`` (``cell``, empty to release).
Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
workbook carries features openpyxl cannot rewrite (same gate as the cell
edits). A backup is created before the archive is replaced; the optional
``if_match`` revision (#156-A12) refuses a rewrite on a file that changed
since it was read (**409** ``conflict``).
Args:
vault_name: Name of the vault.
path: Relative path to the ``.xlsx``/``.xlsm`` file.
body: JSON body with ``ops`` (1 to 50) and optional ``force``.
Returns:
``FileSaveResponse`` confirming the write.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
ops = body.get("ops")
if not isinstance(ops, list) or not ops or len(ops) > 50:
raise HTTPException(status_code=400, detail="Ops invalides (1 à 50 par requête)")
raw_force = body.get("force", False)
if not isinstance(raw_force, bool):
raise HTTPException(status_code=400, detail="Flag invalide: force")
if_match = body.get("if_match")
if if_match is not None and not isinstance(if_match, str):
raise HTTPException(status_code=400, detail="Jeton invalide: if_match")
result = service_mutate_xlsx_style(
vault_name, path, ops, force=raw_force, expected_revision=if_match
)
log_file_save(
current_user["username"], vault_name, path,
len(ops),
current_user.get("_request_ip", "unknown"),
)
return {
"status": "ok", "vault": result["vault"], "path": result["path"],
"size": len(result["applied"]), "revision": result.get("revision"),
}
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)