From c4b8e66206ab26219a356f8415b387c7d88c2356 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 28 Sep 2026 16:40:07 -0400 Subject: [PATCH] feat: tableau de bord classeur - plages nommees, TCD/graphiques et KPI par feuille #153 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 6 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- backend/routers/files_read.py | 44 +++++++++- backend/schemas.py | 37 +++++++++ backend/xlsx_reader.py | 122 ++++++++++++++++++++++++++++ desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 2 +- frontend/js/viewer.js | 67 ++++++++++++++- frontend/locales/en.json | 10 +++ frontend/locales/fr.json | 10 +++ frontend/style.css | 91 +++++++++++++++++++++ package.json | 2 +- tests/frontend/xlsx-viewer.test.mjs | 44 ++++++++++ tests/test_xlsx_dashboard.py | 122 ++++++++++++++++++++++++++++ 18 files changed, 562 insertions(+), 15 deletions(-) create mode 100644 tests/test_xlsx_dashboard.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d8dec3..3c76805 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/), et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). > **En cours de développement** : les changements à venir sont listés dans la section -> [Unreleased](#unreleased). La dernière version livrée est **2.38.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.39.0**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.39.0] — 2026-09-28 + +--- + ## [2.38.0] — 2026-09-28 --- diff --git a/README.fr.md b/README.fr.md index bedbe4e..6858e93 100644 --- a/README.fr.md +++ b/README.fr.md @@ -4,7 +4,7 @@ **Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive. -[![Version](https://img.shields.io/badge/Version-2.38.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.38.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.38.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.39.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 3efdb51..2688b3a 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ **Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface. -[![Version](https://img.shields.io/badge/Version-2.38.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.38.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.39.0). --- -*Project: ObsiGate | Version: 2.38.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.39.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index f31d783..cde8adf 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.38.0 +2.39.0 diff --git a/backend/routers/files_read.py b/backend/routers/files_read.py index cfc666b..7fec5e9 100644 --- a/backend/routers/files_read.py +++ b/backend/routers/files_read.py @@ -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 ) diff --git a/backend/schemas.py b/backend/schemas.py index 217506c..86381ea 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -318,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). diff --git a/backend/xlsx_reader.py b/backend/xlsx_reader.py index 11fd4c4..8d86fa5 100644 --- a/backend/xlsx_reader.py +++ b/backend/xlsx_reader.py @@ -61,6 +61,11 @@ _CACHED_FORMULA_RE = re.compile(rb"][^<]*\s*[^<]") # 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 @@ -645,6 +650,123 @@ def extract_indexable_text(file_path: Path) -> str: 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 ─────────────────────────────── diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index bf5d2f7..42b90e2 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.38.0" +version = "2.39.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index dfd3413..991949b 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.38.0" +version = "2.39.0" description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian" authors = ["Bruno Charest"] edition = "2021" diff --git a/desktop/tauri.conf.json b/desktop/tauri.conf.json index 3341be9..e9a48d4 100644 --- a/desktop/tauri.conf.json +++ b/desktop/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json", "productName": "ObsiGate", - "version": "2.38.0", + "version": "2.39.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index c94920c..d2b8084 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.38.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)** diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 9d42f6c..98b61db 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1163,6 +1163,68 @@ export function renderXlsxViewer(area, data) {
${panels}
`; + // ── #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 = `
…
`; + 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) => + `${escapeHtml(r.name)}${escapeHtml(r.scope || "—")}${escapeHtml(r.ref)}`, + ).join(""); + const kpiCards = (dash.sheets || []).map((s) => { + const cards = (s.kpi || []).map((k) => + `${escapeHtml(k.label)}${escapeHtml(String(k.value))}`, + ).join(""); + return `
+

${escapeHtml(s.name)}

+

${escapeHtml(t("xlsx.dashboard_stats", { + cells: s.cells, rows: s.rows, cols: s.cols, formulas: s.formulas, numeric: s.numeric, + }))}

+
${cards || "—"}
+
`; + }).join(""); + panel.innerHTML = ` +
+

${escapeHtml(t("xlsx.dashboard_title"))}

+ + ${escapeHtml(t("xlsx.dashboard_charts", { n: dash.objects?.charts ?? 0 }))} + · ${escapeHtml(t("xlsx.dashboard_pivots", { n: dash.objects?.pivots ?? 0 }))} + +
+ ${(dash.named_ranges || []).length || (dash.sheets || []).length ? "" : `

${escapeHtml(t("xlsx.dashboard_empty"))}

`} + ${(dash.named_ranges || []).length ? ` + + + + + ${rangeRows}
${escapeHtml(t("xlsx.dashboard_nr_name"))}${escapeHtml(t("xlsx.dashboard_nr_scope"))}${escapeHtml(t("xlsx.dashboard_nr_ref"))}
` : ""} +
${kpiCards}
+

${escapeHtml(t("xlsx.dashboard_hint"))}

`; + safeCreateIcons(); + } catch (err) { + panel.innerHTML = `

${escapeHtml(t("xlsx.load_error"))}: ${escapeHtml(String(err.message || err))}

`; + } + }); + } + const saveBtn = area.querySelector("#xlsx-save-btn"); const panelEls = [...area.querySelectorAll(".xlsx-panel")]; const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; @@ -1688,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(); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 25d8814..82bf57f 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -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", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index 7b49203..4009b8d 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -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", diff --git a/frontend/style.css b/frontend/style.css index 0da30b3..7ec4b74 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -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); } diff --git a/package.json b/package.json index fb8a3b8..bc7e0f4 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.38.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": { diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index 1d8fd41..728b51f 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -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({ diff --git a/tests/test_xlsx_dashboard.py b/tests/test_xlsx_dashboard.py new file mode 100644 index 0000000..e843e49 --- /dev/null +++ b/tests/test_xlsx_dashboard.py @@ -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