From 6ba04c4381583c86c310ff65876634e927593302 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 28 Sep 2026 10:38:21 -0400 Subject: [PATCH] feat: troncature annoncee et lecture par fenetres des tableurs #153 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- CHANGELOG.md | 31 ++++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/openapi_docs.py | 20 +++ backend/routers/files_read.py | 66 ++++++++++ backend/schemas.py | 33 ++++- backend/xlsx_reader.py | 153 ++++++++++++++++++++-- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md | 31 ++++- docs/ISSUES_TODOLIST.md | 2 + docs/ROADMAP.md | 10 +- docs/features/xlsx-viewer.md | 37 ++++-- frontend/js/viewer.js | 31 ++++- frontend/locales/en.json | 4 + frontend/locales/fr.json | 4 + frontend/style.css | 42 +++++- package.json | 2 +- test_vault/sample-xlsx-large.xlsx | Bin 0 -> 15273 bytes tests/e2e/xlsx-viewer.spec.js | 72 +++++++++- tests/frontend/xlsx-viewer.test.mjs | 48 ++++++- tests/test_xlsx_viewer.py | 167 ++++++++++++++++++++++++ 24 files changed, 726 insertions(+), 47 deletions(-) create mode 100644 test_vault/sample-xlsx-large.xlsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f72a28..ca104dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.30.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.31.0**. --- @@ -14,6 +14,35 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.31.0] — 2026-09-28 + +### Correction + +- **BUG-090 — troncature silencieuse d'une feuille `.xlsx` au-delà de + 500 lignes × 40 colonnes.** `render_sheets()` renvoie les dimensions + déclarées par la feuille (`total_rows`/`total_cols`), les plafonds du + moteur (`max_rows`/`max_cols`) et un flag `truncated` : la visionneuse + affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » + (i18n FR/EN) au lieu de présenter une table courte comme complète. La + ligne d'en-têtes est désormais figée au défilement vertical (`thead` + sticky, `top: auto` sur les numéros de ligne pour éviter leur + empilement en haut à gauche). *#153 A8/R5.* + +### Ajouté + +- **#153 A9 — chargement paresseux d'une feuille par fenêtres.** + `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` renvoie un + bloc de lignes (`XlsxSheetWindowResponse`, plafond 1 000 lignes par + requête, `has_more` de pagination) avec les **vraies** coordonnées A1 + et numéros de ligne de la feuille — une fenêtre se comporte exactement + comme le rendu complet. Erreurs typées : 404 feuille inconnue, 415 + fichier non-`.xlsx`. La lecture des valeurs calculées en cache (#153 + A12) s'applique aussi aux fenêtres. Le défilement virtuel côté UI + reste à faire ; l'endpoint rend les lignes au-delà du plafond déjà + accessibles aux clients API. + +--- + ## [2.30.0] — 2026-09-27 ### Correction diff --git a/README.fr.md b/README.fr.md index 8588467..8ca6edd 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.30.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.31.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.30.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.31.0). --- -*Projet : ObsiGate | Version : 2.30.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.31.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 544a355..ac9bc82 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.30.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.31.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.30.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.31.0). --- -*Project: ObsiGate | Version: 2.30.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.31.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 6a69003..bafceb3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.30.0 +2.31.0 diff --git a/backend/openapi_docs.py b/backend/openapi_docs.py index 432aa1e..1143700 100644 --- a/backend/openapi_docs.py +++ b/backend/openapi_docs.py @@ -185,6 +185,26 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = { "request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False}, "response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1}, }, + # GET : pas d'exemple de requête (un requestBody sur un GET serait un OpenAPI + # invalide) — les paramètres sont documentés par leurs Query(). + ("get", "/api/file/{vault_name}/xlsx/sheet"): { + "response": { + "vault": "TestVault", + "path": "data/budget.xlsx", + "sheet": "Budget", + "offset": 0, + "limit": 200, + "rows": 2, + "cols": 2, + "total_rows": 640, + "total_cols": 12, + "max_rows": 500, + "max_cols": 40, + "truncated": True, + "has_more": True, + "html": "…
", + }, + }, ("post", "/api/search/replace"): { "request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True}, "response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True}, diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index 95990a6..316aaa1 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -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. diff --git a/backend/schemas.py b/backend/schemas.py index df8c845..00cf507 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -286,7 +286,12 @@ class FileContentResponse(BaseModel): is_csv: bool | None = Field(default=None, description="True for CSV files") is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files") xlsx_sheets: list[dict[str, Any]] | None = Field( - default=None, description="Rendered xlsx sheets [{name, html}]" + default=None, + description=( + "Rendered xlsx sheets [{name, html, rows, cols, total_rows, " + "total_cols, max_rows, max_cols, truncated}] — `truncated` is true " + "when the sheet exceeds the 500x40 render caps (#153 A8)" + ), ) xlsx_lossy_features: list[str] | None = Field( default=None, @@ -305,6 +310,32 @@ class FileContentResponse(BaseModel): image_mime: str | None = Field(default=None, description="MIME type for image files") +class XlsxSheetWindowResponse(BaseModel): + """One window of rows of a single .xlsx sheet (lazy loading, #153 A9). + + Served by ``GET /api/file/{vault_name}/xlsx/sheet``; the row numbers and + the ``data-cell`` references in ``html`` are the real A1 coordinates of the + sheet, whatever the window. + """ + + vault: str = Field(description="Vault name") + path: str = Field(description="Relative file path within the vault") + sheet: str = Field(description="Sheet name (as shown in the tab)") + offset: int = Field(description="0-based index of the first returned row") + limit: int = Field(description="Maximum number of rows returned (capped server-side)") + rows: int = Field(description="Rows actually returned in this window") + cols: int = Field(description="Columns of the rendered window") + total_rows: int = Field(description="Rows the sheet declares") + total_cols: int = Field(description="Columns the sheet declares") + max_rows: int = Field(description="Row cap of the renderer (500) — the coverage of this window") + max_cols: int = Field(description="Column cap of the renderer (40)") + truncated: bool = Field( + description="True when the sheet exceeds the 500x40 render caps" + ) + has_more: bool = Field(description="True when rows remain after this window") + html: str = Field(description="Rendered HTML table for the window") + + class FileRawResponse(BaseModel): """Raw text content of a file.""" diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py index e265b0a..05cc0c2 100644 --- a/backend/xlsx_reader.py +++ b/backend/xlsx_reader.py @@ -28,6 +28,12 @@ logger = logging.getLogger("obsigate.xlsx_reader") MAX_ROWS = 500 MAX_COLS = 40 +# #153 A9 — window size served by ``read_sheet_window()`` (lazy per-sheet +# loading). The endpoint is bounded so a single request can never ask for the +# whole workbook back in one JSON payload; the UI pages through the rest. +MAX_WINDOW_ROWS = 1_000 +DEFAULT_WINDOW_ROWS = 200 + # #153 A1 — workbook parts openpyxl does not re-serialize on load+save. # Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables # DO survive the round-trip, so they are deliberately absent from this map. @@ -98,7 +104,11 @@ def _cell_cached(cached: list[list[str]] | None, r: int, c: int) -> str: return row[c] if c < len(row) else "" -def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str: +def _table( + grid: list[list[str]], + cached: list[list[str]] | None = None, + row_offset: int = 0, +) -> str: """Render a grid as an HTML table. ``cached`` is the same grid read with ``data_only=True`` (#153 A12): where a @@ -107,6 +117,10 @@ def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str: number Excel last calculated instead of only the formula text. The span carries ``data-cached-value`` and is titled client-side from ``xlsx.cached_value_title`` — the backend never emits UI text. + + ``row_offset`` is the number of rows skipped before this grid (#153 A9): the + row numbers and the ``data-cell`` references must stay the real A1 + coordinates of the sheet, not of the window. """ if not grid: return "

Feuille vide

" @@ -119,7 +133,7 @@ def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str: ] out += [f"{get_column_letter(c)}" for c in range(1, n_cols + 1)] out.append("") - for r, row in enumerate(grid, start=1): + for r, row in enumerate(grid, start=row_offset + 1): out.append(f'{r}') for c, val in enumerate(row, start=1): ref = f"{get_column_letter(c)}{r}" @@ -191,19 +205,24 @@ def inspect_workbook(file_path: Path) -> list[str]: return [] -def render_sheets(file_path: Path) -> list[dict[str, str]]: - """Return ``[{"name": sheet_title, "html": table_html}, ...]``. +def render_sheets(file_path: Path) -> list[dict[str, Any]]: + """Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``. Reads the workbook twice: once with ``data_only=False`` for the formulas (what the user must edit) and, when any formula carries a cached result (#153 A12), once with ``data_only=True`` to show what Excel last computed. The second pass is skipped entirely when the archive holds no cached value, so the common case still costs a single load. + + ``total_rows``/``total_cols`` are the dimensions the sheet declares and + ``truncated`` says whether the hard caps actually cut it (#153 A8) — the + viewer needs both to stop silently hiding the tail of a sheet. """ wb = load_workbook(str(file_path), read_only=True, data_only=False) try: formulas = [_sheet_grid(ws) for ws in wb.worksheets] titles = [ws.title for ws in wb.worksheets] + extents = [_sheet_extent(ws) for ws in wb.worksheets] finally: wb.close() @@ -219,16 +238,134 @@ def render_sheets(file_path: Path) -> list[dict[str, str]]: # shift every cached value left of its formula. Indexing it # positionally against the untrimmed grid keeps the two aligned. shadow = cached[i] if cached is not None and i < len(cached) else None - sheets.append({"name": title, "html": _table(grid, shadow)}) + total_rows, total_cols = extents[i] + sheets.append( + { + "name": title, + "html": _table(grid, shadow), + "rows": len(grid), + "cols": max((len(r) for r in grid), default=0), + "total_rows": total_rows, + "total_cols": total_cols, + # Coverage, not display size: `rows`/`cols` are post-trim (a + # sheet of 3 filled cells in a 500-row block renders 1x1), and + # the client must announce the cap it stopped at, not how many + # cells happen to be non-empty. + "max_rows": MAX_ROWS, + "max_cols": MAX_COLS, + # A sheet is truncated when the caps, not the trailing blanks, + # decided its shape: comparing against the *rendered* size would + # flag every sheet carrying a few empty formatted rows. + "truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS, + } + ) return sheets -def _sheet_grid(ws: Any) -> list[list[str]]: - """Read one worksheet into a grid of formatted strings, bounded by the caps.""" +def read_sheet_window( + file_path: Path, + sheet: str, + offset: int = 0, + limit: int = DEFAULT_WINDOW_ROWS, +) -> dict[str, Any] | None: + """Return a window of rows of one sheet, or ``None`` if the sheet is unknown. + + Backs the lazy per-sheet loading of #153 A9: the viewer asks for the rows + it is about to display instead of shipping every sheet in the initial file + payload. ``offset`` is 0-based; the row numbers and the ``data-cell`` + references in the returned ``html`` are the real A1 coordinates of the + sheet, so a window is indistinguishable from a full render. + + ``limit`` is clamped to :data:`MAX_WINDOW_ROWS`. Raises nothing: an unknown + sheet yields ``None`` and a broken workbook propagates the caller's usual + 500. + """ + offset = max(int(offset), 0) + limit = min(max(int(limit), 1), MAX_WINDOW_ROWS) + + wb = load_workbook(str(file_path), read_only=True, data_only=False) + try: + if sheet not in wb.sheetnames: + return None + ws = wb[sheet] + total_rows, total_cols = _sheet_extent(ws) + grid = _trim( + _sheet_grid(ws, min_row=offset + 1, max_row=offset + limit) + ) + finally: + wb.close() + + shadow: list[list[str]] | None = None + # Same A12 rule as the full render: the second read only happens when the + # archive really holds cached results. + if _has_cached_values(file_path): + shadow = _read_cached_window(file_path, sheet, offset, limit) + return { + "sheet": sheet, + "offset": offset, + "limit": limit, + "rows": len(grid), + "cols": max((len(r) for r in grid), default=0), + "total_rows": total_rows, + "total_cols": total_cols, + "max_rows": MAX_ROWS, + "max_cols": MAX_COLS, + "truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS, + "has_more": offset + len(grid) < total_rows, + "html": _table(grid, shadow, row_offset=offset), + } + + +def _read_cached_window( + file_path: Path, sheet: str, offset: int, limit: int +) -> list[list[str]] | None: + """``data_only=True`` grid for one window, or ``None`` if unavailable. + + Best effort like :func:`_read_cached_grids`: a workbook Excel opens but + openpyxl cannot re-read must still display (formulas only). + """ + try: + wb = load_workbook(str(file_path), read_only=True, data_only=True) + except Exception: + return None + try: + if sheet not in wb.sheetnames: + return None + return _sheet_grid( + wb[sheet], min_row=offset + 1, max_row=offset + limit + ) + except Exception: + logger.debug("xlsx cached window unavailable", exc_info=True) + return None + finally: + wb.close() + + +def _sheet_extent(ws: Any) -> tuple[int, int]: + """Rows and columns the worksheet declares, never negative. + + ``max_row``/``max_column`` come from the sheet's dimension record; a + hand-edited file may omit it, hence the defensive coercion. + """ + try: + rows = max(int(getattr(ws, "max_row", 0) or 0), 0) + except (TypeError, ValueError): + rows = 0 + try: + cols = max(int(getattr(ws, "max_column", 0) or 0), 0) + except (TypeError, ValueError): + cols = 0 + return rows, cols + + +def _sheet_grid( + ws: Any, min_row: int = 1, max_row: int = MAX_ROWS, max_col: int = MAX_COLS +) -> list[list[str]]: + """Read a worksheet window into a grid of formatted strings, bounded by the caps.""" return [ [_fmt(v) for v in row] for row in ws.iter_rows( - min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True + min_row=min_row, max_row=max_row, max_col=max_col, values_only=True ) ] diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 53b4add..972b422 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.30.0" +version = "2.31.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index f40bc51..62e0dbd 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.30.0" +version = "2.31.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index f173fed..35ca0e5 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.30.0", + "version": "2.31.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index ba8c67b..2d4aba0 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -171,13 +171,36 @@ curl -X PUT "http://localhost:2020/api/file/Recettes/xlsx/save?path=budget.xlsx" - Deux sauvegardes simultanées sur le même fichier : la seconde reçoit **409** `conflict` au lieu d'écraser la première. +### Feuilles volumineuses et lecture par fenêtres + +Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille. Quand +une feuille dépasse ce plafond, un bandeau **« Feuille tronquée »** +l'annonce explicitement (par exemple « 500 lignes affichées sur 520 ») +au lieu de présenter une table courte comme complète — le classeur, +lui, n'est jamais modifié. La ligne d'en-têtes de colonnes reste +visible pendant le défilement vertical. + +Côté API, `GET /api/file/{vault}/xlsx/sheet` sert une feuille **par +fenêtres de lignes**, y compris au-delà du plafond d'affichage — les +coordonnées A1 renvoyées sont celles de la feuille réelle : + +```bash +curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet=Budget&offset=500&limit=200" +``` + +- `offset` : première ligne renvoyée (0-based) ; `limit` : nombre de + lignes (1 à 1 000 par requête). +- La réponse porte `total_rows`, `truncated` et `has_more` pour paginer. +- Erreurs : **404** si la feuille n'existe pas, **415** si le fichier + n'est pas un `.xlsx`. + ### Limites -- Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille, sans - pagination : au-delà, le contenu n'est pas affiché (et non éditable). +- L'affichage intégré 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. - Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas - rendus ; le contenu des tableurs n'est pas non plus indexé pour la - recherche (contrairement aux PDF). + rendus. - Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`. --- diff --git a/docs/ISSUES_TODOLIST.md b/docs/ISSUES_TODOLIST.md index 655e0d8..90f2ffe 100644 --- a/docs/ISSUES_TODOLIST.md +++ b/docs/ISSUES_TODOLIST.md @@ -196,6 +196,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | *BUG-087* | Édition d'un `.xlsx` concurrente (deux onglets, agent IA + viewer) : read-modify-write sans verrou, le dernier écrivain gagne silencieusement | 🟢 corrigé | P1 | tableur Excel | IA | `backend/services/mutations.py::_xlsx_write_lock` | Deux `PUT xlsx/save` simultanés sur le même fichier → une écriture est écrasée sans trace | Verrou par chemin (registre + garde, timeout 15 s) autour du cycle load → edit → `os.replace` ; attente dépassée → **409** `conflict`. L'endpoint est devenu `def` (sync) pour que l'attente s'exécute dans le threadpool et ne bloque pas la boucle d'événements. Test : `TestXlsxWriteLock` (2) | #153 A3. Verrou en mémoire, par processus : protège les cas d'un même serveur (le cas desktop/Tauri). Vérifié : cf. BUG-085 | | *BUG-088* | Injection de formule dans un `.xlsx` : une saisie `=cmd\|'/c calc'!A1` est stockée comme formule et s'exécute à l'ouverture dans Excel (DDE) | 🟢 corrigé | P0 | tableur Excel / sécurité | IA | `backend/services/mutations.py::_write_cell`, `backend/routers/files_write.py`, `frontend/js/viewer.js::renderXlsxViewer` | `PUT /api/file/V/xlsx/save` avec `{"sheet": "S", "cells": {"A1": "=1+1"}}` → la cellule sort en `data_type == "f"` | `cell.data_type = "s"` après affectation : le texte est stocké comme chaîne, aucun `` n'est écrit. Opt-in via `allow_formula: true` (endpoint) et le bouton `f(x)` de la visionneuse (session, jamais persisté). Test : `TestXlsxFormulaGuard` (4) + `xlsx-viewer.test.mjs` (toggle) | #153 A4. `+`/`-` ne sont pas neutralisés : ils sont déjà convertis en nombre par `_coerce_xlsx_value`. Le handler global `ServiceError` expose désormais `code` + `details` (le client en a besoin pour le 409), et `api()` (frontend) les propage sur l'Error. Vérifié : cf. BUG-085 | | *BUG-089* | Un reindex manuel ne reconstruisait pas l'index inversé : la recherche TF-IDF continuait de servir un index périmé | 🟢 corrigé | P1 | ⚙️ backend / recherche | IA | `backend/indexer.py::reload_index`, `backend/indexer.py::reload_single_vault`, `backend/search.py` | Modifier le contenu d'un fichier, puis `GET /api/index/reload` → la recherche renvoie encore l'ancien contenu (ou rien pour un fichier nouveau) | `reload_index()` / `reload_single_vault()` appellent `init_inverted_index()` après le rebuild (le remplacement wholesale d'une entrée de vault n'émet pas les notifications incrémentales). En prime, `backend/search.py` lisait l'index via `from backend.indexer import index` (liaison **par valeur** du dict) : un `importlib.reload(backend.indexer)` recréait le dict côté indexer tandis que la recherche écrivait encore dans l'ancien — l'index inversé n'indexait alors plus rien. Tous les accès passent désormais par `_indexer.index`. Contre-preuve : `TestXlsxSearchable::test_search_finds_a_word_stored_in_a_cell` échoue sans le correctif | #153 A5. Trouvé en écrivant le test de recherche d'A5 : il passait isolément et échouait en suite complète selon l'ordre. Le reload incrémental par fichier (watcher, edition) n'est pas concerné : il passe par le hook `_on_index_change`. Vérifié : suite 1402 passed / 6 skipped, ruff/mypy 0 | +| *BUG-090* | Troncature silencieuse d'une feuille `.xlsx` au-delà de 500 lignes × 40 colonnes : l'utilisateur voit une table courte sans aucun indice que la suite existe | 🟢 corrigé | P1 | tableur Excel / UX | IA | `backend/xlsx_reader.py::render_sheets`, `backend/routers/files_read.py`, `frontend/js/viewer.js::renderXlsxViewer`, `frontend/style.css` | Ouvrir `test_vault/sample-xlsx-large.xlsx` (520 lignes) → la feuille s'arrête à la ligne 500 sans aucun message | `render_sheets()` renvoie désormais `total_rows`/`total_cols` (dimensions déclarées par la feuille), `max_rows`/`max_cols` (plafonds du moteur) et `truncated` ; la visionneuse affiche un bandeau « Feuille tronquée — 500 lignes affichées sur 520 » (i18n `xlsx.truncated_*` FR/EN, axe des colonnes inclus). Contre-preuve : neutraliser `truncated` → `TestXlsxTruncationNotice` (2 tests) échoue | #153 A8/R5. La ligne d'en-têtes est aussi `sticky` au défilement vertical (`thead th { top: 0 }` + `top: auto` sur les numéros de ligne pour éviter l'empilement en haut à gauche). L'endpoint `GET …/xlsx/sheet` (#153 A9) sert les fenêtres au-delà du plafond, mais le chargement paresseux complet (défilement virtuel, « charger tout ») reste à faire — le bandeau dit la vérité en attendant. Vérifié : `test_xlsx_viewer.py` 58 passed, E2E 7/7 (dont 3 nouveaux), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity | ### TODOs techniques (améliorations / nouvelles tâches) @@ -213,6 +214,7 @@ Avant de corriger quoi que ce soit, un agent IA doit : | Date | ID(s) traité(s) | Action | Fichiers modifiés | Résumé | Statut après | |---|---|---|---|---|---| +| 2026-09-28 | BUG-090 (#153 A8 + A9) | Correction + feature | `backend/xlsx_reader.py`, `backend/routers/files_read.py`, `backend/schemas.py`, `backend/openapi_docs.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-large.xlsx` | **La troncature d'une feuille est annoncée et les lignes cachées restent accessibles** : (BUG-090/A8) `render_sheets()` renvoie `total_rows`/`total_cols`/`max_rows`/`max_cols`/`truncated`, la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN, axes lignes et colonnes) et la ligne d'en-têtes devient `sticky` (`top: auto` sur les numéros de ligne pour éviter l'empilement) ; (A9) `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, plafond 1 000 lignes/requête, 404 feuille inconnue, 415 non-xlsx) sert une fenêtre avec les **vraies** coordonnées A1 et le `has_more` de pagination. Contre-preuves : neutraliser `truncated` → 2 tests échouent ; neutraliser l'offset → 3 tests échouent. Vérifié : `test_xlsx_viewer.py` 58 passed, xlsx-viewer.test.mjs 14/14, E2E 7/7 (3 nouveaux + fixture `sample-xlsx-large.xlsx` 520 lignes), suite 1417 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-28 | BUG-089 (#153 A5, A10, A12) | Correction | `backend/xlsx_reader.py`, `backend/indexer.py`, `backend/search.py`, `backend/services/mutations.py`, `frontend/js/viewer.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `tests/test_xlsx_viewer.py` | **Les tableurs deviennent visibles ettypés** : (A5) `extract_indexable_text()` indexe noms de feuilles + 20 premières lignes (plafond 5 k caractères) dans le TF-IDF et la recherche sémantique — un mot tapé dans une cellule rend le fichier trouvable ; (A10) `_coerce_xlsx_value()` reconnaît désormais les booléens (`TRUE`/`FAUX`/`OUI`/`NON`) et les dates FR `JJ/MM/AAAA` (jour-first : `01/02/2026` = 1er février), symétrique avec l'affichage ; (A12) la valeur calculée en cache s'affiche sous la formule (``, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un ``), info-bulle traduite via `xlsx.cached_value_title` FR/EN. (BUG-089) un reindex manuel reconstruisait mal l'index inversé et `backend/search.py` lisait l'index par valeur. Contre-preuves vérifiées pour A5, A10 et A12. Vérifié : `test_xlsx_viewer.py` 43 passed, suite 1402 passed / 6 skipped, ruff 0, mypy 0, i18n parity, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10 | 🟢 corrigé (en attente vérif utilisateur) | | 2026-09-27 | BUG-085 → BUG-088 (#153 A1-A4) | Correction | `backend/xlsx_reader.py`, `backend/services/mutations.py`, `backend/routers/files_read.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `backend/main.py`, `frontend/js/viewer.js`, `frontend/js/auth.js`, `frontend/style.css`, `frontend/locales/{fr,en}.json`, `frontend/sw.js`, `tests/test_xlsx_viewer.py`, `tests/frontend/xlsx-viewer.test.mjs`, `tests/e2e/xlsx-viewer.spec.js`, `test_vault/sample-xlsx-lossy.xlsx`, `.gitea/workflows/ci.yml` | **Garde-fous d'écriture des classeurs Excel** : (BUG-085) `inspect_workbook()` détecte ce qu'un round-trip openpyxl perd (valeurs calculées, slicers, contrôles, connexions, custom XML, signature) → la lecture expose `xlsx_lossy_features`, la visionneuse affiche une bannière et `PUT xlsx/save` refuse sans `force` (**409** `xlsx_lossy_content`, confirmation explicite puis reprise) ; (BUG-086) écriture atomique `.tmp` + `os.replace` ; (BUG-087) verrou par fichier (409 `conflict`, endpoint sync pour le threadpool) ; (BUG-088) une saisie `=`/`@` est stockée en texte (`data_type = "s"`), sauf opt-in `allow_formula` / bouton `f(x)`. Le handler `ServiceError` expose désormais `code` + `details` et `api()` les propage. Périmètre de perte revalidé empiriquement sur openpyxl 3.1.5 (graphiques, images et TCD sont préservés). Vérifié : `test_xlsx_viewer.py` 31 passed, suite 1390 passed / 6 skipped, ruff/mypy 0, validate-imports 40 modules, xlsx-viewer.test.mjs 10/10, E2E 3/3 | 🟢 corrigé (en attente vérif utilisateur) | | *(exemple)* 2026-06-15 | BUG-001 | Correction | `frontend/app.js` | Réécriture de `renderFile()` pour préserver le DOM dashboard | 🟢 corrigé (en attente vérif) | diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e009a43..bc30e99 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.30.0 | **Dernière mise à jour :** 2026-09-27 +> **Version :** 2.31.0 | **Dernière mise à jour :** 2026-09-28 > **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact > vers les fonctionnalités livrées. > - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)** @@ -47,7 +47,7 @@ ### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats) - **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡 -- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), reste A6-A9 puis A13-A17 +- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9 livrés le 2026-09-28** (avec BUG-090, défilement virtuel A9bis à venir), reste A6-A7 puis A13-A17 - **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md) - **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure, @@ -74,8 +74,8 @@ - [x] **A5** Indexation du contenu des feuilles (noms de feuilles + 20 premières lignes, plafond 5 k caractères) — les mots tapés dans une cellule rendent le fichier trouvable ; au passage **BUG-089** (reindex manuel ne reconstruisait pas l'index inversé) - [ ] **A6** Outils IA `update_xlsx_cells` / `append_xlsx_rows` / `xlsx_to_markdown` / `list_xlsx_sheets` - [ ] **A7** Navigation clavier + barre de formule + nom de cellule (Tab/Entrée/flèches, `Maj+Entrée`, copie de plage) - - [ ] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) - - [ ] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel) + - [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090 + - [x] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel) - [x] **A10** Types & formats de saisie (nombre/texte, booléens `TRUE`/`FAUX`, dates FR `JJ/MM/AAAA` jour-first) - [ ] **A11** Tests frontend (`tests/frontend/xlsx-viewer.test.mjs`) + E2E (`tests/e2e/xlsx-viewer.spec.js`) au CI - [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un ``, info-bulle FR/EN) @@ -218,7 +218,7 @@ | 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour | | ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé | | ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours | -| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; A5-A12 4-6 j, A13-A17 2-4 j) | 6-11 jours restants | +| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; P1 ✅ A5, A8-A10, A12 — reste A6-A7 ; A13-A17 2-4 j) | 3-5 jours restants | | **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** | --- diff --git a/docs/features/xlsx-viewer.md b/docs/features/xlsx-viewer.md index 7e5f1c9..f707e73 100644 --- a/docs/features/xlsx-viewer.md +++ b/docs/features/xlsx-viewer.md @@ -3,7 +3,7 @@ > **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md) > **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir > [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md)) -> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), reste A6-A9 puis A13-A17 +> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9 livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17 > **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j) > **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte > l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.** @@ -15,8 +15,9 @@ | Couche | Fichier | Rôle | |---|---|---| | Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) | -| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html}]` | -| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets` | +| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html, rows, cols, total_*, max_*, truncated}]` | +| Endpoint fenêtre | `backend/routers/files_read.py` | `GET /api/file/{vault}/xlsx/sheet?path=&sheet=&offset=&limit=` (#153 A9) — une fenêtre de lignes, vraies coordonnées A1 | +| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets`, `XlsxSheetWindowResponse` | | Écriture | `backend/services/mutations.py:227-320` | `edit_xlsx_cells()` (backup, refs A1 validées, coercion `str`→`int`/`float`) | | Endpoint écriture | `backend/routers/files_write.py:116-148` | `PUT /api/file/{vault}/xlsx/save` (1 à 500 cellules / requête) | | Documentation API | `backend/openapi_docs.py:184-187` | exemple d'appel `xlsx/save` | @@ -105,7 +106,7 @@ restent à faire (A7). | R2 | Écriture non atomique (`wb.save()` en place) → classeur corrompu si crash | `mutations.edit_xlsx_cells` | **A2** — `.tmp` + `os.replace` | 🟢 livré (BUG-086) | | R3 | Concurrence : deux éditions (onglets, watcher + IA) → dernier écrivain gagne | `mutations.edit_xlsx_cells` | **A3** — verrou par chemin, **409** `conflict` | 🟢 livré (BUG-087) | | R4 | **Injection de formule** : une saisie `=cmd\|…`, `=HYPERLINK(…)` est stockée comme formule par openpyxl → DDE à l'ouverture dans Excel | `mutations._write_cell` | **A4** — forçage texte (`data_type="s"`), opt-in `allow_formula` | 🟢 livré (BUG-088) | -| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | ⚪ à faire | +| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | 🟢 bandeau + dimensions exposées (BUG-090) ; le chargement paresseux par fenêtres sert les lignes au-delà du plafond | ## 5. Backlog #153 — sous-tâches @@ -149,13 +150,26 @@ couverture) · effort en jours-homme de développement + tests. - [ ] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`). -- [ ] **A8 — `thead` sticky + indicateur de troncature (R5).** Ligne d'en-têtes figlée au - défilement vertical ; bandeau « feuille tronquée à 500 lignes × 40 colonnes » ; libellés - FR/EN. -- [ ] **A9 — Chargement paresseux par feuille (supprime le plafond).** Endpoint - `GET /api/file/{vault}/xlsx/sheet?sheet=N&offset=&limit=` (`response_model` + - `backend/openapi_docs.py`), rendu à la demande avec défilement virtuel, bouton « charger - tout ». +- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).** + Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les + numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose + `total_rows`/`total_cols` (dimensions déclarées), `max_rows`/`max_cols` (plafonds) et + `truncated` — le bandeau « feuille tronquée » annonce le **plafond atteint** et non la + taille élaguée (une feuille creuse rend 1×1 tout en couvrant 500 lignes) ; libellés + `xlsx.truncated_*` FR/EN. *Vérifié :* `TestXlsxTruncationNotice` (4), `xlsx-viewer.test.mjs` + (4 nouveaux), E2E sur `test_vault/sample-xlsx-large.xlsx` (520 lignes). +- [x] **A9 — Chargement paresseux par feuille (côté API).** Endpoint + `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`, + exemple dans `backend/openapi_docs.py`) : une fenêtre de 1 à 1 000 lignes (plafond + `MAX_WINDOW_ROWS`, `limit>1000` → 422), `has_more` pour paginer, valeurs calculées A12 + incluses. Les numéros de ligne et `data-cell` restent les coordonnées A1 réelles de la + feuille (`_table(..., row_offset=offset)`) : une fenêtre est indistinguishable d'un rendu + complet et une édition dans la fenêtre cible la bonne cellule. Erreurs : 404 feuille + inconnue / fichier absent, 415 non-`.xlsx`. *Vérifié :* `TestXlsxSheetWindow` (11), + **contre-preuve** (neutraliser l'offset → 3 tests échouent), E2E « l'endpoint de fenêtre + sert les lignes au-delà du plafond ». *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. - [x] **A10 — Types et formats de saisie.** `_coerce_xlsx_value()` reconnait les booléens (`true`/`vrai`/`oui`/`yes` et leurs négatifs) et les dates FR `JJ/MM/AAAA` (+ `HH:MM`), jour-first comme Excel en locale française : `01/02/2026` = 1ᵉʳ février. Une saisie @@ -208,3 +222,4 @@ couverture) · effort en jours-homme de développement + tests. | 2026-09-27 | Périmètre de perte **remesuré** sur openpyxl 3.1.5 : graphiques / images / TCD sont préservés, seules les valeurs en cache et quelques parties exotiques sont perdues | | 2026-09-27 | **P0 livré** (BUG-085 → BUG-088) : `xlsx_lossy_features` + 409 `xlsx_lossy_content`, écriture atomique, verrou par fichier, formules stockées en texte par défaut | | 2026-09-28 | **A5 + A10 + A12 livrés** : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. **BUG-089** corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; `backend/search.py` lisait l'index par valeur) | +| 2026-09-28 | **A8 + A9 livrés** (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et `GET …/xlsx/sheet` sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre | diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 3cae602..b2069b7 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1005,6 +1005,35 @@ export function renderVideoViewer(area, data) { // confirmation before retrying with `force: true`. A value starting with "=" or // "@" is stored as text unless the user turns the formula toggle on, so a typed // `=cmd|…` cannot execute when the file is later opened in Excel. +// #153 A8 — a sheet bigger than the render caps used to be silently cut: the +// user saw a short table and no way to tell the rest of the workbook still +// existed. The backend now reports the real dimensions of every sheet, so the +// note states exactly what is hidden (and that those cells are not editable +// here — the workbook itself is untouched). A payload without those fields +// (older cache) simply shows no note. +function truncationNote(sheet) { + // The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim + // (a sparse sheet renders 1x1) while the note must say how far the view + // reaches. + const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 }; + const reasons = []; + if (Number(sheet.total_rows) > cap.rows) { + reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) })); + } + if (Number(sheet.total_cols) > cap.cols) { + reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) })); + } + if (!reasons.length) return ""; + return `
+ +
+ ${escapeHtml(t("xlsx.truncated_title"))} + ${escapeHtml(reasons.join(" "))} + ${escapeHtml(t("xlsx.truncated_hint"))} +
+
`; +} + export function renderXlsxViewer(area, data) { const sheets = data.xlsx_sheets || []; const lossy = data.xlsx_lossy_features || []; @@ -1019,7 +1048,7 @@ export function renderXlsxViewer(area, data) { ).join("")}` : ""; const panels = sheets.map((s, i) => - `` + `` ).join(""); const lossWarning = lossy.length ? `
diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 13ab19a..8b60aa3 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1829,6 +1829,10 @@ "xlsx.lossy_cancelled": "Save cancelled", "xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)", "xlsx.cached_value_title": "Last value calculated by Excel", + "xlsx.truncated_title": "Truncated sheet", + "xlsx.truncated_rows": "{shown} of {total} rows displayed.", + "xlsx.truncated_cols": "{shown} of {total} columns displayed.", + "xlsx.truncated_hint": "Cells outside the displayed area cannot be edited here; the workbook is unchanged.", "xlsx.feature_cached_values": "cached values", "xlsx.feature_slicers": "slicers and timelines", "xlsx.feature_form_controls": "form controls", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 140bc8b..2239b92 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1829,6 +1829,10 @@ "xlsx.lossy_cancelled": "Sauvegarde annulée", "xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)", "xlsx.cached_value_title": "Dernière valeur calculée par Excel", + "xlsx.truncated_title": "Feuille tronquée", + "xlsx.truncated_rows": "{shown} lignes affichées sur {total}.", + "xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.", + "xlsx.truncated_hint": "Les cellules hors de l'affichage ne sont pas éditables ici ; le classeur n'est pas modifié.", "xlsx.feature_cached_values": "valeurs calculées", "xlsx.feature_slicers": "segments et chronologies", "xlsx.feature_form_controls": "contrôles de formulaire", diff --git a/frontend/style.css b/frontend/style.css index c153b08..08ae8a3 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -10967,12 +10967,25 @@ body.desktop-mode .editor-container { border-right: 1px solid var(--border-light, var(--border)); position: sticky; left: 0; - z-index: 1; + /* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th` + at `top: 0`, so a row number left sticky on both axes piles up in the + top-left corner instead of tracking its own row. */ + top: auto; + z-index: 2; } .xlsx-table th.xlsx-corner { left: 0; top: 0; - z-index: 2; + z-index: 4; +} +/* #153 A8 — the column headers stay visible while the sheet scrolls down. + Declared explicitly (and not inherited from `.csv-table th`) so the stacking + order is intentional: thead (3) < row numbers (2) < corner (4). */ +.xlsx-table thead th { + position: sticky; + top: 0; + z-index: 3; + background: var(--surface); } .xlsx-table td[contenteditable] { cursor: text; @@ -11051,6 +11064,31 @@ body.desktop-mode .editor-container { color: var(--text-secondary); opacity: 0.85; } +/* #153 A8 — "feuille tronquée" notice. Deliberately NOT the `.xlsx-warning` + look: that one is a data-loss alert, this one only says part of the sheet is + out of view. */ +.xlsx-truncated { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + margin-bottom: 8px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent, #4a90d9); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.82rem; + line-height: 1.45; +} +.xlsx-truncated-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + margin-top: 1px; + color: var(--accent, #4a90d9); +} + .xlsx-formula-toggle { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-weight: 600; diff --git a/package.json b/package.json index ac4c8e2..002dc39 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.30.0", + "version": "2.31.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": { diff --git a/test_vault/sample-xlsx-large.xlsx b/test_vault/sample-xlsx-large.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a222a6d37a23a56c5c7ba3f17c98bbfdc9d0f3c0 GIT binary patch literal 15273 zcmeI3c{J4T`~Sya?2KKA83xH($i9tr$X*g+DnfRZ?As7YmMJX?B_&yttq?<5lTh|$ zvP4Dpefzy$2Jj8umT?|?bXsRV?VZ=&P?~?n`Jn8hB%F!w1 zE#g-qiiKUnSEnY)RNZ`i>$%dzuk#5O-_x2AA90mBT1ThW!N01~)c831@rZx!k^az_ zmz)ggygZ~eX|A0i5l3j{J`9!QfB!75?Qo^#4*F_=f{9?wkcG6uOCjE@K5lCGn4f^k z4Q74$yCURUH_Vz?%~HNKy06=jS5mxTH~vQm4DRD{(*U{%6ar!VGlcW*UiM%N&G?p! zHIlTEw)V>@hk_j~41P4}8FA5?cXc&)U36v>s_oESUA$C&%-)3||H;!-)j()bqo<5d zz#>k=2v*Z?SEk}IdTc^-L{80EsPO2>k=D9zXQ$0g16KLvGF3(xA0Li}iQ+%wQ6H*4 zh-nI1=9JDJa++o@H>Vj0Toor2LMJ|*Ga_j$Clma3{g=`eu|9}Y%%|SzGinuZvjroU zACNLs2KLTpx!4tp4C}tf;UyV$cMIJvtg029;s_?M`99%MlY7hdXXIp1o#x3C2IkqX zegxTe1bmtPvKr{U@%1WmG42?}T-?pPu(SxG5%q zxa?@}YQOXQ2$-NrORGygax6hpd7pe77F}s-kA)T&GGB7??k&6@K557s!Dw;Q#n&z& zJh)Ql7`*8Y-6Y=N%>yMe(eG;?wRFbD<$ULUiJu$47Ik~p2iq;t+fh~`F+e-%X~*H1=GU zcI6}v&-lm{H?`gH;3gxQl%-TB&%0Ent5X#|ar$J}wb+xJ@a)A+Go$vUz`>sn93N8O zv@&XUzS`d&d%ZU2lMmh78KcOW-0QVjpDzD&5)|6Vbx!}z=7`oF{^`fWS~RNGj(}Kr zcz1kgBfVoIyn5v}XZOLfes$|U+lXWir*@Ko_>zBqy zQZsIK8lXRVl#5c$tnpi&jTCZIWcE_vc0dQTEG{NI6*$^5wL<;VP|1PU{7(LvmX?u} z7%%>^{zZ$xMI#+I{?5#*SA3nfwNYBB^R)$cVo0@JXzi$$6-BkeR9ZHVsVu+Fg_&Q>J{7fiCl=$kU$9hwW<9w(dZmpFcPg9a$wS2^lYnHi3?WU6iTF>EVjTtA&Hb4vwVKhd0U%f*Gpck4jTKNjgnc5pI4l^+lw# z%5$Eh)Z{a-c~nOIe5hsb)Rr6PhWV3hwf4wXXDx?S$p?!EwmhABJ+J(@twfeT(1Fl? z!)id=&FbA+?vb8OY4JnnPkA-h*PY0_CEO*SS~aoZsFE@>x!R2i&r3Qj#?Kb7ANooi zRl0toar*E(s&P9j0jj1G?e?O{Iu47%Tt!rZhll&)BN_P&{Ma`xme?7i- zucbyK(%0YioIU~wKw8M%$qQK>uKxa))VvktWl8VHq1sspj z8uu8xYi&E+iXZNMW?$r%Cw%pQE1#)+wbMOjhpff{`fam;r9j7DzIV*l`} z+bu=i;}3n`Gm>1^g7PPF!`Rr5ju`*K$HSOOnr}B~PD8`&o^glVTcxR(rF|{T`Y}gE zK3Gc>cg@hIO@N6dI-iyt$?i2gHFfLa)tJsz(NNd$6G!d7us3R4Cn!sKX>^?sn9J=E zq>1PqI?-Il`Bo6CU*qSNEGD$oYx7yTkX>1O?#X>3&#TBw5By52wX(M?I(Sx&4$6D< zDh!a?d}9{XNDm?eMn{J*W}SQcCeLU^;Gtrn|4b<2!Foq;`eHwkHsEuXxkWdp62**wXx$5ra ze96(?{_-V>?U(H?lH6|<_i)UUj9AGxJ@9S*>jphc3-OE*r53|+3~!y6lc;%b>vh{JrV(z_jePHI7Q1Rc8&1wkE+E|-&gL&4OXl#*9>p;%c{~<1TVF@mrRbXm6hxXB)WQyS8*SZfm1=?J9$6&_=b*`Ycy)z*_&3QEJfe z=+9HCeSufEGS)JJ*S<2uwN(18tYihuEpBW?#<}yV1T6S$O?FCb5`yL~tW37QGYTFV z930qMX53uKGKw2*VPFW}n7k~U;P;p3+45} zo9icS12*+l>4sFdX1{FBWawR?67~EHrPFTn+YLny-uG z-TJ;crcgLz>!HxB?fuQC2!B43vJgLE3lXJH7 z^Q$XygFpoHcRaI3vj?HS{T;Kd!)7~GMp)fn^ThrpaIwo@_ zfUa_7G3&v`k8dugWft;O`&0)u<|}b8Bet$iv~NXjZYtPRtSfels<^ZDUmzSaGwZxC zK~h20Z#s0Uyc6ACQ_~ z8qP{iJ*8Oo#qh7$*Rup{o83BZP34^a=r%8Jaq~>?ONxbWW3N*?d*A!T)BU)=Hs?3- zeXhidHpRPp*l%u**kV*J`*M(#y5nhB>xuaD`Mk~KA_N4Ukn5N)&J}Y3$Bo-?RN$!9 zhziITL1gPwKA}R^VO5hDuOS)v1SX=XetK0q8rC1_iW7`Ddm=rGx~!o3O}->2n-}fk z6VJ2v*o9}SbF1>jkHiRG16^IUm!K<7?rRM%HNCo64|do`woZTu z1`IXJ7JMq}(F{{VHBJGb_ivHsgG3W}gGM8-u6&hN9pMPLbbPMni0ZKfUV4ecZ`M6F zU!=7#tX2)keXA3kt;t)T7FT`vEfQQZ$j8?gxo7BKtbif${Z{{fLp$yliztFOf6n1l z@?~RJJfqg5L{!t77zSQ!^Xp)PxcO9v8so-iH7<(;29Y(#Rjtcu1-(n;Z7nvPUona$ z1WBtx=Y~%-vz#zlOYGXEvf^y~60Ku`{GI$FGMGnB~u1k1EbcYY7Gu zy~6nP#hdGp>mq7tu36*EkGOH%xk*#lGj}P$GVbzn6o_EN#qDENxy1gx=HAes|4V1gRdIF#C zQ{7@QHUma^*cB;*gm)}Q*pJ{(#n@S$pGkWgmymu0Z^OnSk3SV_*L%K|JK=mjqB$X^ z;L)l&#d`V^u>!~V`Xd(cTlxG=VSJcbJ-o{U(Ik`?En7MUFMvU#Fg6&xYogXI%~(R1 zG^PNBi9$7qU@%Y^KZsJ`8Fy0BM z-b9pDuS@Hgx;)JN+apXtB1&Ep?{W(7;#%~6kfL3vLHm}S2*xW4)t!s#?s2=J!q5n2 zuE88X0>=~pNhum-3*@;CY>WXu2i1VY+m+$%oO{Jq4;F1_3RuN(!BEsk#C$C&JV-|j z5W}V$fRnL<6KH_`y*tH!jk%Wm-irqFfut+UJF85x88B_{Mt37up&K7&Q3SJC+`pR| zf8QG&Kpuhw8U~83q%k8Lm;zf&LCN3i9>Mf!;U`YvCnlYlcJ~*M@WMwMA7}TqTi)AaBU8Wn zK`rjV!{C8r{nMR3K3o?+tcQQ{ceo~alqR106rS6)Y+D*uPo41G0vJrP;z zy0=CSVeovISP@L@-wDvc;8`$U))=qCNw(nBpi~rv0saXJ6OEG3MalR4t(RClN`J@Q z=DXzGy)?nwUEeYE{%e1Mu}pnNIaa4$u}!5ep$0;p7EoTh%MP{RaJLwfP{6W4+I zj=#So;B^Nj|78^Ukf-?f-p>B40kl`H`i}Hn@!*iqaW8sr7TG8P1H&s3J~*j&R=u7x zih}Zykx}bRGv-E&U&Mx*&o&j04|M{TB_m5wC0_A9X6)Bx(> zacCK#v^hh#JCSg8+J|z`rh4w2PRrLjO8%QIN6H&{lp@`yzWSCu8S~IjQC>jV47?jQ z3Gioa5#dpew3?mBjGUQ0%40O#+v_thHxBSR7|F*m`0~b4%uYrqN%+qL1g|o<8zRf` zby6#fhAFZten7&y%?w911{#N72KbA$IP-415~i3#N_?BKds}gTtUoTLM$C8d_Nx`+ zt8=i(xel9;b5PxFtj3vu=RbXMbH2_~W4<)$V{rIaz}vV+|0gc)Rx5uk3HSrTfr=SL zKat4!$Kv1V48P}8JY~PrOuRR+EK`wqY1MdX9!9ayVe?s}^+LSz`m=#XlfdU$ez;eD z&Qm`koTcQlWrI$;j~1$YcC8?IRupwEpi2%s?BuG08#{5+ zw%bbn6~h$=cMnJR$)^?1ufsAHYuzVvxO*et6wAt$lq?R|%xmIs{=@7`=7zsC`un)J z`>p)1Ncg1@x+`YN{6!*{9*a-W8BXO?RIn?z5|;;-7b+6x){K{yU=+(8HpCk#jvis| z>#)H_#-QgS0W@}1Jxk=%69>45xGLY3ct!L6U>hWI4E8t&v@+9uoVhtTY$adMa3#Ur z1Mfa*@OJz4*L_M275$;zyOzT4$fG~ zj~HF4clT&;pA@bHW|pki#;xR7&t<&%CM!2qvX}w}hzlBK-!L~^HA|l7>YKOn{|p9r zvU28CkjT}I%wMZ?^lLd46JUUagUha!iNl*G>Na4V8yzW2V1R?}>s5n|5y3BLf^p2j z&hG0#fO504L8I=YA5_2GuOuW_7Da9X0a~wesV~0&ymX0RAW;!>|xmG6X9W`v_vu1c^#{*(W>`uN9kT30P<>%2EGDEV z7Bq_w@x)Y73*@s1V6jvdH>d!!M^qeRWZ6DYeP1Z-QAkl5XqF!0X;wvjIG^P(_7Sy3 z6E&DJYK}lIUY|^g*-ZSZl$2G_$A{b5|&@?xKCvE8vKI?|@I7Fi{C5 zF~;T?gH|TkzC-z8ETR!C9EZp|j*cPGWOTf@bDeYmxkFR?+vY z96sb7VW3$Qd2It!a?=qxoS6L?aGx zIKn!-KrG!Lr?X@%Uy-e3_XCjf)=O^cM3fNxOFCD$%oG;v2MUa{9kRd8306AL8 zuxIPUn&pP#oQXNkRsMWnw`%zynoD^0heM#Lr9M1ujWc)+qt6X3$t9V(4)#7()mNpk z2VQjC)}-!dNC%j|1C~1;o$$myyQr3Pyrr;@XGkUfUREuDZ>>tk;}@sTAMp=e>ZZsIz4VVL z;He)~Jm?;l!EF8p3|)>{40R^<$xQ!|sCPm|4A!J$xuNWT$BR(O3nX&N{S$E~^A1LT zEWDHWmXtr-0cYI4Wq~tdAD(jSzAykW?h6+*zEq6H54T{!Pujr{?a`&+YMyV!+G{9OZoz?JPv_7AvIze@n9#s9+XRK-Cr@Ugrc22 z4tFA|WTJNawbP=eC!OIaCs`@1b+^4XryR;9WLo1Ooe0bA{!d?$-p%}8MlR7APUO_w zZU55#X{E3XFFL3-&T+R}Uoc|nB%a9KNB8hgIuHFM<$Z9#2j4y3y72!J!+taHQ}n(N zz(%!a%@pN*5rFRx_T&S6Gq5LsS@6xkKhptxB;T9d{b2w5{M)?;-X|0FM>4h>W7t13 zyWJSoJvtG8WMjKAQvTxnQytrlk#Gk0hdQ(K)PNXKR zbdES;p*l-Xm0RO6%6z)CB09}e()n~@y)Y9Oa599%4KC(}XiNiMxn-lmz{yOMmvN3) zNL$(HF5`%A)LHtg+@fVEiF9eBbed$NBq3)Oo1|= zKCOse^Qv?{eVEm@vk3%5F^WwQKH3UVq>SBmR)~X4A}lA>3Ux>)5vAUsvmttDDbDdV zsR=J#DUR5;?M#t!nm%oTUNcyFnm){b+u1Y%q7=oZ1SfBUC{fb$yfZh`y-qKG3jQumla6epphm7?fO9oLQyS+;ylQi=dZ8kHy|8Pa4JG$W-Y8Nwb=y!^sqI<5}! zgNykg6w*oj;M~elInUxv8K9qWj$cSyk#wJN#2@M|G7&8P@G2h7CY~sIrz##Y2ZnQ5 z!s&fD$P&VGN$s*O=@O#UA8uuUB?$mh6G8oc6QM5R}V1ce7EZ0DPYlzZ7_qzi&` zt44+K0+KPMoX0sXfDY$z#8q{cjctcjyqZm*!zx~~F^0719fw;8%Pr917NRs5{$5qm z41$J)z{Ns9haqrowJ0cJ+aX!FBRLu1kSv@CMYE861vRLK(9}nOga|Sr__;jcblPwT zDbkWu9q>nrEDeSG8z3VPWC+SuK1v{91SNqnTn^9;IZ{nM%3K|OuuV;!G8ggA#7y@H zB?&FMkTz^Q9!87yfFWany-=t0fTvx71AE~C4^yP{{LEqsM?=ElV&NcMIGh_D_2gMR z9u7r?I|_hosBj_^nj6@Q2)G(jQy&SUA;~h}L(hfNMZ+O9NJ|V~eqVbUlF%AJW zc|07#jI?A1(U_5?*WnHT8Xwss%GNXx?GYuxBwP+Ca8{(6R+PCGh^9rE%l8f_-ItUk zqUb_V5KR>A!M2U2Md`tqc7+kl8e>@5Hku?F8WS%TQ>BPfj&yEgX#{jAz>lh@>~5qi z*J!)cnbcq(E_yWVbs8g+W4MbG*#Pppy!EYT#%!FxHYcE|j-iW>L8s#9xpOgQhi=@Q zE`O%`n2Z}~#T|N8*N?t4UHCS+j^rr?bXaVBPV8T+r19Sq;X#J#)Zft|9>k~A$T(5k ze`WlT7#C0ExBJOIO^*SpPCBAsnerR8t}cYBfSk!E68QLRkc2)RLUu_2g`%YDrltZ3 zO9c%ci=x9`^D!|+g}Yq;4VYn!WcrYp5RXOKIio;#grq)QU7+sb!^u!cE7T6%h_IbZ z#BF^1Ba~g%wiQ_F*}xgc#B?v*<^Ha(c%ae^(P6jZb8hWKFw0Vqgr?Th4Ph#X_zjkV z2F?)-6_qKv*mNf!4QBO#o)05K_3H2F?L_HIecAwNFMzV3>bCq1Z-fM(^25nQkyfI+ zP$K};o%lxrc3A>D=+^qWKn0bClZhj(#Q(Iq8&4Fl`?+leqL)95hcYu&g}V^`LhocT zJ%Yxam@hP!+PG8018Q*}2|0XnXyY8VZV0k{lxA2Am^Wd;QLh#B8bbpS|2 zay(YZ&RGa33ak*Sgt?%H>hVN4nIh6k5nNIc`8qlNiy@`G5UPu+y9adEMb$7Fp6!i3 zq=+OK)^`{J{8fg!ZbEiN%;;_^DsObLH%R7<_EJPdhV>VP!V8$0Cc<4N!2l-0N4(MIipc+>{l~K=tNVDeiNh5*s%BYw zfa>~R*y7Cn>eKxf^&Q@iX1ZT}@&BT}6Z_Sd{x9lF++P^~Mf*q1{-4WQewCp`PV#P( zwESWS2un1d+1yzOYy)SU#odImFA9nNIS6)1^CIRu1<+`-CZ4koPbp&0R0?M=#k2dC7!f|Pk1oS_UtlTdFNS*-^`-s~ zI9u472*1l)Sy>sp+TY*a|NC*rwX(V?;NijLFJ)`)sda9ZoAXb)>q-U#w^rDV7*qo` zhUBF-*Qe*_r#CZ#*EctRJy@$V3MQ@$2Cq#|&o@cfs045D^73r0whzv%2Jg9i5HRE>gv7tcJ1NV#*C=+ zlxnvjhnnS2>7_?+%m%I+PCwJZo3C7adFWE}(*|!YsquZa#s?>ylZyLrX&!};*( zaYE*ai+b2Zts1v>G0e`BZ+$Dq2)ZQI;~b{<~qZ>*K=q>xI8dcqs+(lUi7P9-L3 z3u+#-muoXh&cr@iGRJ+=4Jx=6$?XyurAr` z6^%DX`fOWF&*@_~C8sWuE~W5dGpE*TR}Llt7hhEq>MGXAwH*M0VsbeIm*G>!OE0Zk=dJ$`(3z z!tE!jKv3Qp&wN@qonDqzs5z6UcgpglmdTwHIzJWes~arjh|b(y)E>P?dzfB(waliy z{h92@u=1k|tF70J6Wbo>p4407Ot?H4Z*uaxlbL6{xf>=zo0Lm0zLCkX0XKEB{8CDyTPzUg^f3XQ z`&pDnCbCUUv-iA4zNM&&n)rbWB^7pT8JE&eMOk+yKUMCPFj999$x}G)iFVS}m!h9; zZVNuTG)F6=HLAy-XvOK%KDy~m#I!!L zYA+q~{J52w@PeAg!lc!Y^5}d$Q2~eS=+eAJ_7f>9@!2asS3lh-y9t^6JSQvHCsu)Y zMV^EEOrr7GkXeI>wAmixmtWHr{gS^=_K(;+a7^|${-s=w7psdOrV!hEjG=fQq~!pa zT?Vquxs%}?HT{|AZDkcDDAVI4X(RHOMjF$GDtDvTEYI4o&&kv)6p^fg#{Tg62<{?Hrs zE-AZ|U-#12HwPW!yg6UAx@jGeHj~<9V ze8_7^JSM^(#mZfrpg_p_3*j8x#u~5`*>gc0U*E@(w&Q9RvPYZ zm+jpy+n(_Cu)lN$lv2(u13*e}AUb<10dY}_N6)yhG-o_W&Kq%NZQgRQZ0nUW?9qF~ z#2R6c?K4o`l5#hDAdWGAJb9t(_Cb>X7U)R|!*d7ud~yqs@|g9cVm`g1TUPL;rcsJJz74m^?3W9ocQ_{n?whvxIwmp)+~ z_{1%YqqD-TR}Omo@K;pR)7E&IaNXwV`NZ)YH3iEmzn-DS0~)=Y@B3f{m{rkApcTU#`@&I1x*Nq3iwt54)K%n;9vg_D}E(> literal 0 HcmV?d00001 diff --git a/tests/e2e/xlsx-viewer.spec.js b/tests/e2e/xlsx-viewer.spec.js index 69fa24e..612dcde 100644 --- a/tests/e2e/xlsx-viewer.spec.js +++ b/tests/e2e/xlsx-viewer.spec.js @@ -14,6 +14,12 @@ * - the f(x) toggle is off by default, so "=B1*3" is stored as text ; * - the value Excel last computed is shown under the formula (#153 A12). * + * Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) : + * - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ; + * - the column headers stay pinned while the sheet scrolls (#153 A8) ; + * - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide, + * with the real A1 coordinates (#153 A9). + * * The fixture is restored byte-for-byte in `afterAll` so a local run never * dirties the working copy. * @@ -27,6 +33,10 @@ import path from 'node:path'; const BASE = process.env.BASE_URL || 'http://localhost:2029'; const VAULT = 'TestVault'; const FIXTURE = 'sample-xlsx-lossy.xlsx'; +// #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so +// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and +// committed next to the other fixture; nothing in the suite writes to it. +const LARGE = 'sample-xlsx-large.xlsx'; // Playwright runs from the repository root (run-e2e-local.* / CI both do). const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE); @@ -45,7 +55,11 @@ async function login(page) { } async function openFixture(page) { - const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`); + return openXlsx(page, FIXTURE); +} + +async function openXlsx(page, file) { + const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`); if (!(await treeItem.count())) { await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click(); await treeItem.waitFor({ state: 'attached', timeout: 8000 }); @@ -141,3 +155,59 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (# await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); }); }); + +// ── A8 — troncature annoncée + en-têtes figés ─────────────────────────────── + +test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => { + test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => { + await login(page); + await openXlsx(page, LARGE); + + const note = page.locator('#content-area .xlsx-truncated'); + await expect(note).toBeVisible(); + // Libellé traduit (jamais de texte UI backend, jamais de couleur en dur). + await expect(note).toContainText('Feuille tronquée'); + await expect(note).toContainText('500 lignes affichées sur 520'); + + // La dernière ligne rendue est la 500e ; les suivantes ne sont pas là. + await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1); + await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0); + }); + + test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => { + await login(page); + await openXlsx(page, LARGE); + + const header = page.locator('#content-area .xlsx-table thead th').nth(1); + const before = await header.boundingBox(); + + await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; }); + await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 }) + .toBeLessThanOrEqual(before.y + 1); + + // Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto` + // de A8) et la première ligne de données reste lisible sous l'en-tête. + const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox(); + const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox(); + expect(second.y - first.y).toBeGreaterThan(4); + }); + + test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => { + await login(page); + await openXlsx(page, LARGE); + + const res = await page.request.get( + `${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50` + ); + expect(res.status()).toBe(200); + const win = await res.json(); + expect(win.total_rows).toBe(520); + expect(win.offset).toBe(500); + expect(win.has_more).toBe(false); + // Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre : + // la ligne 520 est servie comme A520, pas comme A20. + expect(win.html).toContain('data-cell="A520"'); + expect(win.html).toContain('Operation 519'); + expect(win.html).not.toContain('data-cell="A1"'); + }); +}); diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index 6e58711..adbbb29 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -7,6 +7,7 @@ * workbook gets 409 `xlsx_lossy_content`, asks for confirmation and * retries with `force: true` (or gives up when refused); * - A4 : the f(x) toggle flips `allow_formula` in the save payload. + * - A8 : a sheet bigger than the render caps shows the truncation notice. * * Usage: node tests/frontend/xlsx-viewer.test.mjs */ @@ -116,14 +117,14 @@ const sheetHtml = (value) => `1${value}` + "
"; -function mount({ lossy = [] } = {}) { +function mount({ lossy = [], sheet = {} } = {}) { const area = document.getElementById("content-area"); area.innerHTML = ""; renderXlsxViewer(area, { vault: "V", path: "data.xlsx", is_xlsx: true, - xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }], + xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }], xlsx_lossy_features: lossy, }); return area; @@ -268,6 +269,49 @@ await test("a non-409 failure is not retried", async () => { assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); }); +// ── A8 — truncation notice ─────────────────────────────────────────────────── + +await test("no notice when the sheet fits within the render caps", () => { + const area = mount({ + sheet: { rows: 500, cols: 40, total_rows: 500, total_cols: 40, max_rows: 500, max_cols: 40, truncated: false }, + }); + assert.equal(area.querySelector(".xlsx-truncated"), null); +}); + +await test("notice states the cap and the real size of a truncated sheet", () => { + const area = mount({ + sheet: { rows: 500, cols: 12, total_rows: 1200, total_cols: 12, max_rows: 500, max_cols: 40, truncated: true }, + }); + const note = area.querySelector(".xlsx-truncated"); + assert.ok(note, "notice absent"); + const txt = note.textContent; + assert.ok(txt.includes(FR["xlsx.truncated_title"]), txt); + // {shown} is the CAP (500), not the post-trim row count: a sparse sheet + // renders 1 row but the view still reaches 500 of them. + const expected = FR["xlsx.truncated_rows"].replace("{shown}", "500").replace("{total}", "1200"); + assert.ok(txt.includes(expected), `${txt} !includes ${expected}`); + // Nothing to say about the columns here (12 < 40). + assert.ok(!txt.includes(FR["xlsx.truncated_cols"]), txt); +}); + +await test("notice mentions both axes when rows AND columns overflow", () => { + const area = mount({ + sheet: { rows: 1, cols: 40, total_rows: 501, total_cols: 45, max_rows: 500, max_cols: 40, truncated: true }, + }); + const txt = area.querySelector(".xlsx-truncated").textContent; + assert.ok( + txt.includes(FR["xlsx.truncated_cols"].replace("{shown}", "40").replace("{total}", "45")), + txt + ); +}); + +await test("a payload without the dimensions shows no notice", () => { + // Backward compatibility: an older cached response must not produce "NaN". + const area = mount({ sheet: { name: "Feuille1" } }); + assert.equal(area.querySelector(".xlsx-truncated"), null); + assert.ok(!area.textContent.includes("NaN")); +}); + // ── Report ────────────────────────────────────────────────────────────────── console.log(`\n${passCount}/${testCount} tests passed\n`); process.exit(passCount === testCount ? 0 : 1); diff --git a/tests/test_xlsx_viewer.py b/tests/test_xlsx_viewer.py index b833523..28753aa 100644 --- a/tests/test_xlsx_viewer.py +++ b/tests/test_xlsx_viewer.py @@ -519,6 +519,173 @@ class TestXlsxWriteLock: assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "premier" +@pytest.fixture +def wide_xlsx(test_vault_dir: str) -> str: + """Workbook whose sheet exceeds BOTH render caps (501 rows x 45 cols). + + Sparse on purpose: a cell in A501 and one in AS1 are enough for openpyxl + to declare those dimensions, without writing 20 000 cells to disk. + """ + from openpyxl import Workbook + + path = Path(test_vault_dir) / "grand.xlsx" + wb = Workbook() + ws = wb.active + ws.title = "Data" + ws["A1"] = "tête" + ws["A501"] = "dernière ligne" + ws["AS1"] = "colonne 45" + wb.save(path) + return str(path) + + +@pytest.fixture +def edge_xlsx(test_vault_dir: str) -> str: + """Sheet exactly on the caps (500 rows x 40 cols) — must NOT be truncated.""" + from openpyxl import Workbook + + path = Path(test_vault_dir) / "limite.xlsx" + wb = Workbook() + ws = wb.active + ws.title = "Data" + ws["A1"] = "bord" + ws["A500"] = "ligne 500" + ws["AN1"] = "colonne 40" + wb.save(path) + return str(path) + + +# ── #153 A8 — silent truncation made visible ───────────────────────────── + + +class TestXlsxTruncationNotice: + """A sheet bigger than the caps must SAY so instead of looking complete.""" + + def test_render_reports_the_real_dimensions(self, client, wide_xlsx): + resp = client.get(f"/api/file/{VAULT}", params={"path": "grand.xlsx"}) + sheet = resp.json()["xlsx_sheets"][0] + assert (sheet["total_rows"], sheet["total_cols"]) == (501, 45) + assert sheet["truncated"] is True + # The caps are the coverage the banner announces — `rows`/`cols` are + # post-trim and would understate it on a sparse sheet. + assert (sheet["max_rows"], sheet["max_cols"]) == (500, 40) + assert (sheet["rows"], sheet["cols"]) == (1, 1) # only 3 filled cells + + def test_a_sheet_on_the_caps_is_not_flagged(self, client, edge_xlsx): + """Boundary: 500x40 is exactly what the renderer supports.""" + resp = client.get(f"/api/file/{VAULT}", params={"path": "limite.xlsx"}) + sheet = resp.json()["xlsx_sheets"][0] + assert sheet["truncated"] is False + assert (sheet["total_rows"], sheet["total_cols"]) == (500, 40) + + def test_a_normal_sheet_is_not_flagged(self, client, xlsx_file): + resp = client.get(f"/api/file/{VAULT}", params={"path": "budget.xlsx"}) + assert all(not s["truncated"] for s in resp.json()["xlsx_sheets"]) + + def test_blank_tail_is_not_reported_as_truncation(self, client, test_vault_dir): + """A sheet with empty rows below its data fits in the caps.""" + from openpyxl import Workbook + + path = Path(test_vault_dir) / "blanc.xlsx" + wb = Workbook() + ws = wb.active + ws.title = "Data" + ws["A1"] = "seule ligne" + ws["A300"] = None # formatted-but-empty row inside the caps + wb.save(path) + resp = client.get(f"/api/file/{VAULT}", params={"path": "blanc.xlsx"}) + sheet = resp.json()["xlsx_sheets"][0] + assert sheet["truncated"] is False + assert sheet["rows"] == 1 # trailing blanks dropped by _trim + + +# ── #153 A9 — lazy per-sheet loading ───────────────────────────────────── + + +class TestXlsxSheetWindow: + """GET /api/file/{vault}/xlsx/sheet — one window of one sheet.""" + + def _get(self, client, path="budget.xlsx", **params): + return client.get( + f"/api/file/{VAULT}/xlsx/sheet", params={"path": path, **params} + ) + + def test_window_returns_rows_and_totals(self, client, xlsx_file): + resp = self._get(client, sheet="Budget", offset=0, limit=10) + assert resp.status_code == 200 + data = resp.json() + assert data["sheet"] == "Budget" + assert (data["offset"], data["limit"]) == (0, 10) + assert data["rows"] == 2 and data["cols"] == 2 + assert data["total_rows"] == 2 and data["truncated"] is False + assert (data["max_rows"], data["max_cols"]) == (500, 40) + assert data["has_more"] is False + assert 'data-cell="A1"' in data["html"] + + def test_window_keeps_the_real_a1_coordinates(self, client, xlsx_file): + """A window must be indistinguishable from a full render: the A1 + references and the row numbers have to be the sheet's, not the + window's, or an edit would land on the wrong cell. The CONTENT matters + as much as the label — row 2 of "Budget" is "Total", not "Poste".""" + data = self._get(client, sheet="Budget", offset=1, limit=1).json() + assert data["rows"] == 1 + assert 'data-cell="A2"' in data["html"] + assert 'data-cell="A1"' not in data["html"] + assert "2" in data["html"] + assert "Total" in data["html"] and "Poste" not in data["html"] + + def test_window_offsets_walk_the_whole_sheet(self, client, wide_xlsx): + first = self._get(client, path="grand.xlsx", sheet="Data", offset=0, limit=10).json() + last = self._get(client, path="grand.xlsx", sheet="Data", offset=500, limit=10).json() + assert first["truncated"] is True and first["has_more"] is True + assert 'data-cell="A501"' in last["html"] # the row the caps used to hide + assert "dernière ligne" in last["html"] + assert "dernière ligne" not in first["html"] + assert last["rows"] == 1 and last["has_more"] is False + + def test_offset_past_the_end_is_empty_not_an_error(self, client, xlsx_file): + resp = self._get(client, sheet="Budget", offset=9999, limit=10) + assert resp.status_code == 200 + data = resp.json() + assert data["rows"] == 0 + assert data["has_more"] is False + + def test_cached_result_survives_the_window(self, client, lossy_xlsx): + """A12 must not be lost on the lazy path.""" + data = self._get(client, path="risky.xlsx", sheet="Data", offset=0, limit=10).json() + assert "xlsx-cached" in data["html"] + + def test_unknown_sheet_is_404(self, client, xlsx_file): + resp = self._get(client, sheet="Nope") + assert resp.status_code == 404 + assert "Feuille introuvable" in resp.json()["detail"] + + def test_missing_file_is_404(self, client, test_vault_dir): + assert self._get(client, path="absent.xlsx", sheet="Budget").status_code == 404 + + def test_non_xlsx_file_is_415(self, client, test_vault_dir): + (Path(test_vault_dir) / "note.md").write_text("# hi", encoding="utf-8") + resp = self._get(client, path="note.md", sheet="Budget") + assert resp.status_code == 415 + + def test_limit_above_the_server_cap_is_rejected(self, client, xlsx_file): + """The cap is a contract, not a silent truncation of the request.""" + assert self._get(client, sheet="Budget", limit=100_000).status_code == 422 + + def test_reader_clamps_a_hostile_limit(self, xlsx_file): + """Belt and braces: the reader caps too, whoever calls it.""" + from backend.xlsx_reader import MAX_WINDOW_ROWS, read_sheet_window + + window = read_sheet_window(Path(xlsx_file), "Budget", offset=0, limit=10**9) + assert window["limit"] == MAX_WINDOW_ROWS + + def test_reader_rejects_a_negative_offset(self, xlsx_file): + from backend.xlsx_reader import read_sheet_window + + window = read_sheet_window(Path(xlsx_file), "Budget", offset=-5, limit=10) + assert window["offset"] == 0 + + # ── #153 A4 — formula injection ──────────────────────────────────────────