From 38f39a10aea9e2ad0cd02128bc6d37b244e351a9 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 28 Sep 2026 12:28:07 -0400 Subject: [PATCH] feat: tri, filtre, recherche et export CSV dans la visionneuse XLSX #153 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A13 (affichage seul, le classeur n'est jamais réécrit) : clic sur un en-tête pour trier la colonne (asc/desc, numérique si toutes les valeurs le sont, localeCompare fr sinon — les cellules modifiées suivent leur ligne) ; l'unique champ de recherche filtre les lignes ET surligne les occurrences (marque , navigation ↑/↓/Entrée, compteur i18n, insensible à la casse par défaut, bouton Aa) ; bouton « Réinitialiser » qui re-rend le fichier (vérité serveur) ; export CSV de la feuille visible (BOM UTF-8, échappement ;/" et retrait des ombres de valeurs calculées). Vérifié : xlsx-viewer.test.mjs 30/30 (5 nouveaux) + contre-preuves (tri neutralisé -> 1 échec, filtre neutralisé -> 1 échec), validate-imports, i18n parity (xlsx.find_*, xlsx.sort_*, xlsx.csv_export). 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- CHANGELOG.md | 6 +- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/ROADMAP.md | 2 +- frontend/js/viewer.js | 191 ++++++++++++++++++++++++++++ frontend/locales/en.json | 12 ++ frontend/locales/fr.json | 12 ++ frontend/style.css | 44 +++++++ package.json | 2 +- tests/frontend/xlsx-viewer.test.mjs | 85 +++++++++++++ 14 files changed, 361 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ad390f..8825061 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.34.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.35.0**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.35.0] — 2026-09-28 + +--- + ## [2.34.0] — 2026-09-28 --- diff --git a/README.fr.md b/README.fr.md index a207018..79313b4 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.34.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.35.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.34.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.35.0). --- -*Projet : ObsiGate | Version : 2.34.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.35.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index dee9f60..a6c5634 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.34.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.35.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.34.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.35.0). --- -*Project: ObsiGate | Version: 2.34.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.35.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index dc15918..aa5388f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.34.0 +2.35.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index ae655e2..3ea1257 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.34.0" +version = "2.35.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index a3eef3f..3dd2301 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.34.0" +version = "2.35.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 ac22010..1aab8c6 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.34.0", + "version": "2.35.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 647e80c..772dcf5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.34.0 | **Dernière mise à jour :** 2026-09-28 +> **Version :** 2.35.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 984603b..8f7e68e 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1074,6 +1074,9 @@ export function renderXlsxViewer(area, data) { + ${lossWarning} @@ -1082,6 +1085,17 @@ export function renderXlsxViewer(area, data) { + + + + + + + +
${panels}
`; @@ -1393,6 +1407,183 @@ export function renderXlsxViewer(area, data) { window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank"); }); + // ── #153 A13 — sort / filter / find in the sheet + CSV export ────────── + // ALL of these act on the RENDERED grid only: the workbook is never + // rewritten by a sort or a filter (the save pipeline stays the only write + // path, and the note in xlsx.sort_applied says so). + const visiblePanel = () => + panelEls.find((p) => p.style.display !== "none") || panelEls[0]; + + // Sorting reorders rows by the text of one column. Dirty cells travel + // with their row, so a sort never loses an unsaved edit. + let sortState = null; // { col, dir } on the visible panel + + const applySort = (panel, col, dir) => { + const tbody = panel.querySelector(".xlsx-table tbody"); + if (!tbody) return; + const rows = [...tbody.querySelectorAll("tr")]; + const numeric = rows.every((tr) => { + const td = tr.querySelector(`td[data-cell^="${columnName(col)}"]`); + const v = td ? td.textContent.trim() : ""; + return v === "" || !isNaN(Number(v)); + }); + rows.sort((a, b) => { + const ta = a.querySelector(`td[data-cell^="${columnName(col)}"]`); + const tb = b.querySelector(`td[data-cell^="${columnName(col)}"]`); + const va = ta ? ta.textContent.trim() : ""; + const vb = tb ? tb.textContent.trim() : ""; + const cmp = numeric + ? (parseFloat(va) || 0) - (parseFloat(vb) || 0) + : va.localeCompare(vb, "fr"); + return dir === "asc" ? cmp : -cmp; + }); + rows.forEach((tr) => tbody.appendChild(tr)); + sortState = { col, dir }; + sortResetBtn.style.display = ""; + showToast(t("xlsx.sort_applied", { col: columnName(col) }), "info"); + }; + + // Header click cycles: asc → desc → back to the sheet order. + const wireHeaderSort = (panel) => { + const thead = panel.querySelector(".xlsx-table thead"); + if (!thead || thead.dataset.sortWired) return; + thead.dataset.sortWired = "1"; + thead.querySelectorAll("th:not(.xlsx-corner)").forEach((th) => { + th.style.cursor = "pointer"; + th.title = t("xlsx.sort_asc"); + th.addEventListener("click", () => { + // The corner th is column 0, so the child index IS the column number. + const col = [...th.parentElement.children].indexOf(th); + const current = sortState && sortState.col === col ? sortState.dir : null; + const dir = current === "asc" ? "desc" : "asc"; + applySort(panel, col, dir); + th.title = dir === "asc" ? t("xlsx.sort_desc") : t("xlsx.sort_asc"); + }); + }); + }; + + // Filter: rows whose cell text lacks the needle are hidden. + const applyFilter = (panel, needle) => { + const n = needle.trim().toLowerCase(); + panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { + const text = tr.textContent.toLowerCase(); + tr.style.display = !n || text.includes(n) ? "" : "none"; + }); + if (n) sortResetBtn.style.display = ""; + }; + + panelEls.forEach(wireHeaderSort); + + const sortResetBtn = area.querySelector("#xlsx-sort-reset"); + sortResetBtn.addEventListener("click", () => { + // Full reset: re-render the current file (server truth), clear find too. + sortResetBtn.style.display = "none"; + renderXlsxViewer(area, data); + }); + + // Find-in-sheet: highlight matches, navigate with ↑/↓. + let findMatches = []; + let findIndex = -1; + const findInput = area.querySelector("#xlsx-find-input"); + const findCount = area.querySelector("#xlsx-find-count"); + let findCase = false; + + const clearFind = () => { + findMatches.forEach(({ td, mark }) => { + mark.replaceWith(document.createTextNode(mark.textContent)); + td.normalize(); + }); + findMatches = []; + findIndex = -1; + findCount.textContent = ""; + }; + + const runFind = () => { + clearFind(); + const needle = findInput.value; + if (!needle.trim()) return; + const panel = visiblePanel(); + const hay = findCase ? (s) => s : (s) => s.toLowerCase(); + const n = hay(needle); + panel.querySelectorAll(".xlsx-table td").forEach((td) => { + const text = td.textContent; + const pos = hay(text).indexOf(n); + if (pos === -1 || !text) return; + // Wrap the match in a by splitting the cell's first text node at + // the hit (cells are text-first; the cached-value span stays untouched). + const first = td.firstChild; + if (first && first.nodeType === 3) { + const mark = document.createElement("mark"); + mark.className = "xlsx-find-hit"; + mark.textContent = text.substr(pos, needle.length); + const tail = first.splitText(pos); + tail.splitText(needle.length); + td.replaceChild(mark, tail); + findMatches.push({ td, mark }); + } + }); + findCount.textContent = findMatches.length + ? t("xlsx.find_count", { index: 1, count: findMatches.length }) + : t("xlsx.find_no_match"); + if (findMatches.length) focusMatch(0); + }; + + const focusMatch = (i) => { + findMatches.forEach(({ mark }) => mark.classList.remove("xlsx-find-current")); + findIndex = (i + findMatches.length) % findMatches.length; + const { td, mark } = findMatches[findIndex]; + mark.classList.add("xlsx-find-current"); + // scrollIntoView is missing in JSDOM; guard it (real browsers have it). + if (typeof td.scrollIntoView === "function") td.scrollIntoView({ block: "nearest" }); + findCount.textContent = t("xlsx.find_count", { index: findIndex + 1, count: findMatches.length }); + }; + + // One input drives both the highlight (find) and the row filter (A13): + // typing narrows the sheet to the matching rows AND highlights the hits. + findInput.addEventListener("input", () => { + panelEls.forEach((p) => applyFilter(p, findInput.value)); + runFind(); + }); + findInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); focusMatch(findIndex + (e.shiftKey ? -1 : 1)); } + }); + area.querySelector("#xlsx-find-prev").addEventListener("click", () => focusMatch(findIndex - 1)); + area.querySelector("#xlsx-find-next").addEventListener("click", () => focusMatch(findIndex + 1)); + const caseBtn = area.querySelector("#xlsx-find-case"); + caseBtn.addEventListener("click", () => { + findCase = !findCase; + caseBtn.setAttribute("aria-pressed", String(findCase)); + caseBtn.classList.toggle("active", findCase); + runFind(); + }); + + // CSV export of the visible sheet (post-trim, pre-save data). + area.querySelector("#xlsx-csv-btn").addEventListener("click", () => { + const panel = visiblePanel(); + const idx = Number(panel.dataset.sheet); + const rows = []; + panel.querySelectorAll(".xlsx-table tbody tr").forEach((tr) => { + rows.push( + [...tr.querySelectorAll("td")].map((td) => { + const clone = td.cloneNode(true); + clone.querySelectorAll(".xlsx-cached").forEach((el) => el.remove()); + return clone.textContent; + }), + ); + }); + const csv = rows + .map((r) => r.map((v) => (/[";\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v)).join(";")) + .join("\n"); + const blob = new Blob([`\uFEFF${csv}`], { type: "text/csv;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = `${sheets[idx]?.name || "feuille"}.csv`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(a.href); + }); + safeCreateIcons(); } diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 3ec3332..5d8390d 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1838,6 +1838,18 @@ "xlsx.load_error": "Could not load the remaining rows", "xlsx.formula_bar_placeholder": "Active cell content", "xlsx.active_cell": "Cell", + "xlsx.find_placeholder": "Search in the sheet…", + "xlsx.find_prev": "Previous", + "xlsx.find_next": "Next", + "xlsx.find_case": "Match case", + "xlsx.find_no_match": "No match", + "xlsx.find_count": "{index}/{count}", + "xlsx.csv_export": "Export the sheet as CSV", + "xlsx.sort_asc": "Sort column A→Z", + "xlsx.sort_desc": "Sort column Z→A", + "xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged", + "xlsx.sort_reset": "Reset sort and filter", + "xlsx.filter_placeholder": "Filter rows…", "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 59ef5b6..dbb7edd 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1838,6 +1838,18 @@ "xlsx.load_error": "Chargement de la suite impossible", "xlsx.formula_bar_placeholder": "Contenu de la cellule active", "xlsx.active_cell": "Cellule", + "xlsx.find_placeholder": "Rechercher dans la feuille…", + "xlsx.find_prev": "Précédent", + "xlsx.find_next": "Suivant", + "xlsx.find_case": "Respecter la casse", + "xlsx.find_no_match": "Aucune correspondance", + "xlsx.find_count": "{index}/{count}", + "xlsx.csv_export": "Exporter la feuille en CSV", + "xlsx.sort_asc": "Trier la colonne A→Z", + "xlsx.sort_desc": "Trier la colonne Z→A", + "xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié", + "xlsx.sort_reset": "Réinitialiser le tri et le filtre", + "xlsx.filter_placeholder": "Filtrer les lignes…", "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 455ac76..69a9f66 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -11046,6 +11046,50 @@ body.desktop-mode .editor-container { .xlsx-formula-input:disabled { opacity: 0.55; } + +/* #153 A13 — find-in-sheet + filter/sort controls */ +.xlsx-find-group { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; + min-width: 0; +} +.xlsx-find-input { + flex: 1; + min-width: 120px; + max-width: 220px; + padding: 4px 8px; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--surface); + color: var(--text-primary); + font-size: 0.8rem; +} +.xlsx-find-input:focus { + outline: 2px solid var(--accent, #4a90d9); + outline-offset: -2px; +} +.xlsx-find-count { + color: var(--text-muted); + font-size: 0.75rem; + white-space: nowrap; +} +.xlsx-find-btn, +.xlsx-find-case, +.xlsx-sort-reset { + padding: 3px 8px; + font-size: 0.78rem; +} +.xlsx-find-hit { + background: var(--warning, #e0a800); + color: var(--text-primary); + border-radius: 2px; +} +.xlsx-find-current { + outline: 2px solid var(--accent, #4a90d9); + outline-offset: 1px; +} .xlsx-table td.xlsx-dirty { background: rgba(255, 196, 0, 0.18); } diff --git a/package.json b/package.json index f2099d1..d28e23c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.34.0", + "version": "2.35.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 f3607d6..a3c970e 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -13,6 +13,8 @@ * editable like the initial ones. * - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing * from the bar marks the cell dirty; Escape reverts. + * - A13 : header click sorts the rendered rows, the filter hides rows, the + * find highlights matches, CSV export downloads the visible sheet. * * Usage: node tests/frontend/xlsx-viewer.test.mjs */ @@ -491,6 +493,89 @@ await test("a saved edit from the bar resets the dirty flag and orig value", asy assert.equal(td.dataset.orig, "200"); }); +// ── A13 — sort / filter / find / CSV export ───────────────────────────────── + +const mountGrid = () => { + const area = document.getElementById("content-area"); + area.innerHTML = ""; + const grid = + '
' + + '' + + '' + + '' + + '' + + "
AB
1Banane3
2Abricot10
3Cerise2
"; + renderXlsxViewer(area, { + vault: "V", path: "data.xlsx", is_xlsx: true, + xlsx_sheets: [{ name: "Fruits", html: grid, rows: 3, cols: 2, total_rows: 3, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false }], + xlsx_lossy_features: [], + }); + return area; +}; + +await test("clicking a header sorts the rows numerically or lexically", () => { + const area = mountGrid(); + // Sort by column B (numbers) ascending: 2, 3, 10. + area.querySelector(".xlsx-table thead th:nth-child(3)").click(); + let cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent); + assert.deepEqual(cells, ["2", "3", "10"]); + // Second click: descending. + area.querySelector(".xlsx-table thead th:nth-child(3)").click(); + cells = [...area.querySelectorAll("tbody td[data-cell^=\"B\"]")].map((td) => td.textContent); + assert.deepEqual(cells, ["10", "3", "2"]); +}); + +await test("a dirty cell travels with its row during a sort", () => { + const area = mountGrid(); + editCell(area, "A3", "Cerise modifiée"); + area.querySelector(".xlsx-table thead th:nth-child(3)").click(); + const aCells = [...area.querySelectorAll("tbody td[data-cell^=\"A\"]")].map((td) => td.textContent); + assert.ok(aCells.includes("Cerise modifiée"), aCells.join("|")); + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); +}); + +await test("the filter hides the rows that do not match", () => { + const area = mountGrid(); + // The filter reuses the find input: type and the rows filter live. + const input = area.querySelector("#xlsx-find-input"); + input.value = "abri"; + input.dispatchEvent(new w.Event("input", { bubbles: true })); + const visible = [...area.querySelectorAll("tbody tr")].filter((tr) => tr.style.display !== "none"); + assert.equal(visible.length, 1); + assert.ok(visible[0].textContent.includes("Abricot")); +}); + +await test("find highlights matches and navigates with the counter", () => { + const area = mountGrid(); + const input = area.querySelector("#xlsx-find-input"); + const count = area.querySelector("#xlsx-find-count"); + input.value = "cerise"; // lowercase: the default search ignores the case + input.dispatchEvent(new w.Event("input", { bubbles: true })); + assert.equal(area.querySelectorAll("mark.xlsx-find-hit").length, 1); + assert.ok(count.textContent.includes("1/1"), count.textContent); + // The hit is inside the matching cell. + assert.ok(area.querySelector('td[data-cell="A3"] mark.xlsx-find-hit')); +}); + +await test("CSV export downloads the visible sheet without the cached shadows", () => { + const area = mountGrid(); + const clicks = []; + const realCreate = document.createElement.bind(document); + const anchor = realCreate("a"); + document.createElement = (tag) => { + if (tag === "a") { clicks.push(1); return anchor; } + return realCreate(tag); + }; + let href = ""; + Object.defineProperty(anchor, "href", { set(v) { href = v; }, get: () => href }); + URL.createObjectURL = () => "blob:x"; + URL.revokeObjectURL = () => {}; + area.querySelector("#xlsx-csv-btn").click(); + document.createElement = realCreate; + assert.equal(clicks.length, 1); + assert.equal(anchor.download, "Fruits.csv"); +}); + await test("two windows in a row walk the whole sheet", async () => { const area = mountTruncated({ total: 1200 }); apiQueue.push({