From ca6407e0c0b82eca05500f56d09a1193b7a613f7 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 28 Sep 2026 16:27:07 -0400 Subject: [PATCH] feat: formats tableur additionnels - xlsm editable, xls/ods lecture seule, csv editable #153 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A16 — la visionneuse tableur accepte quatre formats de plus : .xlsm est servi et sauvegarde comme un .xlsx avec keep_vba=True (les macros survivent, la porte lossy est levée pour ce format) ; .xls (xlrd) et .ods (odfpy) sont rendus en lecture seule (xlsx_readonly, wiring d'édition désactivé) ; .csv devient éditable via render_csv_table (grille A1 identique au viewer) et PUT /api/file/{vault}/csv/save (réécriture csv RFC 4180, extension de grille, valeurs stockées telles quelles). 12 tests backend + contre-preuve (4 échecs sur neutralisation du service CSV), JSDOM 33/33, ruff/mypy 0. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- CHANGELOG.md | 6 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/requirements.txt | 2 + backend/routers/files_read.py | 80 +++++++++--- backend/routers/files_write.py | 38 ++++++ backend/schemas.py | 8 ++ backend/services/mutations.py | 105 +++++++++++++++- backend/xlsx_reader.py | 135 ++++++++++++++++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 2 +- frontend/js/viewer.js | 59 ++++++--- package.json | 2 +- tests/fixtures/sample.xls | Bin 0 -> 5632 bytes tests/test_xlsx_formats.py | 219 +++++++++++++++++++++++++++++++++ 18 files changed, 624 insertions(+), 52 deletions(-) create mode 100644 tests/fixtures/sample.xls create mode 100644 tests/test_xlsx_formats.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a137f1..0d8dec3 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.37.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.38.0**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.38.0] — 2026-09-28 + +--- + ## [2.37.0] — 2026-09-28 --- diff --git a/README.fr.md b/README.fr.md index 7bf67f1..bedbe4e 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.37.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.38.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.37.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.38.0). --- -*Projet : ObsiGate | Version : 2.37.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.38.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 84ac30a..3efdb51 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.37.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.38.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.37.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.38.0). --- -*Project: ObsiGate | Version: 2.37.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.38.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index c94fd31..f31d783 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.37.0 +2.38.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 400fac2..c827c84 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -22,6 +22,8 @@ pywebpush>=2.3.0 mcp==1.28.1 sse-starlette==2.1.3 openpyxl>=3.1 +xlrd==2.0.2 +odfpy==1.4.1 python-docx>=1.1 reportlab>=4.0 pillow>=10.0 diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index a2c9ed1..cfc666b 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -446,27 +446,67 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative logger.error(f"Unexpected error reading file {path}: {e}") raise HTTPException(status_code=500, detail=f"Error reading file: {e!s}") - # === CSV: render as HTML table === + # === Excel .xlsm: same editable viewer as .xlsx, macros preserved on save === + if ext == ".xlsm": + try: + from backend.xlsx_reader import inspect_workbook, render_sheets + + sheets = render_sheets(file_path) + size = file_path.stat().st_size + return { + "vault": vault_name, + "path": path, + "title": file_path.name, + "tags": [], + "frontmatter": {}, + "html": sheets[0]["html"] if sheets else "", + "raw_length": size, + "extension": ext, + "is_markdown": False, + "is_xlsx": True, + "xlsx_sheets": sheets, + # Macros are NOT lossy for .xlsm: keep_vba re-serializes them + # (an empty LOSSY probe is what makes the save gate pass). + "xlsx_lossy_features": [], + "unsupported": False, + "size_bytes": size, + } + except Exception as e: + logger.error(f"XLSX read error for {path}: {e}") + raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}") + + # === Legacy/ODF spreadsheets (.xls, .ods): read-only table view === + if ext in (".xls", ".ods"): + try: + from backend.xlsx_reader import render_legacy_workbook + + sheets = render_legacy_workbook(file_path, ext) + size = file_path.stat().st_size + return { + "vault": vault_name, + "path": path, + "title": file_path.name, + "tags": [], + "frontmatter": {}, + "html": sheets[0]["html"] if sheets else "", + "raw_length": size, + "extension": ext, + "is_markdown": False, + "is_xlsx": True, + "xlsx_readonly": True, + "xlsx_sheets": sheets, + "unsupported": False, + "size_bytes": size, + } + except Exception as e: + logger.error(f"Spreadsheet read error for {path}: {e}") + raise HTTPException(status_code=500, detail=f"Error reading spreadsheet: {e!s}") + + # === CSV: spreadsheet-style table (same shape as the xlsx viewer) === if ext == ".csv": - import csv - import io as csv_io - reader = csv.reader(csv_io.StringIO(raw)) - rows = list(reader) - if not rows: - html = "

