feat: troncature annoncee et lecture par fenetres des tableurs #153
- BUG-090 (#153 A8) : render_sheets() expose total_rows/total_cols, max_rows/max_cols et truncated ; la visionneuse affiche un bandeau « Feuille tronquée » (i18n FR/EN) au lieu de couper en silence, et la ligne d'en-têtes devient sticky (top:auto sur les numéros de ligne). - #153 A9 : GET /api/file/{vault}/xlsx/sheet?sheet&offset&limit sert une fenêtre de 1 à 1000 lignes avec les vraies coordonnées A1, has_more de pagination et valeurs calculées A12 ; 404 feuille inconnue, 415 non-xlsx. - Tests : TestXlsxTruncationNotice (4) + TestXlsxSheetWindow (11) avec contre-preuves, xlsx-viewer.test.mjs 14/14, E2E 7/7 (fixture sample-xlsx-large.xlsx 520 lignes), suite 1417 passed / 6 skipped, ruff/mypy 0, i18n parity. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <[email protected]>
This commit is contained in:
+30
-1
@@ -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).
|
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
|
> **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
|
## [2.30.0] — 2026-09-27
|
||||||
|
|
||||||
### Correction
|
### Correction
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -185,6 +185,26 @@ _ENDPOINT_EXAMPLES: dict[tuple[str, str], dict[str, Any]] = {
|
|||||||
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
"request": {"sheet": "Budget", "cells": {"B1": "250"}, "allow_formula": False, "force": False},
|
||||||
"response": {"status": "ok", "vault": "TestVault", "path": "data/budget.xlsx", "size": 1},
|
"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": "<table>…</table>",
|
||||||
|
},
|
||||||
|
},
|
||||||
("post", "/api/search/replace"): {
|
("post", "/api/search/replace"): {
|
||||||
"request": {"query": "Python", "replacement": "Python 3", "vault": "all", "dry_run": True},
|
"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},
|
"response": {"matches": [{"vault": "TestVault", "path": "note1.md", "title": "Python", "match_count": 3}], "total_matches": 3, "dry_run": True},
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from backend.schemas import (
|
|||||||
BrowseResponse,
|
BrowseResponse,
|
||||||
FileContentResponse,
|
FileContentResponse,
|
||||||
FileRawResponse,
|
FileRawResponse,
|
||||||
|
XlsxSheetWindowResponse,
|
||||||
)
|
)
|
||||||
from backend.services.files import read_raw_file
|
from backend.services.files import read_raw_file
|
||||||
from backend.services.paths import resolve_safe_path
|
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)
|
@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)):
|
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.
|
"""Return rendered HTML and metadata for a file.
|
||||||
|
|||||||
+32
-1
@@ -286,7 +286,12 @@ class FileContentResponse(BaseModel):
|
|||||||
is_csv: bool | None = Field(default=None, description="True for CSV files")
|
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")
|
is_xlsx: bool | None = Field(default=None, description="True for Excel .xlsx files")
|
||||||
xlsx_sheets: list[dict[str, Any]] | None = Field(
|
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(
|
xlsx_lossy_features: list[str] | None = Field(
|
||||||
default=None,
|
default=None,
|
||||||
@@ -305,6 +310,32 @@ class FileContentResponse(BaseModel):
|
|||||||
image_mime: str | None = Field(default=None, description="MIME type for image files")
|
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):
|
class FileRawResponse(BaseModel):
|
||||||
"""Raw text content of a file."""
|
"""Raw text content of a file."""
|
||||||
|
|
||||||
|
|||||||
+145
-8
@@ -28,6 +28,12 @@ logger = logging.getLogger("obsigate.xlsx_reader")
|
|||||||
MAX_ROWS = 500
|
MAX_ROWS = 500
|
||||||
MAX_COLS = 40
|
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.
|
# #153 A1 — workbook parts openpyxl does not re-serialize on load+save.
|
||||||
# Verified against openpyxl 3.1.5: charts, images, drawings and pivot tables
|
# 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.
|
# 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 ""
|
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.
|
"""Render a grid as an HTML table.
|
||||||
|
|
||||||
``cached`` is the same grid read with ``data_only=True`` (#153 A12): where a
|
``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
|
number Excel last calculated instead of only the formula text. The span
|
||||||
carries ``data-cached-value`` and is titled client-side from
|
carries ``data-cached-value`` and is titled client-side from
|
||||||
``xlsx.cached_value_title`` — the backend never emits UI text.
|
``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:
|
if not grid:
|
||||||
return "<p><em>Feuille vide</em></p>"
|
return "<p><em>Feuille vide</em></p>"
|
||||||
@@ -119,7 +133,7 @@ def _table(grid: list[list[str]], cached: list[list[str]] | None = None) -> str:
|
|||||||
]
|
]
|
||||||
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
||||||
out.append("</tr></thead><tbody>")
|
out.append("</tr></thead><tbody>")
|
||||||
for r, row in enumerate(grid, start=1):
|
for r, row in enumerate(grid, start=row_offset + 1):
|
||||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||||
for c, val in enumerate(row, start=1):
|
for c, val in enumerate(row, start=1):
|
||||||
ref = f"{get_column_letter(c)}{r}"
|
ref = f"{get_column_letter(c)}{r}"
|
||||||
@@ -191,19 +205,24 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
def render_sheets(file_path: Path) -> list[dict[str, str]]:
|
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||||
"""Return ``[{"name": sheet_title, "html": table_html}, ...]``.
|
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
|
||||||
|
|
||||||
Reads the workbook twice: once with ``data_only=False`` for the formulas
|
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
|
(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.
|
(#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,
|
The second pass is skipped entirely when the archive holds no cached value,
|
||||||
so the common case still costs a single load.
|
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)
|
wb = load_workbook(str(file_path), read_only=True, data_only=False)
|
||||||
try:
|
try:
|
||||||
formulas = [_sheet_grid(ws) for ws in wb.worksheets]
|
formulas = [_sheet_grid(ws) for ws in wb.worksheets]
|
||||||
titles = [ws.title for ws in wb.worksheets]
|
titles = [ws.title for ws in wb.worksheets]
|
||||||
|
extents = [_sheet_extent(ws) for ws in wb.worksheets]
|
||||||
finally:
|
finally:
|
||||||
wb.close()
|
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
|
# shift every cached value left of its formula. Indexing it
|
||||||
# positionally against the untrimmed grid keeps the two aligned.
|
# positionally against the untrimmed grid keeps the two aligned.
|
||||||
shadow = cached[i] if cached is not None and i < len(cached) else None
|
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
|
return sheets
|
||||||
|
|
||||||
|
|
||||||
def _sheet_grid(ws: Any) -> list[list[str]]:
|
def read_sheet_window(
|
||||||
"""Read one worksheet into a grid of formatted strings, bounded by the caps."""
|
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 [
|
return [
|
||||||
[_fmt(v) for v in row]
|
[_fmt(v) for v in row]
|
||||||
for row in ws.iter_rows(
|
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
|
||||||
)
|
)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.30.0"
|
version = "2.31.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.30.0"
|
version = "2.31.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.30.0",
|
"version": "2.31.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
@@ -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
|
- Deux sauvegardes simultanées sur le même fichier : la seconde reçoit
|
||||||
**409** `conflict` au lieu d'écraser la première.
|
**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
|
### Limites
|
||||||
|
|
||||||
- Le rendu est plafonné à **500 lignes × 40 colonnes** par feuille, sans
|
- L'affichage intégré reste plafonné à **500 lignes × 40 colonnes** par
|
||||||
pagination : au-delà, le contenu n'est pas affiché (et non éditable).
|
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
|
- 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
|
rendus.
|
||||||
recherche (contrairement aux PDF).
|
|
||||||
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
|
- Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -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-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 `<f>` 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-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 `<f>` 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-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)
|
### 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 |
|
| 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 (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), 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-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 (`<span class="xlsx-cached">`, 2ᵉ lecture `data_only=True` uniquement si l'archive contient un `<v>`), 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) |
|
| 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) |
|
| *(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) |
|
||||||
|
|||||||
+5
-5
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **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)
|
### 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 :** 🟡
|
- **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)
|
- **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
|
- **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,
|
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é)
|
- [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`
|
- [ ] **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)
|
- [ ] **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)
|
- [x] **A8** `thead` sticky + bandeau « feuille tronquée » (lève la troncature silencieuse) — BUG-090
|
||||||
- [ ] **A9** Chargement paresseux par feuille (`GET …/xlsx/sheet?offset&limit`, défilement virtuel)
|
- [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)
|
- [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
|
- [ ] **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 `<v>`, info-bulle FR/EN)
|
- [x] **A12** Valeur calculée affichée sous la formule (2ᵉ lecture `data_only=True` seulement si l'archive contient un `<v>`, 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 |
|
| 🔵 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é |
|
| ⚪ 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 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** |
|
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
|
> **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md)
|
||||||
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
|
> **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir
|
||||||
> [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md))
|
> [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)
|
> **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
|
> **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.**
|
> l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.**
|
||||||
@@ -15,8 +15,9 @@
|
|||||||
| Couche | Fichier | Rôle |
|
| Couche | Fichier | Rôle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) |
|
| 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}]` |
|
| 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}]` |
|
||||||
| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets` |
|
| 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`) |
|
| É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) |
|
| 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` |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 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
|
## 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
|
- [ ] **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
|
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`).
|
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
|
- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).**
|
||||||
défilement vertical ; bandeau « feuille tronquée à 500 lignes × 40 colonnes » ; libellés
|
Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les
|
||||||
FR/EN.
|
numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
|
||||||
- [ ] **A9 — Chargement paresseux par feuille (supprime le plafond).** Endpoint
|
`total_rows`/`total_cols` (dimensions déclarées), `max_rows`/`max_cols` (plafonds) et
|
||||||
`GET /api/file/{vault}/xlsx/sheet?sheet=N&offset=&limit=` (`response_model` +
|
`truncated` — le bandeau « feuille tronquée » annonce le **plafond atteint** et non la
|
||||||
`backend/openapi_docs.py`), rendu à la demande avec défilement virtuel, bouton « charger
|
taille élaguée (une feuille creuse rend 1×1 tout en couvrant 500 lignes) ; libellés
|
||||||
tout ».
|
`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
|
- [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`),
|
(`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
|
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 | 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-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 | **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 |
|
||||||
|
|||||||
+30
-1
@@ -1005,6 +1005,35 @@ export function renderVideoViewer(area, data) {
|
|||||||
// confirmation before retrying with `force: true`. A value starting with "=" or
|
// 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
|
// "@" 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.
|
// `=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 `<div class="xlsx-truncated" role="note">
|
||||||
|
<i data-lucide="scissors" class="xlsx-truncated-icon"></i>
|
||||||
|
<div class="xlsx-warning-body">
|
||||||
|
<strong>${escapeHtml(t("xlsx.truncated_title"))}</strong>
|
||||||
|
<span>${escapeHtml(reasons.join(" "))}</span>
|
||||||
|
<span class="xlsx-warning-hint">${escapeHtml(t("xlsx.truncated_hint"))}</span>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
export function renderXlsxViewer(area, data) {
|
export function renderXlsxViewer(area, data) {
|
||||||
const sheets = data.xlsx_sheets || [];
|
const sheets = data.xlsx_sheets || [];
|
||||||
const lossy = data.xlsx_lossy_features || [];
|
const lossy = data.xlsx_lossy_features || [];
|
||||||
@@ -1019,7 +1048,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
).join("")}</div>`
|
).join("")}</div>`
|
||||||
: "";
|
: "";
|
||||||
const panels = sheets.map((s, i) =>
|
const panels = sheets.map((s, i) =>
|
||||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${s.html}</div>`
|
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
||||||
).join("");
|
).join("");
|
||||||
const lossWarning = lossy.length
|
const lossWarning = lossy.length
|
||||||
? `<div class="xlsx-warning" role="note">
|
? `<div class="xlsx-warning" role="note">
|
||||||
|
|||||||
@@ -1829,6 +1829,10 @@
|
|||||||
"xlsx.lossy_cancelled": "Save cancelled",
|
"xlsx.lossy_cancelled": "Save cancelled",
|
||||||
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
|
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
|
||||||
"xlsx.cached_value_title": "Last value calculated by Excel",
|
"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_cached_values": "cached values",
|
||||||
"xlsx.feature_slicers": "slicers and timelines",
|
"xlsx.feature_slicers": "slicers and timelines",
|
||||||
"xlsx.feature_form_controls": "form controls",
|
"xlsx.feature_form_controls": "form controls",
|
||||||
|
|||||||
@@ -1829,6 +1829,10 @@
|
|||||||
"xlsx.lossy_cancelled": "Sauvegarde annulée",
|
"xlsx.lossy_cancelled": "Sauvegarde annulée",
|
||||||
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
|
"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.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_cached_values": "valeurs calculées",
|
||||||
"xlsx.feature_slicers": "segments et chronologies",
|
"xlsx.feature_slicers": "segments et chronologies",
|
||||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||||
|
|||||||
+40
-2
@@ -10967,12 +10967,25 @@ body.desktop-mode .editor-container {
|
|||||||
border-right: 1px solid var(--border-light, var(--border));
|
border-right: 1px solid var(--border-light, var(--border));
|
||||||
position: sticky;
|
position: sticky;
|
||||||
left: 0;
|
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 {
|
.xlsx-table th.xlsx-corner {
|
||||||
left: 0;
|
left: 0;
|
||||||
top: 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] {
|
.xlsx-table td[contenteditable] {
|
||||||
cursor: text;
|
cursor: text;
|
||||||
@@ -11051,6 +11064,31 @@ body.desktop-mode .editor-container {
|
|||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
opacity: 0.85;
|
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 {
|
.xlsx-formula-toggle {
|
||||||
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
Binary file not shown.
@@ -14,6 +14,12 @@
|
|||||||
* - the f(x) toggle is off by default, so "=B1*3" is stored as text ;
|
* - 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).
|
* - 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
|
* The fixture is restored byte-for-byte in `afterAll` so a local run never
|
||||||
* dirties the working copy.
|
* dirties the working copy.
|
||||||
*
|
*
|
||||||
@@ -27,6 +33,10 @@ import path from 'node:path';
|
|||||||
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||||
const VAULT = 'TestVault';
|
const VAULT = 'TestVault';
|
||||||
const FIXTURE = 'sample-xlsx-lossy.xlsx';
|
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).
|
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
|
||||||
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
|
||||||
|
|
||||||
@@ -45,7 +55,11 @@ async function login(page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function openFixture(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())) {
|
if (!(await treeItem.count())) {
|
||||||
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
|
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
|
||||||
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
|
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 });
|
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"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
|
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
|
||||||
* retries with `force: true` (or gives up when refused);
|
* retries with `force: true` (or gives up when refused);
|
||||||
* - A4 : the f(x) toggle flips `allow_formula` in the save payload.
|
* - 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
|
* Usage: node tests/frontend/xlsx-viewer.test.mjs
|
||||||
*/
|
*/
|
||||||
@@ -116,14 +117,14 @@ const sheetHtml = (value) =>
|
|||||||
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
|
`<tr><th class="xlsx-rownum">1</th><td data-cell="A1">${value}</td></tr>` +
|
||||||
"</tbody></table></div>";
|
"</tbody></table></div>";
|
||||||
|
|
||||||
function mount({ lossy = [] } = {}) {
|
function mount({ lossy = [], sheet = {} } = {}) {
|
||||||
const area = document.getElementById("content-area");
|
const area = document.getElementById("content-area");
|
||||||
area.innerHTML = "";
|
area.innerHTML = "";
|
||||||
renderXlsxViewer(area, {
|
renderXlsxViewer(area, {
|
||||||
vault: "V",
|
vault: "V",
|
||||||
path: "data.xlsx",
|
path: "data.xlsx",
|
||||||
is_xlsx: true,
|
is_xlsx: true,
|
||||||
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
|
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100"), ...sheet }],
|
||||||
xlsx_lossy_features: lossy,
|
xlsx_lossy_features: lossy,
|
||||||
});
|
});
|
||||||
return area;
|
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);
|
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 ──────────────────────────────────────────────────────────────────
|
// ── Report ──────────────────────────────────────────────────────────────────
|
||||||
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
console.log(`\n${passCount}/${testCount} tests passed\n`);
|
||||||
process.exit(passCount === testCount ? 0 : 1);
|
process.exit(passCount === testCount ? 0 : 1);
|
||||||
|
|||||||
@@ -519,6 +519,173 @@ class TestXlsxWriteLock:
|
|||||||
assert openpyxl.load_workbook(xlsx_file)["Budget"]["A1"].value == "premier"
|
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 "<th class=\"xlsx-rownum\">2</th>" 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 ──────────────────────────────────────────
|
# ── #153 A4 — formula injection ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user