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