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]>
This commit is contained in:
+5
-1
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
|
|||||||
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
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
|
> **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.37.0**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## [2.37.0] — 2026-09-28
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.36.0] — 2026-09-28
|
## [2.36.0] — 2026-09-28
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-3
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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.37.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Projet : ObsiGate | Version : 2.36.0 | Dernière mise à jour : Septembre 2026*
|
*Projet : ObsiGate | Version : 2.37.0 | Dernière mise à jour : Septembre 2026*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
|
||||||
|
|
||||||
[]()
|
[]()
|
||||||
[](https://opensource.org/licenses/MIT)
|
[](https://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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.37.0).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
|
*Project: ObsiGate | Version: 2.37.0 | Last updated: September 2026*
|
||||||
|
|||||||
@@ -309,6 +309,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
|||||||
try:
|
try:
|
||||||
from backend.xlsx_reader import inspect_workbook, render_sheets
|
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)
|
sheets = render_sheets(file_path)
|
||||||
size = file_path.stat().st_size
|
size = file_path.stat().st_size
|
||||||
return {
|
return {
|
||||||
|
|||||||
+198
-3
@@ -65,6 +65,130 @@ MAX_INDEX_CHARS = 5_000
|
|||||||
_INDEX_ROWS_PER_SHEET = 20
|
_INDEX_ROWS_PER_SHEET = 20
|
||||||
MAX_INDEX_SHEETS = 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:
|
def _fmt(value: Any) -> str:
|
||||||
if value is None:
|
if value is None:
|
||||||
@@ -108,6 +232,7 @@ def _table(
|
|||||||
grid: list[list[str]],
|
grid: list[list[str]],
|
||||||
cached: list[list[str]] | None = None,
|
cached: list[list[str]] | None = None,
|
||||||
row_offset: int = 0,
|
row_offset: int = 0,
|
||||||
|
styles: dict[str, dict[str, Any]] | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Render a grid as an HTML table.
|
"""Render a grid as an HTML table.
|
||||||
|
|
||||||
@@ -121,6 +246,11 @@ def _table(
|
|||||||
``row_offset`` is the number of rows skipped before this grid (#153 A9): the
|
``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
|
row numbers and the ``data-cell`` references must stay the real A1
|
||||||
coordinates of the sheet, not of the window.
|
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:
|
if not grid:
|
||||||
return "<p><em>Feuille vide</em></p>"
|
return "<p><em>Feuille vide</em></p>"
|
||||||
@@ -137,6 +267,20 @@ def _table(
|
|||||||
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
out.append(f'<tr><th class="xlsx-rownum">{r}</th>')
|
||||||
for c, val in enumerate(row, start=1):
|
for c, val in enumerate(row, start=1):
|
||||||
ref = f"{get_column_letter(c)}{r}"
|
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
|
# The cached result only makes sense for a formula cell: on a plain
|
||||||
# value cell the two reads are identical and showing both would
|
# value cell the two reads are identical and showing both would
|
||||||
# duplicate the text.
|
# duplicate the text.
|
||||||
@@ -153,7 +297,7 @@ def _table(
|
|||||||
f"{html.escape(cval)}</span>"
|
f"{html.escape(cval)}</span>"
|
||||||
)
|
)
|
||||||
out.append(
|
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("</tr>")
|
||||||
out.append("</tbody></table></div>")
|
out.append("</tbody></table></div>")
|
||||||
@@ -205,6 +349,40 @@ def inspect_workbook(file_path: Path) -> list[str]:
|
|||||||
return []
|
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]]:
|
def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
||||||
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
|
"""Return one dict per sheet: ``{name, html, rows, cols, total_*, truncated}``.
|
||||||
|
|
||||||
@@ -230,6 +408,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
|||||||
if _has_cached_values(file_path):
|
if _has_cached_values(file_path):
|
||||||
cached = _read_cached_grids(file_path, titles)
|
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 = []
|
sheets = []
|
||||||
for i, title in enumerate(titles):
|
for i, title in enumerate(titles):
|
||||||
grid = _trim(formulas[i])
|
grid = _trim(formulas[i])
|
||||||
@@ -239,10 +421,11 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
|||||||
# positionally against the untrimmed grid keeps the two aligned.
|
# positionally against the untrimmed grid keeps the two aligned.
|
||||||
shadow = cached[i] if cached is not None and i < len(cached) else None
|
shadow = cached[i] if cached is not None and i < len(cached) else None
|
||||||
total_rows, total_cols = extents[i]
|
total_rows, total_cols = extents[i]
|
||||||
|
sheet_meta = meta.get(title, {})
|
||||||
sheets.append(
|
sheets.append(
|
||||||
{
|
{
|
||||||
"name": title,
|
"name": title,
|
||||||
"html": _table(grid, shadow),
|
"html": _table(grid, shadow, styles=sheet_meta.get("styles")),
|
||||||
"rows": len(grid),
|
"rows": len(grid),
|
||||||
"cols": max((len(r) for r in grid), default=0),
|
"cols": max((len(r) for r in grid), default=0),
|
||||||
"total_rows": total_rows,
|
"total_rows": total_rows,
|
||||||
@@ -257,6 +440,10 @@ def render_sheets(file_path: Path) -> list[dict[str, Any]]:
|
|||||||
# decided its shape: comparing against the *rendered* size would
|
# decided its shape: comparing against the *rendered* size would
|
||||||
# flag every sheet carrying a few empty formatted rows.
|
# flag every sheet carrying a few empty formatted rows.
|
||||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
"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
|
return sheets
|
||||||
@@ -300,6 +487,10 @@ def read_sheet_window(
|
|||||||
# archive really holds cached results.
|
# archive really holds cached results.
|
||||||
if _has_cached_values(file_path):
|
if _has_cached_values(file_path):
|
||||||
shadow = _read_cached_window(file_path, sheet, offset, limit)
|
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 {
|
return {
|
||||||
"sheet": sheet,
|
"sheet": sheet,
|
||||||
"offset": offset,
|
"offset": offset,
|
||||||
@@ -312,7 +503,11 @@ def read_sheet_window(
|
|||||||
"max_cols": MAX_COLS,
|
"max_cols": MAX_COLS,
|
||||||
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
"truncated": total_rows > MAX_ROWS or total_cols > MAX_COLS,
|
||||||
"has_more": offset + len(grid) < total_rows,
|
"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", ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.36.0"
|
version = "2.37.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.36.0"
|
version = "2.37.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.36.0",
|
"version": "2.37.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# ObsiGate — Roadmap
|
||||||
|
|
||||||
> **Version :** 2.36.0 | **Dernière mise à jour :** 2026-09-28
|
> **Version :** 2.37.0 | **Dernière mise à jour :** 2026-09-28
|
||||||
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
|
|||||||
@@ -1047,6 +1047,59 @@ export function renderXlsxViewer(area, data) {
|
|||||||
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
`<button class="xlsx-tab${i === 0 ? " active" : ""}" data-sheet="${i}">${escapeHtml(s.name)}</button>`
|
||||||
).join("")}</div>`
|
).join("")}</div>`
|
||||||
: "";
|
: "";
|
||||||
|
// #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 = sheets.map((s, i) =>
|
const panels = sheets.map((s, i) =>
|
||||||
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
`<div class="xlsx-panel" data-sheet="${i}"${i === 0 ? "" : ' style="display:none"'}>${truncationNote(s)}${s.html}</div>`
|
||||||
).join("");
|
).join("");
|
||||||
@@ -1151,6 +1204,20 @@ export function renderXlsxViewer(area, data) {
|
|||||||
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1"));
|
||||||
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]");
|
||||||
cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); });
|
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();
|
safeCreateIcons();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1327,6 +1394,7 @@ export function renderXlsxViewer(area, data) {
|
|||||||
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
|
// Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
|
||||||
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
|
||||||
panelEls.forEach(wireLazyRows);
|
panelEls.forEach(wireLazyRows);
|
||||||
|
panelEls.forEach(applySheetMeta);
|
||||||
// The formula bar starts disabled: nothing is selected yet.
|
// The formula bar starts disabled: nothing is selected yet.
|
||||||
setActiveCell(null);
|
setActiveCell(null);
|
||||||
|
|
||||||
|
|||||||
@@ -11092,6 +11092,21 @@ body.desktop-mode .editor-container {
|
|||||||
outline-offset: 1px;
|
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) */
|
/* #153 A14 — structure menu (sheets / rows / columns) */
|
||||||
.xlsx-structure-menu {
|
.xlsx-structure-menu {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"name": "obsigate",
|
||||||
"version": "2.36.0",
|
"version": "2.37.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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user