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.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](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.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](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"| {h} | "
- html += "
"
- for row in data_rows:
- html += ""
- for cell in row:
- html += f"| {cell} | "
- html += "
"
- html += "
"
+ 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"{get_column_letter(c)} | " for c in range(1, n_cols + 1)]
+ out.append("
")
+ for r, row in enumerate(rows, start=1):
+ out.append(f'| {r} | ')
+ for c in range(1, n_cols + 1):
+ val = row[c - 1] if c - 1 < len(row) else ""
+ out.append(f'{html.escape(val)} | ')
+ out.append("
")
+ out.append("
")
+ 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) =>
- `${truncationNote(s)}${s.html}
`
- ).join("");
+ const panels = isCsv
+ ? `${sheets[0]?.html || data.html || ""}
`
+ : sheets.map((s, i) =>
+ `${truncationNote(s)}${s.html}
`
+ ).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 0000000..b1c5481
Binary files /dev/null and b/tests/fixtures/sample.xls differ
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