feat: formats tableur additionnels - xlsm editable, xls/ods lecture seule, csv editable #153
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 <[email protected]>
This commit is contained in:
+5
-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).
|
||||
|
||||
> **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
|
||||
|
||||
---
|
||||
|
||||
+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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](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*
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = "<p><em>Fichier CSV vide</em></p>"
|
||||
else:
|
||||
headers = rows[0]
|
||||
data_rows = rows[1:]
|
||||
html = '<div class="csv-table-wrapper"><table class="csv-table"><thead><tr>'
|
||||
for h in headers:
|
||||
html += f"<th>{h}</th>"
|
||||
html += "</tr></thead><tbody>"
|
||||
for row in data_rows:
|
||||
html += "<tr>"
|
||||
for cell in row:
|
||||
html += f"<td>{cell}</td>"
|
||||
html += "</tr>"
|
||||
html += "</tbody></table></div>"
|
||||
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": {},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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=(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 "<p><em>Feuille vide</em></p>"
|
||||
n_cols = max(len(r) for r in rows)
|
||||
out = [
|
||||
('<div class="csv-table-wrapper"><table class="csv-table xlsx-table">'
|
||||
'<thead><tr><th class="xlsx-corner"></th>')
|
||||
]
|
||||
out += [f"<th>{get_column_letter(c)}</th>" for c in range(1, n_cols + 1)]
|
||||
out.append("</tr></thead><tbody>")
|
||||
for r, row in enumerate(rows, start=1):
|
||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||
for c in range(1, n_cols + 1):
|
||||
val = row[c - 1] if c - 1 < len(row) else ""
|
||||
out.append(f'<td data-cell="{get_column_letter(c)}{r}">{html.escape(val)}</td>')
|
||||
out.append("</tr>")
|
||||
out.append("</tbody></table></div>")
|
||||
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": (
|
||||
'<p><em>Feuille vide</em></p>'
|
||||
),
|
||||
"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
|
||||
|
||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.37.0"
|
||||
version = "2.38.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
+1
-1
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
+1
-1
@@ -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)**
|
||||
|
||||
+44
-15
@@ -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) =>
|
||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
||||
).join("");
|
||||
const panels = isCsv
|
||||
? `<div class="xlsx-panel" data-sheet="0">${sheets[0]?.html || data.html || ""}</div>`
|
||||
: sheets.map((s, i) =>
|
||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
||||
).join("");
|
||||
const lossWarning = lossy.length
|
||||
? `<div class="xlsx-warning" role="note">
|
||||
<i data-lucide="alert-triangle" class="xlsx-warning-icon"></i>
|
||||
@@ -1123,16 +1130,16 @@ export function renderXlsxViewer(area, data) {
|
||||
<span class="xlsx-toolbar-actions">
|
||||
<button class="btn-action xlsx-formula-toggle" id="xlsx-formula-btn" type="button"
|
||||
aria-pressed="false" title="${escapeHtml(t("xlsx.formula_toggle_title"))}">f(x)</button>
|
||||
<button class="btn-action" id="xlsx-save-btn" disabled>${t("common.save")}</button>
|
||||
<button class="btn-action" id="xlsx-save-btn" disabled${isCsv || readOnly ? "" : ""}>${t("common.save")}</button>
|
||||
<button class="btn-action" id="xlsx-download-btn">
|
||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||
</button>
|
||||
<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
${isCsv || readOnly ? "" : `<button class="btn-action" id="xlsx-structure-btn" title="${escapeHtml(t("xlsx.structure_btn"))}">
|
||||
<i data-lucide="table-properties" style="width:14px;height:14px"></i>
|
||||
</button>
|
||||
</button>`}
|
||||
</span>
|
||||
</div>
|
||||
${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);
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
Vendored
BIN
Binary file not shown.
@@ -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
|
||||
Reference in New Issue
Block a user