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 0000000..a222a6d Binary files /dev/null and b/test_vault/sample-xlsx-large.xlsx differ 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 ──────────────────────────────────────────