Compare commits

..
2 Commits
Author SHA1 Message Date
bruno c4b8e66206 feat: tableau de bord classeur - plages nommees, TCD/graphiques et KPI par feuille #153
A17 — nouveau endpoint GET /api/file/{vault}/xlsx/dashboard (read_workbook_
dashboard : plages nommees avec portee depuis defined_names read_only,
comptage graphiques/TCD par parts OPC, stats par feuille bornées 500x40 :
cellules/lignes/colonnes/formules/numerique + 8 premieres valeurs en cartes
KPI) et panneau frontend toggled depuis la toolbar (table des plages,
cartes KPI par feuille, hint actions IA). Bouton absent pour .csv et
formats en lecture seule ; le menu structure est saute quand le bouton
n'existe pas. i18n FR/EN (xlsx.dashboard_*), 8 tests backend + 2 tests
JSDOM + contre-preuve (5 echecs sur neutralisation), ruff/mypy 0.

🤖 Generated with Codebuff
Co-Authored-By: Codebuff <[email protected]>
2026-09-28 16:40:09 -04:00
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
23 changed files with 1173 additions and 54 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.37.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.39.0**.
---
@@ -14,6 +14,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.39.0] — 2026-09-28
---
## [2.38.0] — 2026-09-28
---
## [2.37.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.37.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.39.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.37.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.39.0).
---
*Projet : ObsiGate | Version : 2.37.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.39.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.37.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.39.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.37.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.39.0).
---
*Project: ObsiGate | Version: 2.37.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.39.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.37.0
2.39.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
+103 -21
View File
@@ -38,11 +38,12 @@ from backend.schemas import (
BrowseResponse,
FileContentResponse,
FileRawResponse,
XlsxDashboardResponse,
XlsxSheetWindowResponse,
)
from backend.services.files import read_raw_file
from backend.services.paths import resolve_safe_path
from backend.services.vaults import browse_directory
from backend.services.vaults import browse_directory, get_vault_root
logger = logging.getLogger("obsigate")
@@ -179,6 +180,47 @@ async def api_file_backlinks(
}
@router.get(
"/api/file/{vault_name}/xlsx/dashboard", response_model=XlsxDashboardResponse
)
def api_file_xlsx_dashboard(
vault_name: str,
path: str = Query(..., description="Relative path to the .xlsx workbook"),
current_user=Depends(require_auth),
):
"""Return the dashboard metadata of an .xlsx workbook (#153 A17).
Named ranges (workbook- or sheet-scoped), chart/pivot object counts and
per-sheet KPI stats (non-empty cells, rows/cols coverage, formulas,
numeric cells, first numeric values as KPI cards). Read-only, bounded by
the 500x40 render caps; never raises for an unreadable workbook — an
empty payload comes back and the viewer hides the panel.
"""
if not check_vault_access(vault_name, current_user):
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
_vault_root = get_vault_root(vault_name)
file_path = resolve_safe_path(_vault_root, path)
if not file_path.is_file():
raise HTTPException(status_code=404, detail=f"File not found: {path}")
if file_path.suffix.lower() not in (".xlsx", ".xlsm"):
raise HTTPException(
status_code=415, detail="Le fichier n'est pas un classeur .xlsx/.xlsm"
)
from backend.xlsx_reader import read_workbook_dashboard
try:
dashboard = read_workbook_dashboard(file_path)
except Exception as e:
logger.error(f"XLSX dashboard read error for {path}: {e}")
raise HTTPException(status_code=500, detail=f"Error reading XLSX: {e!s}")
return {
"vault": vault_name,
"path": path,
**dashboard,
}
@router.get(
"/api/file/{vault_name}/xlsx/sheet", response_model=XlsxSheetWindowResponse
)
@@ -446,27 +488,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,
+45
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=(
@@ -310,6 +318,43 @@ class FileContentResponse(BaseModel):
image_mime: str | None = Field(default=None, description="MIME type for image files")
class XlsxDashboardNamedRange(BaseModel):
"""One named range of a workbook (#153 A17)."""
name: str = Field(description="Range name as declared in the workbook")
scope: str = Field(description="Sheet name when sheet-scoped, empty when workbook-wide")
ref: str = Field(description="Formula-style reference, e.g. Data!$A$1:$B$5")
class XlsxDashboardSheetKpi(BaseModel):
"""One KPI card of a sheet dashboard (#153 A17)."""
label: str = Field(description="A1 reference of the numeric cell")
value: float = Field(description="Numeric value of the cell")
class XlsxDashboardSheet(BaseModel):
"""Per-sheet KPI stats of a workbook dashboard (#153 A17)."""
name: str = Field(description="Sheet name")
cells: int = Field(description="Non-empty cells inside the 500x40 caps")
rows: int = Field(description="Rows carrying at least one non-empty cell")
cols: int = Field(description="Columns carrying at least one non-empty cell")
formulas: int = Field(description="Cells whose value is a formula")
numeric: int = Field(description="Cells carrying a numeric value")
kpi: list[XlsxDashboardSheetKpi] = Field(description="First numeric cells as KPI cards")
class XlsxDashboardResponse(BaseModel):
"""Dashboard metadata of an .xlsx workbook (#153 A17)."""
vault: str = Field(description="Vault name")
path: str = Field(description="Relative file path within the vault")
named_ranges: list[XlsxDashboardNamedRange] = Field(description="Named ranges, sorted by name")
objects: dict[str, int] = Field(description="Object counts: {charts, pivots}")
sheets: list[XlsxDashboardSheet] = Field(description="Per-sheet KPI stats")
class XlsxSheetWindowResponse(BaseModel):
"""One window of rows of a single .xlsx sheet (lazy loading, #153 A9).
+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,
+257
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
@@ -57,6 +61,11 @@ _CACHED_FORMULA_RE = re.compile(rb"<f[ >][^<]*</f>\s*<v>[^<]")
# Sheet XML scanned by the cached-formula probe (CPU guard, like MAX_REPLACE_FILE_BYTES).
_MAX_PROBE_BYTES = 8_000_000
# #153 A17 — OPC parts of chart / pivot objects, matched against the archive
# name list (xl/charts/chart1.xml, xl/pivotTables/pivotTable1.xml, …).
_CHART_PART_RE = re.compile(r"^xl/charts/chart\d+\.xml$")
_PIVOT_PART_RE = re.compile(r"^xl/pivotTables/pivotTable\d+\.xml$")
# #153 A5 — ceiling on the text handed to the TF-IDF / semantic index. A workbook
# is a data dump, not prose: indexing every cell would flood the inverted index
# and bury the notes. Sheet names + the first rows are enough to make a
@@ -639,3 +648,251 @@ def extract_indexable_text(file_path: Path) -> str:
finally:
wb.close()
return "\n".join(c for c in chunks if c).strip()
# ── #153 A17 — dashboard metadata ───────────────────────────────────
def read_workbook_dashboard(file_path: Path) -> dict[str, Any]:
"""Return the dashboard metadata of a workbook (#153 A17).
Shape::
{
"named_ranges": [{"name", "scope", "ref"}],
"objects": {"charts": int, "pivots": int},
"sheets": [{
"name": str,
"cells": int, # non-empty cells inside the caps
"rows": int, # rows carrying at least one non-empty cell
"cols": int, # columns carrying at least one non-empty cell
"formulas": int,
"numeric": int,
"kpi": [ # first 8 numeric cells as {"label", "value"}
{"label": str, "value": float}
],
}],
}
Named ranges come from the streaming load (available read-only), cell
stats from ``iter_rows(values_only=True)``. Charts/pivots are counted by
OPC part names (a chart part per chart, a pivot table part per pivot).
Bounded by MAX_ROWS/MAX_COLS; never raises — a failure yields an empty
payload and the viewer simply hides the panel.
"""
payload: dict[str, Any] = {
"named_ranges": [],
"objects": {"charts": 0, "pivots": 0},
"sheets": [],
}
try:
wb = load_workbook(str(file_path), read_only=True, data_only=False)
except Exception:
return payload
try:
dn = getattr(wb, "defined_names", None)
items: list[tuple[Any, Any]] = (
list(dn.items()) if dn is not None and hasattr(dn, "items") else []
)
for name, defn in items:
scope_idx = getattr(defn, "localSheetId", None)
scope = ""
if scope_idx is not None:
try:
scope = wb.sheetnames[int(scope_idx)]
except (IndexError, ValueError):
scope = ""
payload["named_ranges"].append(
{
"name": str(name),
"scope": scope,
"ref": str(getattr(defn, "attr_text", "") or ""),
}
)
payload["named_ranges"].sort(key=lambda d: d["name"].lower())
for ws in wb.worksheets:
cells = rows = formulas = numeric = 0
col_seen: set[int] = set()
kpi: list[dict[str, Any]] = []
for r, row in enumerate(
ws.iter_rows(min_row=1, max_row=MAX_ROWS, max_col=MAX_COLS, values_only=True),
start=1,
):
row_has_value = False
for c, value in enumerate(row, start=1):
if value is None or (isinstance(value, str) and not value.strip()):
continue
cells += 1
col_seen.add(c)
row_has_value = True
if isinstance(value, str) and value.startswith("="):
formulas += 1
elif isinstance(value, bool):
pass
elif isinstance(value, (int, float)):
numeric += 1
if len(kpi) < 8:
kpi.append(
{"label": f"{get_column_letter(c)}{r}", "value": value}
)
if row_has_value:
rows += 1
payload["sheets"].append(
{
"name": ws.title,
"cells": cells,
"rows": rows,
"cols": len(col_seen),
"formulas": formulas,
"numeric": numeric,
"kpi": kpi,
}
)
# Chart/pivot parts, counted from the archive (chart XML parts are
# one per chart; pivot parts one per pivot table/cache).
with zipfile.ZipFile(file_path) as zf:
names = zf.namelist()
payload["objects"]["charts"] = sum(1 for n in names if _CHART_PART_RE.match(n))
payload["objects"]["pivots"] = sum(1 for n in names if _PIVOT_PART_RE.match(n))
return payload
except Exception:
logger.debug("xlsx dashboard unavailable", exc_info=True)
return {
"named_ranges": [],
"objects": {"charts": 0, "pivots": 0},
"sheets": [],
}
finally:
wb.close()
# ── #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.37.0"
version = "2.39.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.37.0"
version = "2.39.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.37.0",
"version": "2.39.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+1 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.37.0 | **Dernière mise à jour :** 2026-09-28
> **Version :** 2.39.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)**
+110 -16
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).
@@ -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}
@@ -1156,6 +1163,68 @@ export function renderXlsxViewer(area, data) {
<div class="xlsx-panels">${panels}</div>
</div>`;
// ── #153 A17 — dashboard panel: named ranges, objects, per-sheet KPI ──
// Fetched lazily on first open of the dashboard tab; a workbook without
// anything notable hides the button entirely (no empty panel).
if (!isCsv && !readOnly) {
const dashBtn = el("button", {
class: "btn-action xlsx-dash-btn",
id: "xlsx-dashboard-btn",
type: "button",
title: t("xlsx.dashboard_btn"),
}, [icon("layout-dashboard", 14), document.createTextNode(t("xlsx.dashboard_btn"))]);
area.querySelector(".xlsx-toolbar-actions").prepend(dashBtn);
dashBtn.addEventListener("click", async () => {
const old = area.querySelector(".xlsx-dashboard");
if (old) { old.remove(); dashBtn.classList.remove("active"); return; }
dashBtn.classList.add("active");
const panel = document.createElement("div");
panel.className = "xlsx-dashboard";
panel.innerHTML = `<div class="xlsx-dashboard-loading">…</div>`;
area.querySelector(".xlsx-panels").prepend(panel);
try {
const dash = await api(
`/api/file/${encodeURIComponent(data.vault)}/xlsx/dashboard?path=${encodeURIComponent(data.path)}`,
);
const rangeRows = (dash.named_ranges || []).map((r) =>
`<tr><td><code>${escapeHtml(r.name)}</code></td><td>${escapeHtml(r.scope || "—")}</td><td><code>${escapeHtml(r.ref)}</code></td></tr>`,
).join("");
const kpiCards = (dash.sheets || []).map((s) => {
const cards = (s.kpi || []).map((k) =>
`<span class="xlsx-kpi"><span class="xlsx-kpi-label">${escapeHtml(k.label)}</span><span class="xlsx-kpi-value">${escapeHtml(String(k.value))}</span></span>`,
).join("");
return `<div class="xlsx-kpi-sheet">
<h4>${escapeHtml(s.name)}</h4>
<p class="xlsx-kpi-meta">${escapeHtml(t("xlsx.dashboard_stats", {
cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric,
}))}</p>
<div class="xlsx-kpi-cards">${cards || "<span class=\"xlsx-kpi-empty\">—</span>"}</div>
</div>`;
}).join("");
panel.innerHTML = `
<div class="xlsx-dashboard-head">
<h3><i data-lucide="layout-dashboard" style="width:14px;height:14px"></i> ${escapeHtml(t("xlsx.dashboard_title"))}</h3>
<span class="xlsx-dashboard-objects">
${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))}
· ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))}
</span>
</div>
${(dash.named_ranges || []).length || (dash.sheets || []).length ? "" : `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.dashboard_empty"))}</p>`}
${(dash.named_ranges || []).length ? `
<table class="csv-table xlsx-ranges-table"><thead><tr>
<th>${escapeHtml(t("xlsx.dashboard_nr_name"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_scope"))}</th>
<th>${escapeHtml(t("xlsx.dashboard_nr_ref"))}</th>
</tr></thead><tbody>${rangeRows}</tbody></table>` : ""}
<div class="xlsx-kpi-grid">${kpiCards}</div>
<p class="xlsx-dashboard-hint">${escapeHtml(t("xlsx.dashboard_hint"))}</p>`;
safeCreateIcons();
} catch (err) {
panel.innerHTML = `<p class="xlsx-kpi-empty">${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}</p>`;
}
});
}
const saveBtn = area.querySelector("#xlsx-save-btn");
const panelEls = [...area.querySelectorAll(".xlsx-panel")];
const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length;
@@ -1392,7 +1461,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 +1489,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).
@@ -1666,7 +1750,10 @@ export function renderXlsxViewer(area, data) {
};
// Structure menu: built on demand, positioned under the button.
area.querySelector("#xlsx-structure-btn").addEventListener("click", (e) => {
// (.csv / read-only workbooks have no such button — the menu block above
// is skipped for them.)
const structureBtn = area.querySelector("#xlsx-structure-btn");
if (structureBtn) structureBtn.addEventListener("click", (e) => {
const old = area.querySelector(".xlsx-structure-menu");
if (old) { old.remove(); return; }
const idx = visibleSheetIndex();
@@ -1835,6 +1922,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);
+10
View File
@@ -1868,6 +1868,16 @@
"xlsx.structure_saved": "Structure updated",
"xlsx.structure_error": "Could not change the structure",
"xlsx.last_sheet": "The last sheet cannot be deleted",
"xlsx.dashboard_btn": "Dashboard",
"xlsx.dashboard_title": "Workbook dashboard",
"xlsx.dashboard_stats": "{{cells}} cells · {{rows}} rows · {{cols}} columns · {{formulas}} formulas · {{numeric}} numeric values",
"xlsx.dashboard_charts": "{{n}} chart(s)",
"xlsx.dashboard_pivots": "{{n}} pivot tables",
"xlsx.dashboard_empty": "No named range or usable data in this workbook.",
"xlsx.dashboard_nr_name": "Name",
"xlsx.dashboard_nr_scope": "Scope",
"xlsx.dashboard_nr_ref": "Reference",
"xlsx.dashboard_hint": "Select a range or open the AI assistant to analyse this data.",
"xlsx.feature_cached_values": "cached values",
"xlsx.feature_slicers": "slicers and timelines",
"xlsx.feature_form_controls": "form controls",
+10
View File
@@ -1868,6 +1868,16 @@
"xlsx.structure_saved": "Structure mise à jour",
"xlsx.structure_error": "Modification de la structure impossible",
"xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
"xlsx.dashboard_btn": "Tableau de bord",
"xlsx.dashboard_title": "Tableau de bord du classeur",
"xlsx.dashboard_stats": "{{cells}} cellules · {{rows}} lignes · {{cols}} colonnes · {{formulas}} formules · {{numeric}} valeurs numériques",
"xlsx.dashboard_charts": "{{n}} graphique(s)",
"xlsx.dashboard_pivots": "{{n}} TCD",
"xlsx.dashboard_empty": "Aucune plage nommée ni donnée exploitable dans ce classeur.",
"xlsx.dashboard_nr_name": "Nom",
"xlsx.dashboard_nr_scope": "Portée",
"xlsx.dashboard_nr_ref": "Référence",
"xlsx.dashboard_hint": "Sélectionnez une plage ou ouvrez l'assistant IA pour analyser ces données.",
"xlsx.feature_cached_values": "valeurs calculées",
"xlsx.feature_slicers": "segments et chronologies",
"xlsx.feature_form_controls": "contrôles de formulaire",
+91
View File
@@ -11128,6 +11128,97 @@ body.desktop-mode .editor-container {
background: transparent;
color: var(--text-primary);
}
/* #153 A17 — workbook dashboard panel: named ranges table + KPI cards.
Colors come from the existing CSS variables (no hardcoded values). */
.xlsx-dashboard {
margin: 8px 0;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.xlsx-dashboard-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 8px;
}
.xlsx-dashboard-head h3 {
display: flex;
align-items: center;
gap: 6px;
margin: 0;
font-size: 0.95rem;
color: var(--text-primary);
}
.xlsx-dashboard-objects {
font-size: 0.8rem;
color: var(--text-muted);
}
.xlsx-ranges-table {
margin-bottom: 10px;
font-size: 0.82rem;
}
.xlsx-kpi-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 8px;
}
.xlsx-kpi-sheet {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-secondary);
}
.xlsx-kpi-sheet h4 {
margin: 0 0 4px;
font-size: 0.85rem;
color: var(--text-primary);
}
.xlsx-kpi-meta {
margin: 0 0 6px;
font-size: 0.75rem;
color: var(--text-muted);
}
.xlsx-kpi-cards {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.xlsx-kpi {
display: inline-flex;
flex-direction: column;
min-width: 64px;
padding: 4px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--surface);
}
.xlsx-kpi-label {
font-size: 0.7rem;
color: var(--text-muted);
}
.xlsx-kpi-value {
font-family: 'JetBrains Mono', monospace;
font-size: 0.85rem;
color: var(--text-primary);
}
.xlsx-kpi-empty {
color: var(--text-muted);
font-size: 0.8rem;
}
.xlsx-dashboard-hint {
margin: 10px 0 0;
font-size: 0.78rem;
color: var(--text-muted);
}
.xlsx-dashboard-loading {
color: var(--text-muted);
font-size: 0.8rem;
}
.xlsx-structure-item:hover {
background: var(--bg-secondary);
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.37.0",
"version": "2.39.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.
+44
View File
@@ -648,6 +648,50 @@ await test("the 409 lossy flow re-emits with force after confirmation", async ()
assert.equal(calls[1].body.force, true);
});
// ── A17 — dashboard panel ─────────────────────────────────────────────
await test("the dashboard button fetches the metadata and renders named ranges + KPIs", async () => {
const area = mount();
const dashBtn = area.querySelector("#xlsx-dashboard-btn");
assert.ok(dashBtn, "the dashboard button exists for an editable workbook");
apiQueue.push({
ok: true,
status: 200,
body: {
vault: "V",
path: "data.xlsx",
named_ranges: [{ name: "MaPlage", scope: "", ref: "Data!$A$1:$B$5" }],
objects: { charts: 2, pivots: 1 },
sheets: [{ name: "Feuille1", cells: 9, rows: 3, cols: 3, formulas: 1, numeric: 2, kpi: [{ label: "A2", value: 12 }] }],
},
});
dashBtn.click();
await new Promise((r) => setTimeout(r, 5));
assert.match(calls[0].url, /\/xlsx\/dashboard\?path=data\.xlsx/);
const panel = area.querySelector(".xlsx-dashboard");
assert.ok(panel, "the dashboard panel is rendered");
assert.ok(panel.textContent.includes("MaPlage"), "named range is listed");
assert.ok(panel.textContent.includes("A2"), "KPI label is rendered");
assert.ok(panel.textContent.includes("12"), "KPI value is rendered");
// Second click closes the panel.
dashBtn.click();
assert.equal(area.querySelector(".xlsx-dashboard"), null, "panel toggles closed");
});
await test("a csv mounts without the dashboard button", () => {
const area = document.getElementById("content-area");
area.innerHTML = "";
renderXlsxViewer(area, {
vault: "V",
path: "data.csv",
is_csv: true,
xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("100") }],
xlsx_lossy_features: [],
});
assert.equal(area.querySelector("#xlsx-dashboard-btn"), null, "no dashboard for csv");
assert.equal(area.querySelector("#xlsx-structure-btn"), null, "no structure menu for csv");
assert.ok(area.querySelector("td[data-cell=\"A1\"]"), "the grid is still editable");
});
await test("two windows in a row walk the whole sheet", async () => {
const area = mountTruncated({ total: 1200 });
apiQueue.push({
+122
View File
@@ -0,0 +1,122 @@
"""Workbook dashboard: named ranges, chart/pivot objects and per-sheet KPI
stats (#153 A17), plus the ``GET …/xlsx/dashboard`` endpoint wiring."""
from __future__ import annotations
from pathlib import Path
import pytest
openpyxl = pytest.importorskip("openpyxl")
VAULT = "TestVault"
@pytest.fixture
def dash_book(test_vault_dir: str) -> str:
"""A workbook with a named range, a chart and numeric KPI cells."""
from openpyxl import Workbook
from openpyxl.chart import BarChart, Reference
from openpyxl.workbook.defined_name import DefinedName
path = Path(test_vault_dir) / "dash.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Data"
ws["A1"] = "Ventes"
ws["A2"] = 12
ws["A3"] = 48
ws["B2"] = "=SUM(A2:A3)"
wb.defined_names.add(DefinedName("MaPlage", attr_text="Data!$A$1:$B$5"))
chart = BarChart()
chart.add_data(Reference(ws, min_col=1, min_row=1, max_row=3))
ws.add_chart(chart, "D2")
wb.save(path)
return str(path)
@pytest.fixture
def plain_book(test_vault_dir: str) -> str:
"""A workbook without any dashboard-worthy feature."""
from openpyxl import Workbook
path = Path(test_vault_dir) / "plain.xlsx"
wb = Workbook()
ws = wb.active
ws.title = "Vide"
ws["A1"] = "texte seul"
wb.save(path)
return str(path)
class TestDashboardReading:
def test_named_range_is_detected(self, dash_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(dash_book))
names = [r["name"] for r in dash["named_ranges"]]
assert "MaPlage" in names
entry = next(r for r in dash["named_ranges"] if r["name"] == "MaPlage")
assert "Data!" in entry["ref"]
def test_charts_are_counted(self, dash_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(dash_book))
assert dash["objects"]["charts"] >= 1
assert dash["objects"]["pivots"] == 0
def test_sheet_kpis_carry_numeric_cells(self, dash_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(dash_book))
sheet = next(s for s in dash["sheets"] if s["name"] == "Data")
assert sheet["cells"] >= 4
assert sheet["formulas"] == 1
assert sheet["numeric"] >= 2
values = [k["value"] for k in sheet["kpi"]]
assert 12 in values and 48 in values
def test_plain_book_yields_empty_dashboard(self, plain_book):
from backend.xlsx_reader import read_workbook_dashboard
dash = read_workbook_dashboard(Path(plain_book))
assert dash["named_ranges"] == []
assert dash["objects"]["charts"] == 0
sheet = dash["sheets"][0]
assert sheet["numeric"] == 0
assert sheet["kpi"] == []
def test_broken_book_yields_empty_payload(self, test_vault_dir):
from backend.xlsx_reader import read_workbook_dashboard
bad = Path(test_vault_dir) / "broken-dash.xlsx"
bad.write_bytes(b"not a zip")
dash = read_workbook_dashboard(bad)
assert dash["named_ranges"] == []
assert dash["sheets"] == []
class TestDashboardEndpoint:
def test_endpoint_serves_the_dashboard(self, client, dash_book):
resp = client.get(f"/api/file/{VAULT}", params={"path": dash_book})
assert resp.status_code == 200 # sanity: file is readable
dash = client.get(
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": dash_book}
).json()
assert any(r["name"] == "MaPlage" for r in dash["named_ranges"])
assert dash["objects"]["charts"] >= 1
assert any(s["kpi"] for s in dash["sheets"])
def test_unknown_file_is_404(self, client):
resp = client.get(
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "nope.xlsx"}
)
assert resp.status_code == 404
def test_non_workbook_is_415(self, client, test_vault_dir):
(Path(test_vault_dir) / "texte.txt").write_text("hello", encoding="utf-8")
resp = client.get(
f"/api/file/{VAULT}/xlsx/dashboard", params={"path": "texte.txt"}
)
assert resp.status_code == 415
+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