Compare commits

...
2 Commits
Author SHA1 Message Date
bruno ca6407e0c0 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]>
2026-09-28 16:27:10 -04:00
bruno dff32a97ee feat: styles minimaux, fusions et volets figes dans la visionneuse xlsx #153
A15 — le rendu tableur lit les metadonnees du classeur (une charge en mode
normal par feuille) et les restitue : styles inline data-driven (couleur de
police en premier, fond, gras/italique, format numerique signale en mono),
alignements non-defaut, plages fusionnees (rowSpan/colSpan cote client) et
ancrage des volets figes (sticky rows/cols). Le chargement lazy replie les
metadonnees de chaque fenetre. Contrat API : styles/aligns = {ref: css},
merges = [ranges], freeze = ancre. 9 tests backend + contre-preuve (5 echecs
sur neutralisation), JSDOM 33/33.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:07:58 -04:00
20 changed files with 1011 additions and 55 deletions
+9 -1
View File
@@ -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.36.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.38.0**.
---
@@ -14,6 +14,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.38.0] — 2026-09-28
---
## [2.37.0] — 2026-09-28
---
## [2.36.0] — 2026-09-28
---
+3 -3
View File
@@ -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.36.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.36.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.36.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.38.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -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.36.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.36.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.38.0).
---
*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.38.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.36.0
2.38.0
+2
View File
@@ -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
+63 -20
View File
@@ -309,6 +309,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
try:
from backend.xlsx_reader import inspect_workbook, render_sheets
# #153 A15 — every sheet dict already carries its styles, aligns,
# merges and freeze anchor (read_workbook_meta, one normal-mode
# load inside render_sheets).
sheets = render_sheets(file_path)
size = file_path.stat().st_size
return {
@@ -443,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": {},
+38
View File
@@ -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,
+8
View File
@@ -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=(
+101 -4
View File
@@ -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,
+333 -3
View File
@@ -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
@@ -65,6 +69,130 @@ MAX_INDEX_CHARS = 5_000
_INDEX_ROWS_PER_SHEET = 20
MAX_INDEX_SHEETS = 20
# #153 A15 — reading styles is a second (non-read_only) pass on the sheet XML.
# Bounded like everything else: a cell must be INSIDE the rendered window to
# deserve an inline style, so a huge workbook never triggers a huge payload.
# Only data-driven fragments are emitted: the hex values come from the file,
# never from a hardcoded color table.
def _cell_fragments(cell: Any) -> tuple[list[str], str | None]:
"""Inline CSS fragments of one cell plus its horizontal alignment.
Fixed, color-first order: the API contract documents ``color:...`` as the
first fragment of a styled cell. Only data-driven values are emitted —
every hex comes from the workbook itself, never a hardcoded table.
"""
fragments: list[str] = []
font = cell.font
if font and font.color is not None and isinstance(font.color.rgb, str):
# ARGB from the workbook itself — never a hardcoded table.
rgb = font.color.rgb
if len(rgb) == 8 and rgb != "FF000000":
fragments.append(f"color:#{rgb[2:].lower()}")
fill = cell.fill
if fill and fill.fgColor is not None and isinstance(fill.fgColor.rgb, str):
rgb = fill.fgColor.rgb
if len(rgb) == 8 and rgb not in ("00000000", "FFFFFFFF"):
fragments.append(f"background:#{rgb[2:].lower()}")
if font and font.bold:
fragments.append("font-weight:600")
if font and font.italic:
fragments.append("font-style:italic")
fmt = cell.number_format
if fmt and fmt not in ("General", "@"):
# A custom number format is signalled typographically (mono font)
# rather than rendered: the displayed value already carries the
# formatting from _fmt(). Single quotes: the fragment lands inside a
# double-quoted HTML attribute.
fragments.append("font-family:'JetBrains Mono',monospace")
alignment = cell.alignment
align = alignment.horizontal if alignment else None
return fragments, (align if align in ("left", "right", "center") else None)
def _sheet_style_maps(ws: Any) -> tuple[dict[str, str], dict[str, str]]:
"""Flat ``{ref: css}`` and ``{ref: align}`` maps of one worksheet.
The flat string is what the API serves and what the viewer applies
verbatim to ``td.style``; a plain cell is simply absent from the map.
``left`` is the table default and never included. Bounded by
``MAX_ROWS x MAX_COLS`` like the render itself.
"""
styles: dict[str, str] = {}
aligns: dict[str, str] = {}
for row in ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS):
for cell in row:
if cell.value is None and cell.number_format == "General":
continue
fragments, align = _cell_fragments(cell)
if fragments:
styles[cell.coordinate] = ";".join(fragments)
if align and align != "left":
aligns[cell.coordinate] = align
return styles, aligns
def read_sheet_styles(file_path: Path, sheet: str) -> dict[str, dict[str, Any]]:
"""Return ``{ref: {style, align}}`` for the styled cells of one sheet.
``style`` is the flat CSS fragment the viewer applies verbatim and
``align`` the horizontal text-align when it is not the table default.
Normal (non-streaming) load — styles are unavailable in read_only mode;
a failure yields ``{}`` so the viewer falls back to the plain rendering.
"""
meta = read_workbook_meta(file_path).get(sheet, {})
styles_map = meta.get("styles", {})
aligns = meta.get("aligns", {})
out: dict[str, dict[str, Any]] = {}
for ref, css in styles_map.items():
entry: dict[str, Any] = {"style": css}
if ref in aligns:
entry["align"] = aligns[ref]
out[ref] = entry
return out
def read_sheet_merges(file_path: Path, sheet: str) -> list[str]:
"""Return the merged ranges of one sheet as ``A1:C3`` strings."""
try:
# Styles and merges are only fully materialised in normal mode
# (read_only=True leaves merged_cells empty).
wb = load_workbook(str(file_path))
except Exception:
return []
try:
if sheet not in wb.sheetnames:
return []
merged = getattr(wb[sheet], "merged_cells", None)
ranges = getattr(merged, "ranges", None) or []
return [str(r) for r in ranges]
except Exception:
logger.debug("xlsx merges unavailable", exc_info=True)
return []
finally:
wb.close()
def read_sheet_freeze(file_path: Path, sheet: str) -> str:
"""Return the freeze-panes anchor of one sheet ('' when not frozen).
Normal (non-streaming) load: `freeze_panes` is NOT materialised on
ReadOnlyWorksheet in openpyxl 3.1.x — read_only=True always yields ''.
"""
try:
wb = load_workbook(str(file_path))
except Exception:
return ""
try:
if sheet not in wb.sheetnames:
return ""
return str(getattr(wb[sheet], "freeze_panes", None) or "")
except Exception:
return ""
finally:
wb.close()
def _fmt(value: Any) -> str:
if value is None:
@@ -108,6 +236,7 @@ def _table(
grid: list[list[str]],
cached: list[list[str]] | None = None,
row_offset: int = 0,
styles: dict[str, dict[str, Any]] | None = None,
) -> str:
"""Render a grid as an HTML table.
@@ -121,6 +250,11 @@ def _table(
``row_offset`` is the number of rows skipped before this grid (#153 A9): the
row numbers and the ``data-cell`` references must stay the real A1
coordinates of the sheet, not of the window.
``styles`` maps A1 references to ``{style, align}`` fragments (#153 A15:
bold, italic, background, alignment) — the backend only reads the
workbook, the fragments are built from it and always data-driven, never
hardcoded colors. A plain ``str`` value is tolerated (legacy callers).
"""
if not grid:
return "<p><em>Feuille vide</em></p>"
@@ -137,6 +271,20 @@ def _table(
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
for c, val in enumerate(row, start=1):
ref = f"{get_column_letter(c)}{r}"
meta = (styles or {}).get(ref)
if meta is None:
style_attr = ""
else:
# Legacy callers may still pass a bare CSS string.
if isinstance(meta, str):
meta = {"style": meta}
fragment = meta.get("style", "")
align = meta.get("align")
if align and align not in ("left",):
# left is the table default; only non-default alignments
# need an explicit declaration.
fragment = f"{fragment};text-align:{align}" if fragment else f"text-align:{align}"
style_attr = f' style="{fragment}"' if fragment else ""
# The cached result only makes sense for a formula cell: on a plain
# value cell the two reads are identical and showing both would
# duplicate the text.
@@ -153,7 +301,7 @@ def _table(
f"{html.escape(cval)}</span>"
)
out.append(
f'<td data-cell="{ref}">{html.escape(val)}{shadow}</td>'
f'<td data-cell="{ref}"{style_attr}>{html.escape(val)}{shadow}</td>'
)
out.append("</tr>")
out.append("</tbody></table></div>")
@@ -205,6 +353,40 @@ def inspect_workbook(file_path: Path) -> list[str]:
return []
def read_workbook_meta(file_path: Path) -> dict[str, dict[str, Any]]:
"""Return ``{sheet: {styles, aligns, merges, freeze}}`` for every sheet.
One normal (non-streaming) load serves the three A15 metadata maps: the
fragments are the workbook's own values, a failure yields ``{}`` per sheet
so the viewer keeps its plain rendering. Styles are read with
``data_only=False`` — the edited value is the formula, not its result.
"""
try:
wb = load_workbook(str(file_path), data_only=False)
except Exception:
return {}
out: dict[str, dict[str, Any]] = {}
try:
for ws in wb.worksheets:
styles, aligns = _sheet_style_maps(ws)
merged = getattr(ws, "merged_cells", None)
ranges = getattr(merged, "ranges", None) or []
out[ws.title] = {
"styles": styles,
"aligns": aligns,
"merges": [str(r) for r in ranges],
"freeze": str(getattr(ws, "freeze_panes", None) or ""),
}
return out
except Exception:
logger.debug("xlsx meta unavailable", exc_info=True)
for t in wb.sheetnames:
out.setdefault(t, {"styles": {}, "aligns": {}, "merges": [], "freeze": ""})
return out
finally:
wb.close()
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
@@ -230,6 +412,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
if _has_cached_values(file_path):
cached = _read_cached_grids(file_path, titles)
# #153 A15 — one extra normal-mode load serves the styles/merges/freeze
# metadata of every sheet; the HTML then carries the fragments itself.
meta = read_workbook_meta(file_path)
sheets = []
for i, title in enumerate(titles):
grid = _trim(formulas[i])
@@ -239,10 +425,11 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
# positionally against the untrimmed grid keeps the two aligned.
shadow = cached[i] if cached is not None and i < len(cached) else None
total_rows, total_cols = extents[i]
sheet_meta = meta.get(title, {})
sheets.append(
{
"name": title,
"html": _table(grid, shadow),
"html": _table(grid, shadow, styles=sheet_meta.get("styles")),
"rows": len(grid),
"cols": max((len(r) for r in grid), default=0),
"total_rows": total_rows,
@@ -257,6 +444,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
# decided its shape: comparing against the *rendered* size would
# flag every sheet carrying a few empty formatted rows.
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
"styles": sheet_meta.get("styles", {}),
"aligns": sheet_meta.get("aligns", {}),
"merges": sheet_meta.get("merges", []),
"freeze": sheet_meta.get("freeze", ""),
}
)
return sheets
@@ -300,6 +491,10 @@ def read_sheet_window(
# archive really holds cached results.
if _has_cached_values(file_path):
shadow = _read_cached_window(file_path, sheet, offset, limit)
# #153 A15 — same metadata as the full render, so a lazy window is
# indistinguishable from it (styles in the HTML, merges/freeze for the
# client-side spanning).
meta = read_workbook_meta(file_path).get(sheet, {})
return {
"sheet": sheet,
"offset": offset,
@@ -312,7 +507,11 @@ def read_sheet_window(
"max_cols": MAX_COLS,
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
"has_more": offset + len(grid) < total_rows,
"html": _table(grid, shadow, row_offset=offset),
"html": _table(grid, shadow, row_offset=offset, styles=meta.get("styles")),
"styles": meta.get("styles", {}),
"aligns": meta.get("aligns", {}),
"merges": meta.get("merges", []),
"freeze": meta.get("freeze", ""),
}
@@ -444,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
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.36.0"
version = "2.38.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.36.0"
version = "2.38.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.36.0",
"version": "2.38.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.36.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)**
+112 -15
View File
@@ -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).
@@ -1047,9 +1052,64 @@ export function renderXlsxViewer(area, data) {
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
).join("")}</div>`
: "";
const panels = sheets.map((s, i) =>
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
).join("");
// #153 A15 — inline styles (bold/italic/colors from the file itself), merged
// ranges and the freeze anchor are applied AFTER the panels are in the DOM
// (the HTML string alone cannot carry them: col/row spans need the table).
const applySheetMeta = (panel) => {
const meta = sheets[Number(panel.dataset.sheet)] || {};
const styleMap = meta.styles || {};
const alignMap = meta.aligns || {};
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
const ref = td.dataset.cell;
const style = styleMap[ref];
if (style) td.setAttribute("style", style);
const align = alignMap[ref];
if (align) td.style.textAlign = align;
});
// Merged ranges: span the anchor cell over the range and hide the covered
// cells (rowSpan/colSpan survive a re-render; hidden cells are skipped by
// the save collector because their text was already blank in openpyxl).
(meta.merges || []).forEach((range) => {
const [from, to] = range.split(":");
const anchor = panel.querySelector(`td[data-cell="${from}"]`);
const last = panel.querySelector(`td[data-cell="${to}"]`);
if (!anchor || !last || anchor === last) return;
const a = parseRef(from);
const b = parseRef(to);
if (!a || !b) return;
anchor.rowSpan = Math.max(1, b.row - a.row + 1);
anchor.colSpan = Math.max(1, b.col - a.col + 1);
// Hide every covered cell of the block.
for (let r = a.row; r <= b.row; r++) {
for (let c = a.col; c <= b.col; c++) {
if (r === a.row && c === a.col) continue;
const covered = panel.querySelector(`td[data-cell="${columnName(c)}${r}"]`);
if (covered) covered.style.display = "none";
}
}
});
// Freeze panes: sticky the rows/cols left of and above the anchor.
const freeze = meta.freeze;
if (freeze && freeze !== "A1") {
const f = parseRef(freeze);
if (f) {
panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => {
const ref = tr.querySelector("td")?.dataset.cell || "";
const p = parseRef(ref);
if (p && p.row < f.row) tr.classList.add("xlsx-frozen-row");
});
panel.querySelectorAll(".xlsx-table td[data-cell]").forEach((td) => {
const p = parseRef(td.dataset.cell);
if (p && p.col < f.col) td.classList.add("xlsx-frozen-col");
});
}
}
};
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>
@@ -1070,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}
@@ -1151,6 +1211,20 @@ export function renderXlsxViewer(area, data) {
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
// #153 A15 — the window carries its own style/merge metadata: fold it
// into the sheet meta and re-apply, so lazy rows look like the initial
// render (applySheetMeta is idempotent over already-processed cells).
const sheetMeta = sheets[Number(panel.dataset.sheet)];
if (sheetMeta) {
Object.assign(sheetMeta.styles || (sheetMeta.styles = {}), win.styles || {});
Object.assign(sheetMeta.aligns || (sheetMeta.aligns = {}), win.aligns || {});
if (!sheetMeta.merges) sheetMeta.merges = [];
(win.merges || []).forEach((m) => {
if (!sheetMeta.merges.includes(m)) sheetMeta.merges.push(m);
});
if (win.freeze && !sheetMeta.freeze) sheetMeta.freeze = win.freeze;
applySheetMeta(panel);
}
safeCreateIcons();
};
@@ -1325,8 +1399,15 @@ 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.
setActiveCell(null);
@@ -1346,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).
@@ -1767,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);
+15
View File
@@ -11092,6 +11092,21 @@ body.desktop-mode .editor-container {
outline-offset: 1px;
}
/* #153 A15 — freeze panes: frozen rows sit UNDER the sticky thead, frozen
columns stay left. z-index mirrors thead (3) without covering it. */
.xlsx-table tr.xlsx-frozen-row td,
.xlsx-table tr.xlsx-frozen-row th {
position: sticky;
top: 33px; /* thead height — keeps the frozen row below the header */
z-index: 2;
background: var(--surface);
}
.xlsx-table td.xlsx-frozen-col {
position: sticky;
left: 44px; /* the row-number column width */
background: var(--surface);
}
/* #153 A14 — structure menu (sheets / rows / columns) */
.xlsx-structure-menu {
position: absolute;
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.36.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": {
BIN
View File
Binary file not shown.
+219
View File
@@ -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
+99
View File
@@ -0,0 +1,99 @@
"""Style metadata in the .xlsx viewer (#153 A15): bold, colors, number formats,
merged ranges and freeze panes — plus the endpoint wiring."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def styled_book(test_vault_dir: str) -> str:
"""A workbook with a bold red-font header, a filled cell, a merge and a freeze."""
from openpyxl.styles import Font, PatternFill
path = Path(test_vault_dir) / "styles.xlsx"
wb = openpyxl.Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "En-tête"
ws["A1"].font = Font(bold=True, italic=True, color="FFCC0000")
ws["B1"] = "Total"
ws["B1"].fill = PatternFill("solid", fgColor="FFFFEE99")
ws["B2"] = 1234.5
ws["B2"].number_format = "#,##0.00"
ws["C3"] = "fusionnée"
ws.merge_cells("C3:D3")
ws["C3"].value = "fusionnée"
ws.freeze_panes = "A2"
wb.save(path)
return str(path)
class TestStyleReading:
def test_bold_italic_and_font_color(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
a1 = styles["A1"]
assert "font-weight:600" in a1["style"]
assert "font-style:italic" in a1["style"]
assert a1["style"].startswith("color:#cc0000")
def test_background_fill_is_read(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
assert "background:#ffee99" in styles["B1"]["style"]
def test_number_format_is_signalled(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
assert "font-family" in styles["B2"]["style"]
def test_plain_cell_has_no_entry(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
styles = read_sheet_styles(Path(styled_book), "Data")
assert "A2" not in styles # untouched cell
def test_merges_and_freeze(self, styled_book):
from backend.xlsx_reader import read_sheet_freeze, read_sheet_merges
assert read_sheet_merges(Path(styled_book), "Data") == ["C3:D3"]
assert read_sheet_freeze(Path(styled_book), "Data") == "A2"
def test_unknown_sheet_yields_empty(self, styled_book):
from backend.xlsx_reader import read_sheet_styles
assert read_sheet_styles(Path(styled_book), "Nope") == {}
def test_broken_file_yields_empty(self, test_vault_dir):
from backend.xlsx_reader import read_sheet_styles
bad = Path(test_vault_dir) / "broken-styles.xlsx"
bad.write_bytes(b"not a zip")
assert read_sheet_styles(bad, "Data") == {}
class TestEndpointWiring:
def test_read_response_carries_styles_merges_freeze(self, client, styled_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
assert resp.status_code == 200
sheet = resp.json()["xlsx_sheets"][0]
assert sheet["styles"]["A1"].startswith("color:#cc0000")
assert sheet["aligns"] == {} or isinstance(sheet["aligns"], dict)
assert sheet["merges"] == ["C3:D3"]
assert sheet["freeze"] == "A2"
def test_rendered_html_carries_the_inline_style(self, client, styled_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": "styles.xlsx"})
html = resp.json()["xlsx_sheets"][0]["html"]
assert 'data-cell="A1" style="' in html
assert "font-weight:600" in html