${escapeHtml(r.name)}${escapeHtml(r.ref)}${escapeHtml(t("xlsx.dashboard_empty"))}
`} + ${(dash.named_ranges || []).length ? ` +| ${escapeHtml(t("xlsx.dashboard_nr_name"))} | +${escapeHtml(t("xlsx.dashboard_nr_scope"))} | +${escapeHtml(t("xlsx.dashboard_nr_ref"))} | +
|---|
${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