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.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](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.
-[]()
+[]()
[](https://opensource.org/licenses/MIT)
[](https://www.docker.com/)
[](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 =
+ '
' +
+ '
A
B
' +
+ '
1
Banane
3
' +
+ '
2
Abricot
10
' +
+ '
3
Cerise
2
' +
+ "
";
+ 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({