feat: troncature annoncee et lecture par fenetres des tableurs #153
CI / lint (push) Successful in 2m29s
CI / security (push) Failing after 1m39s
CI / test (push) Successful in 4m1s
CI / build (push) Successful in 1m37s
CI / e2e (push) Successful in 14m47s

- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols,
  max_rows/max_cols et truncated ; la visionneuse affiche un bandeau
  « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la
  ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne).
- #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une
  fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de
  pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx.
- Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec
  contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture
  sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped,
  ruff/mypy 0, i18n parity.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
2026-09-28 10:38:24 -04:00
parent 06f8e63d06
commit 6ba04c4381
24 changed files with 726 additions and 47 deletions
+66
View File
@@ -38,6 +38,7 @@ from backend.schemas import (
BrowseResponse,
FileContentResponse,
FileRawResponse,
XlsxSheetWindowResponse,
)
from backend.services.files import read_raw_file
from backend.services.paths import resolve_safe_path
@@ -178,6 +179,71 @@ async def api_file_backlinks(
}
@router.get(
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
)
def api_file_xlsx_sheet(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx file"),
sheet: str = Query(..., description="Sheet name (as shown in the viewer tab)"),
offset: int = Query(0, ge=0, description="0-based index of the first row to return"),
limit: int = Query(
200, ge=1, le=1000, description="Rows to return (server-capped)"
),
current_user=Depends(require_auth),
):
"""Return a window of rows of one sheet of an .xlsx workbook (#153 A9).
Backs the viewer's lazy loading: instead of every sheet in a single JSON
payload, the client asks for the block it is about to display. The row
numbers and the ``data-cell`` references are the real A1 coordinates of the
sheet, so a window behaves like the full render (editing a cell in it
targets the right cell).
The response also carries ``total_rows``/``total_cols`` and the ``truncated``
flag, so the client can say what is hidden behind the 500x40 render caps
instead of silently hiding it.
Args:
vault_name: Name of the vault.
path: Relative path of the .xlsx file within the vault.
sheet: Sheet name; **404** if the workbook has no such sheet.
offset: 0-based index of the first row to return.
limit: Rows to return, capped server-side at 1000.
Returns:
``XlsxSheetWindowResponse`` with the rendered ``html`` of the window.
Raises:
HTTPException: 403 (vault access), 404 (vault, file or sheet unknown),
415 (not an .xlsx file), 500 (unreadable workbook).
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
vault_data = get_vault_data(vault_name)
if not vault_data:
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
file_path = resolve_safe_path(Path(vault_data["path"]), path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() != ".xlsx":
raise HTTPException(status_code=415, detail="Le fichier n'est pas un classeur .xlsx")
# Import tardif : openpyxl n'est chargé que si un .xlsx est réellement demandé.
from backend.xlsx_reader import read_sheet_window
try:
window = read_sheet_window(file_path, sheet, offset=offset, limit=limit)
except Exception as e:
logger.error(f"XLSX sheet read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
if window is None:
raise HTTPException(status_code=404, detail=f"Feuille introuvable: {sheet}")
return {"vault": vault_name, "path": path, **window}
@router.get("/api/file/{vault_name}", response_model=FileContentResponse)
async def api_file(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Return rendered HTML and metadata for a file.