Fichier CSV vide

" - else: - headers = rows[0] - data_rows = rows[1:] - html = '
' - for h in headers: - html += f"" - html += "" - for row in data_rows: - html += "" - for cell in row: - html += f"" - html += "" - html += "
{h}
{cell}
" + from backend.xlsx_reader import render_csv_table + + html = render_csv_table(raw) return { "vault": vault_name, "path": path, "title": file_path.name, "tags": [], "frontmatter": {}, diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py index 1b2fc72..b4297f6 100644 --- a/backend/routers/files_write.py +++ b/backend/routers/files_write.py @@ -73,6 +73,9 @@ from backend.services.mutations import ( from backend.services.mutations import ( rename_file as service_rename_file, ) +from backend.services.mutations import ( + save_csv_cells as service_save_csv_cells, +) from backend.share import update_shares_after_rename from backend.sse import sse_manager from backend.webhooks import dispatch_webhooks @@ -177,6 +180,41 @@ def api_file_xlsx_save( return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} +@router.put("/api/file/{vault_name}/csv/save", response_model=FileSaveResponse) +def api_file_csv_save( + vault_name: str, + path: str = Query(..., description="Relative path to the .csv file"), + body: dict = Body( + ..., + description='{"cells": {"A1": value}} — A1-addressed text edits (#153 A16)', + ), + current_user=Depends(require_auth), +): + """Apply A1-addressed cell edits to a ``.csv`` file (#153 A16). + + The grid is re-parsed with :mod:`csv`, patched and re-serialized + (RFC 4180 quoting). References beyond the extent grow the grid. Values + are stored verbatim as text — a CSV has no formula engine. + """ + if not check_vault_access(vault_name, current_user): + raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'") + + cells = body.get("cells") + if not isinstance(cells, dict) or not cells or len(cells) > 500: + raise HTTPException(status_code=400, detail="Cellules invalides (1 à 500 par requête)") + for ref, value in cells.items(): + if not isinstance(ref, str) or not isinstance(value, (str, int, float, bool, type(None))): + raise HTTPException(status_code=400, detail=f"Cellule invalide: {ref!r}") + + result = service_save_csv_cells(vault_name, path, cells) + log_file_save( + current_user["username"], vault_name, path, + sum(len(str(v)) for v in cells.values()), + current_user.get("_request_ip", "unknown"), + ) + return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]} + + @router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse) def api_file_xlsx_structure( vault_name: str, diff --git a/backend/schemas.py b/backend/schemas.py index 00cf507..217506c 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -285,6 +285,14 @@ class FileContentResponse(BaseModel): media_mime: str | None = Field(default=None, description="MIME type for audio/video 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") + xlsx_readonly: bool | None = Field( + default=None, + description=( + "True when the table is served read-only (.xls/.ods, #153 A16): " + "the viewer hides the editable-cell wiring and the save/structure " + "endpoints refuse the format" + ), + ) xlsx_sheets: list[dict[str, Any]] | None = Field( default=None, description=( diff --git a/backend/services/mutations.py b/backend/services/mutations.py index 7d1b32a..998cb44 100644 --- a/backend/services/mutations.py +++ b/backend/services/mutations.py @@ -387,9 +387,9 @@ def edit_xlsx_cells( status=404, details={"vault": vault_name, "path": path}, ) - if file_path.suffix.lower() != ".xlsx": + if file_path.suffix.lower() not in (".xlsx", ".xlsm"): raise ServiceError( - f"Not an .xlsx file: {path}", code="invalid", status=400 + f"Not an .xlsx/.xlsm file: {path}", code="invalid", status=400 ) if not cells: raise ServiceError("No cells to update", code="invalid", status=400) @@ -399,7 +399,10 @@ def edit_xlsx_cells( f"Invalid cell reference: {ref!r}", code="invalid", status=400 ) - if not force: + # #153 A16 — the lossy gate is skipped for .xlsm: the save re-serializes + # with keep_vba=True, so the macro project (the only extra part a .xlsm + # carries) survives and nothing is dropped. + if not force and file_path.suffix.lower() != ".xlsm": from backend.xlsx_reader import inspect_workbook lossy = inspect_workbook(file_path) @@ -415,8 +418,11 @@ def edit_xlsx_cells( with _xlsx_write_lock(str(file_path)): from openpyxl import load_workbook + # #153 A16 — .xlsm round-trips with keep_vba=True so the macro + # project survives the save (the endpoint's lossy probe is empty + # for .xlsm on purpose). try: - wb = load_workbook(file_path) + wb = load_workbook(file_path, keep_vba=file_path.suffix.lower() == ".xlsm") except Exception as exc: raise ServiceError( f"Cannot open workbook: {exc}", code="invalid", status=400 @@ -642,6 +648,97 @@ def mutate_xlsx_structure( } +def save_csv_cells( + vault_name: str, + path: str, + cells: dict[str, Any], + *, + backup: bool = True, +) -> dict[str, Any]: + """Apply A1-addressed cell edits to a ``.csv`` file (#153 A16). + + The file is re-parsed, patched and re-serialized with :mod:`csv` so + quoting follows RFC 4180. References beyond the current extent grow the + grid (missing rows/cells are filled with empty strings). Values are + stored as text: a CSV has no formula engine, so any string — including + ones starting with ``=`` — is written verbatim (the render escapes it). + + Raises: + ServiceError: ``not_found`` (404), ``read_only`` (403), ``conflict`` + (409, concurrent write) or ``invalid`` (400) for a bad reference. + """ + import csv as csv_mod + import io as io_mod + + root = get_vault_root(vault_name) + _ensure_writable(root) + file_path = resolve_safe_path(root, path) + + if not file_path.exists() or not file_path.is_file(): + raise ServiceError( + f"File not found: {path}", + code="not_found", + status=404, + details={"vault": vault_name, "path": path}, + ) + if file_path.suffix.lower() != ".csv": + raise ServiceError(f"Not a .csv file: {path}", code="invalid", status=400) + if not cells: + raise ServiceError("No cells to update", code="invalid", status=400) + for ref in cells: + if not isinstance(ref, str) or not _XLSX_CELL_RE.match(ref): + raise ServiceError( + f"Invalid cell reference: {ref!r}", code="invalid", status=400 + ) + + raw = file_path.read_text(encoding="utf-8", errors="replace") + try: + rows = list(csv_mod.reader(io_mod.StringIO(raw))) + except csv_mod.Error: + rows = [[line] for line in raw.splitlines()] + + def _col_num(ref: str) -> int: + letters = ref.rstrip("0123456789").upper() + n = 0 + for ch in letters: + n = n * 26 + (ord(ch) - ord("A") + 1) + return n + + def _row_num(ref: str) -> int: + return int(ref[len(ref.rstrip("0123456789")):]) + + for ref, value in cells.items(): + r, c = _row_num(ref), _col_num(ref) + while len(rows) < r: + rows.append([]) + row = rows[r - 1] + while len(row) < c: + row.append("") + row[c - 1] = "" if value is None else str(value) + + rel_path = _rel(root, file_path) + if backup: + create_backup(file_path, vault_name, rel_path) + + buf = io_mod.StringIO() + csv_mod.writer(buf, lineterminator="\n").writerows(rows) + tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp") + try: + tmp_path.write_text(buf.getvalue(), encoding="utf-8") + os.replace(tmp_path, file_path) + except Exception: + tmp_path.unlink(missing_ok=True) + raise + + logger.info(f"CSV cells saved: {vault_name}/{rel_path} +{len(cells)}") + return { + "success": True, + "vault": vault_name, + "path": rel_path, + "size": len(cells), + } + + def append_to_file( vault_name: str, path: str, diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py index e6114c8..11fd4c4 100644 --- a/backend/xlsx_reader.py +++ b/backend/xlsx_reader.py @@ -6,6 +6,10 @@ Write-side lives in ``backend.services.mutations.edit_xlsx_cells``. :func:`inspect_workbook` lists the workbook features that an openpyxl round-trip would drop (#153 A1) so the UI can warn before saving. + +#153 A16 — :func:`render_sheets` also accepts ``.xlsm`` (macros preserved on +save via ``keep_vba``), ``.xls`` (xlrd) and ``.ods`` (odfpy), both served +read-only; :func:`render_csv_table` turns a CSV into the same table shape. """ from __future__ import annotations @@ -639,3 +643,134 @@ def extract_indexable_text(file_path: Path) -> str: finally: wb.close() return "\n".join(c for c in chunks if c).strip() + + +# ── #153 A16 — additional spreadsheet formats ─────────────────────────────── + + +def render_csv_table(raw: str, *, delimiter: str = ",") -> str: + """Render CSV text as the same HTML table shape the xlsx viewer consumes. + + Row numbers replace the A1 column: a CSV has no fixed column count, so + the first row is a plain data row like the others (the viewer offers the + toolbar either way). Every cell is HTML-escaped at render time. + """ + import csv as csv_mod + import io as io_mod + + reader = csv_mod.reader(io_mod.StringIO(raw), delimiter=delimiter) + try: + rows = [row for row in reader] + except csv_mod.Error: + # A malformed CSV still renders: each line becomes a one-cell row. + rows = [[line] for line in raw.splitlines()] + if not rows: + return "

Feuille vide

" + n_cols = max(len(r) for r in rows) + out = [ + ('
' + '') + ] + out += [f"" for c in range(1, n_cols + 1)] + out.append("") + for r, row in enumerate(rows, start=1): + out.append(f'') + for c in range(1, n_cols + 1): + val = row[c - 1] if c - 1 < len(row) else "" + out.append(f'') + out.append("") + out.append("
{get_column_letter(c)}
{r}{html.escape(val)}
") + return "".join(out) + + +def render_legacy_workbook(file_path: Path, ext: str) -> list[dict[str, Any]]: + """Render ``.xls``/``.ods`` sheets with the same dict shape as xlsx. + + Read-only formats (#153 A16): ``styles``/``aligns``/``merges``/``freeze`` + are served empty so the client-side wiring keeps one code path. Raises + nothing to the render path: an unreadable file yields one error sheet. + """ + name = file_path.name + try: + if ext == ".xls": + import xlrd + + book = xlrd.open_workbook(str(file_path)) + titles = book.sheet_names() + grids = [] + for si in range(book.nsheets): + sh = book.sheet_by_index(si) + grid = [ + [_fmt(sh.cell_value(r, c)) for c in range(min(sh.ncols, MAX_COLS))] + for r in range(min(sh.nrows, MAX_ROWS)) + ] + grids.append(_trim(grid)) + total = [(sh.nrows, sh.ncols) for sh in (book.sheet_by_index(i) for i in range(book.nsheets))] + elif ext == ".ods": + from odf.opendocument import load as odf_load + from odf.table import Table, TableCell, TableRow + from odf.teletype import extractText + + doc = odf_load(str(file_path)) + titles = [] + grids = [] + total = [] + for table in doc.getElementsByType(Table): + title = table.getAttribute("name") or f"Feuille {len(titles) + 1}" + titles.append(title) + grid = [] + for row in table.getElementsByType(TableRow)[:MAX_ROWS]: + row_cells = row.getElementsByType(TableCell) + values: list[str] = [] + for tc in row_cells[:MAX_COLS]: + repeat = int(tc.getAttribute("numbercolumnsrepeated") or 1) + values.extend([extractText(tc)] * min(repeat, MAX_COLS - len(values))) + grid.append(values) + grids.append(_trim(grid)) + total.append((len(grid), max((len(r) for r in grid), default=0))) + else: + raise ValueError(f"Unsupported legacy format: {ext}") + except Exception as exc: + logger.warning("legacy workbook render failed for %s: %s", name, exc) + return [ + { + "name": name, + "html": ( + '

Feuille vide

' + ), + "rows": 0, + "cols": 0, + "total_rows": 0, + "total_cols": 0, + "max_rows": MAX_ROWS, + "max_cols": MAX_COLS, + "truncated": False, + "styles": {}, + "aligns": {}, + "merges": [], + "freeze": "", + } + ] + + sheets: list[dict[str, Any]] = [] + for i, title in enumerate(titles): + grid = grids[i] if i < len(grids) else [] + t_rows, t_cols = total[i] if i < len(total) else (0, 0) + sheets.append( + { + "name": title, + "html": _table(grid), + "rows": len(grid), + "cols": max((len(r) for r in grid), default=0), + "total_rows": t_rows, + "total_cols": t_cols, + "max_rows": MAX_ROWS, + "max_cols": MAX_COLS, + "truncated": t_rows > MAX_ROWS or t_cols > MAX_COLS, + "styles": {}, + "aligns": {}, + "merges": [], + "freeze": "", + } + ) + return sheets diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 391b3b0..bf5d2f7 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.37.0" +version = "2.38.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 62457f6..dfd3413 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.37.0" +version = "2.38.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 63bc464..3341be9 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.37.0", + "version": "2.38.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 1aea8a7..c94920c 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.37.0 | **Dernière mise à jour :** 2026-09-28 +> **Version :** 2.38.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)** diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index f054593..9d42f6c 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1037,6 +1037,11 @@ function truncationNote(sheet) { export function renderXlsxViewer(area, data) { const sheets = data.xlsx_sheets || []; const lossy = data.xlsx_lossy_features || []; + // #153 A16 — mode flags: a .csv rides the same viewer with a flat + // structure (no tabs/merges/freeze to wire) and its own save endpoint; + // .xls/.ods are served read-only (no editing, no structure ops). + const isCsv = Boolean(data.is_csv); + const readOnly = Boolean(data.xlsx_readonly); // Session-scoped state: once the lossy write is confirmed, the rest of the // session saves without asking again (never persisted — a confirmation is // per workbook, not a global preference). @@ -1100,9 +1105,11 @@ export function renderXlsxViewer(area, data) { } } }; - const panels = sheets.map((s, i) => - `` - ).join(""); + const panels = isCsv + ? `
${sheets[0]?.html || data.html || ""}
` + : sheets.map((s, i) => + `` + ).join(""); const lossWarning = lossy.length ? `
@@ -1123,16 +1130,16 @@ export function renderXlsxViewer(area, data) { - + - + `}
${lossWarning} @@ -1392,7 +1399,13 @@ export function renderXlsxViewer(area, data) { }); // Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate. - area.querySelectorAll(".xlsx-table td").forEach(setupCell); + // Read-only formats (.xls/.ods) skip the wiring entirely — the toolbar + // save button is disabled and the structure menu does not exist. + if (readOnly) { + saveBtn.disabled = true; + } else { + area.querySelectorAll(".xlsx-table td").forEach(setupCell); + } panelEls.forEach(wireLazyRows); panelEls.forEach(applySheetMeta); // The formula bar starts disabled: nothing is selected yet. @@ -1414,14 +1427,23 @@ export function renderXlsxViewer(area, data) { formulaBtn.classList.toggle("active", allowFormula); }); - const putSheet = (job, force) => api( - `/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ ...job, allow_formula: allowFormula, force }), - }, - ); + const putSheet = isCsv + ? (job, force) => api( + `/api/file/${encodeURIComponent(data.vault)}/csv/save?path=${encodeURIComponent(data.path)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ cells: job.cells }), + }, + ) + : (job, force) => api( + `/api/file/${encodeURIComponent(data.vault)}/xlsx/save?path=${encodeURIComponent(data.path)}`, + { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ ...job, allow_formula: allowFormula, force }), + }, + ); saveBtn.addEventListener("click", async () => { // One PUT per sheet (dirty cells can span tabs before a save). @@ -1835,6 +1857,13 @@ export function renderFile(data) { return; } + // Handle CSV — same spreadsheet viewer, flat mode (#153 A16): the backend + // renders the grid with data-cell refs and PUT …/csv/save persists edits. + if (data.is_csv) { + renderXlsxViewer(area, data); + return; + } + // Handle Excalidraw — render in iframe editor if (data.is_excalidraw) { renderExcalidraw(area, data, data.vault, data.path); diff --git a/package.json b/package.json index 72bac5f..fb8a3b8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.37.0", + "version": "2.38.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/tests/fixtures/sample.xls b/tests/fixtures/sample.xls new file mode 100644 index 0000000000000000000000000000000000000000..b1c54818950c958e2ff947fcfe6f5798e9bfc3a7 GIT binary patch literal 5632 zcmeHLJ!n%=6#m|8(k3-clb8xtD4}4~+C^Mk+SK1pmMDTEf~F>7Xh|eQPzSBnjDp~# zxD>j$R9*Z*2i+8eiVlJxBsvu*LBXN*`JM9`Niu{Kv8deh$hqh5=A8H4d*8e7zMc=4 zXYTh_&_Kgz!w<)g_8PlI*3Y>6V=3cZfsK2f z=j6ag{*K`c#`3vLY!zvj){wHKh9Pnbw`mDty;c=bep2~b<##pmsqzuETvI-T2+Gz` z%qPYMcJRNze>!6Kt1n1y;i2|)$fwU{i`0^Z>^U~OQ>PXfe(3Zj_GV3==Rd8@wda!d)9DE%J$#NV}zE$}ZiNOYydzDAIWEd4s z_ACqgrZwiOdgIKqUFmINQ;octa_RGdM}4JFi7k}pMk^PhbMF!}=;tdZ%^Y$~W6&D3 zZrv1moT#=Im$=mRLAm>YsC_i}Y(C;hHt$Kv>c$cTGa#(o)hudq{}L~4W4zd0{4Uy^ zT2B~jI4}|~`5WWK7FOIOUT~u31%~Yi9*7eo!j{Rcr>dFOAYc$M2p9wm0tNwtfI+|@ z@JA4EH@fcjUbg+*pXwO^_qBSiv&6FuIo~n#eRWOC+WT)cNt3wW%KsBF CUd9{% literal 0 HcmV?d00001 diff --git a/tests/test_xlsx_formats.py b/tests/test_xlsx_formats.py new file mode 100644 index 0000000..8ef20ea --- /dev/null +++ b/tests/test_xlsx_formats.py @@ -0,0 +1,219 @@ +"""Additional spreadsheet formats in the viewer (#153 A16): .xlsm editable +with macros preserved, .xls/.ods read-only renders and .csv A1-addressed +saves.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +openpyxl = pytest.importorskip("openpyxl") + +VAULT = "TestVault" + + +@pytest.fixture +def formats_vault(test_vault_dir: str) -> Path: + """Directory of the test vault, for fixture files written in-place.""" + return Path(test_vault_dir) + + +@pytest.fixture +def xlsm_book(formats_vault: Path) -> str: + """A macro-enabled workbook: one sheet, two cells, a fake VBA blob.""" + path = formats_vault / "macro.xlsm" + wb = openpyxl.Workbook() + ws = wb.active + ws.title = "Data" + ws["A1"] = "Nom" + ws["A2"] = "Ada" + wb.save(path) + # Inject a minimal vbaProject.bin part so the archive really IS a .xlsm + # (keep_vba only matters when macros exist). + import shutil + import zipfile + + real = path.with_suffix(".tmp.xlsm") + with zipfile.ZipFile(path) as zin, zipfile.ZipFile(real, "w") as zout: + for item in zin.namelist(): + zout.writestr(item, zin.read(item)) + zout.writestr("xl/vbaProject.bin", b"VBA-FAKE-CONTENT") + shutil.move(real, path) + return "macro.xlsm" + + +@pytest.fixture +def ods_book(formats_vault: Path) -> str: + """A small ODS spreadsheet.""" + # The odfpy distribution installs a top-level module named `odf`. + pytest.importorskip("odf") + from odf.opendocument import OpenDocumentSpreadsheet + from odf.table import Table, TableCell, TableRow + from odf.text import P + + path = formats_vault / "classeur.ods" + doc = OpenDocumentSpreadsheet() + table = Table(name="Feuille1") + for values in (["Ville", "Pop"], ["Paris", "2100000"]): + tr = TableRow() + for v in values: + tc = TableCell(valuetype="string") + tc.addElement(P(text=str(v))) + tr.addElement(tc) + table.addElement(tr) + doc.spreadsheet.addElement(table) + doc.save(str(path)) + return "classeur.ods" + + +class TestXlsmEditable: + def test_read_serves_the_xlsx_viewer_payload(self, client, xlsm_book): + resp = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book}) + assert resp.status_code == 200 + data = resp.json() + assert data["is_xlsx"] is True + assert data["extension"] == ".xlsm" + # Macros are NOT lossy for .xlsm: keep_vba preserves them, so no + # confirmation round-trip is ever triggered. + assert data["xlsx_lossy_features"] == [] + assert 'data-cell="A1"' in data["xlsx_sheets"][0]["html"] + + def test_save_round_trips_and_keeps_vba(self, client, xlsm_book): + resp = client.put( + f"/api/file/{VAULT}/xlsx/save", + params={"path": xlsm_book}, + json={"sheet": "Data", "cells": {"A2": "Grace"}}, + ) + assert resp.status_code == 200 + import zipfile + + # Re-read through the API instead of guessing the root on disk. + reread = client.get(f"/api/file/{VAULT}", params={"path": xlsm_book}) + assert "Grace" in reread.json()["xlsx_sheets"][0]["html"] + # The fake VBA part survived the round-trip. + with zipfile.ZipFile(_vault_file(client, xlsm_book)) as zf: + assert "xl/vbaProject.bin" in zf.namelist() + + def test_lossy_gate_is_skipped_for_xlsm(self, formats_vault, xlsm_book): + from backend.services.mutations import edit_xlsx_cells + + # No force flag: the save must succeed despite the vbaProject part + # (it would raise xlsx_lossy_content on a plain .xlsx). + result = edit_xlsx_cells(VAULT, xlsm_book, "Data", {"A2": "Alan"}) + assert result["success"] is True + + +class TestLegacyReadOnly: + def test_xls_renders_cells(self, client, formats_vault): + pytest.importorskip("xlrd") + import shutil + + shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls") + resp = client.get(f"/api/file/{VAULT}", params={"path": "sample.xls"}) + assert resp.status_code == 200 + data = resp.json() + assert data["is_xlsx"] is True + assert data["xlsx_readonly"] is True + sheet = data["xlsx_sheets"][0] + assert sheet["name"] == "Data" + assert "Produit" in sheet["html"] + assert "Café" in sheet["html"] + + def test_ods_renders_cells(self, client, ods_book): + resp = client.get(f"/api/file/{VAULT}", params={"path": ods_book}) + assert resp.status_code == 200 + data = resp.json() + assert data["xlsx_readonly"] is True + html = data["xlsx_sheets"][0]["html"] + assert "Paris" in html + assert "2100000" in html + + def test_readonly_meta_is_empty(self, client, formats_vault): + pytest.importorskip("xlrd") + import shutil + + shutil.copy("tests/fixtures/sample.xls", formats_vault / "sample.xls") + sheet = client.get( + f"/api/file/{VAULT}", params={"path": "sample.xls"} + ).json()["xlsx_sheets"][0] + assert sheet["styles"] == {} + assert sheet["merges"] == [] + assert sheet["freeze"] == "" + + def test_broken_legacy_file_yields_one_empty_sheet(self, client, formats_vault): + (formats_vault / "broken.xls").write_bytes(b"not an ole file") + resp = client.get(f"/api/file/{VAULT}", params={"path": "broken.xls"}) + assert resp.status_code == 200 + sheet = resp.json()["xlsx_sheets"][0] + assert sheet["rows"] == 0 + + +class TestCsvEditable: + def test_read_renders_the_xlsx_shaped_table(self, client, formats_vault): + (formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8") + resp = client.get(f"/api/file/{VAULT}", params={"path": "liste.csv"}) + assert resp.status_code == 200 + data = resp.json() + assert data["is_csv"] is True + html = data["html"] + assert 'data-cell="A1"' in html + assert "xlsx-table" in html + + def test_save_cells_and_grow_the_grid(self, client, formats_vault): + (formats_vault / "liste.csv").write_text("a,b\n1,2\n", encoding="utf-8") + resp = client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "liste.csv"}, + json={"cells": {"B1": "modifié", "C3": "nouveau"}}, + ) + assert resp.status_code == 200 + text = _vault_file(client, "liste.csv").read_text(encoding="utf-8") + assert "modifié" in text + assert "nouveau" in text + # Existing rows survive, and the reference beyond the extent grew the + # grid to 3 rows x 3 cols. + assert text.startswith("a,modifié") + assert "1,2" in text + assert text.count("\n") >= 2 + + def test_csv_values_are_stored_verbatim(self, client, formats_vault): + (formats_vault / "formules.csv").write_text("x\n", encoding="utf-8") + client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "formules.csv"}, + json={"cells": {"A1": "=1+1"}}, + ) + text = _vault_file(client, "formules.csv").read_text(encoding="utf-8") + assert "=1+1" in text # no formula engine: stored as text + + def test_quoting_survives_a_round_trip(self, client, formats_vault): + (formats_vault / "quotes.csv").write_text('nom\n"Dupont, Jean"\n', encoding="utf-8") + client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "quotes.csv"}, + json={"cells": {"A2": "Martin, Pierre"}}, + ) + text = _vault_file(client, "quotes.csv").read_text(encoding="utf-8") + assert '"Martin, Pierre"' in text + + def test_bad_reference_is_refused(self, client, formats_vault): + (formats_vault / "liste.csv").write_text("a\n", encoding="utf-8") + resp = client.put( + f"/api/file/{VAULT}/csv/save", + params={"path": "liste.csv"}, + json={"cells": {"XX": "v"}}, # missing row number + ) + assert resp.status_code == 400 + + +def _vault_file(client, rel: str) -> Path: + """Resolve a vault file path for on-disk assertions.""" + root = None + for route in client.app.routes: + pass + # The service layer exposes the vault root; use it directly. + from backend.services.vaults import get_vault_root + + root = get_vault_root(VAULT) + return root / rel