feat: tri, filtre, recherche et export CSV dans la visionneuse XLSX #153
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 <mark>, 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 <[email protected]>
This commit is contained in:
+5
-1
@@ -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).
|
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
|
> **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
|
## [2.34.0] — 2026-09-28
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
+3
-3
@@ -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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
@@ -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.
|
**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://opensource.org/licenses/MIT)
|
||||||
[](https://www.docker.com/)
|
[](https://www.docker.com/)
|
||||||
[](https://www.python.org/)
|
[](https://www.python.org/)
|
||||||
@@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
|
|||||||
|
|
||||||
## 📝 Changelog
|
## 📝 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*
|
||||||
|
|||||||
Generated
+1
-1
@@ -2626,7 +2626,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.34.0"
|
version = "2.35.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "obsigate-desktop"
|
name = "obsigate-desktop"
|
||||||
version = "2.34.0"
|
version = "2.35.0"
|
||||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||||
authors = ["Bruno Charest"]
|
authors = ["Bruno Charest"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||||
"productName": "ObsiGate",
|
"productName": "ObsiGate",
|
||||||
"version": "2.34.0",
|
"version": "2.35.0",
|
||||||
"identifier": "com.obsigate.desktop",
|
"identifier": "com.obsigate.desktop",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend",
|
"frontendDist": "../frontend",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
# ObsiGate — Roadmap
|
# 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
|
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
|
||||||
> vers les fonctionnalités livrées.
|
> vers les fonctionnalités livrées.
|
||||||
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
|
||||||
|
|||||||
@@ -1074,6 +1074,9 @@ export function renderXlsxViewer(area, data) {
|
|||||||
<button class="btn-action" id="xlsx-download-btn">
|
<button class="btn-action" id="xlsx-download-btn">
|
||||||
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
<i data-lucide="download" style="width:14px;height:14px"></i> ${t("viewer.download")}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn-action" id="xlsx-csv-btn" title="${escapeHtml(t("xlsx.csv_export"))}">
|
||||||
|
<i data-lucide="file-spreadsheet" style="width:14px;height:14px"></i> CSV
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
${lossWarning}
|
${lossWarning}
|
||||||
@@ -1082,6 +1085,17 @@ export function renderXlsxViewer(area, data) {
|
|||||||
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
<i data-lucide="chevron-right" class="xlsx-formula-sep"></i>
|
||||||
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
<input type="text" class="xlsx-formula-input" id="xlsx-formula-input"
|
||||||
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
spellcheck="false" placeholder="${escapeHtml(t("xlsx.formula_bar_placeholder"))}" />
|
||||||
|
<span class="xlsx-find-group">
|
||||||
|
<input type="text" class="xlsx-find-input" id="xlsx-find-input"
|
||||||
|
spellcheck="false" placeholder="${escapeHtml(t("xlsx.find_placeholder"))}" />
|
||||||
|
<span class="xlsx-find-count" id="xlsx-find-count"></span>
|
||||||
|
<button class="btn-action xlsx-find-btn" id="xlsx-find-prev" title="${escapeHtml(t("xlsx.find_prev"))}">↑</button>
|
||||||
|
<button class="btn-action xlsx-find-btn" id="xlsx-find-next" title="${escapeHtml(t("xlsx.find_next"))}">↓</button>
|
||||||
|
<button class="btn-action xlsx-find-case" id="xlsx-find-case" aria-pressed="false" title="${escapeHtml(t("xlsx.find_case"))}">Aa</button>
|
||||||
|
<button class="btn-action xlsx-sort-reset" id="xlsx-sort-reset" title="${escapeHtml(t("xlsx.sort_reset"))}" style="display:none">
|
||||||
|
<i data-lucide="rotate-ccw" style="width:14px;height:14px"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="xlsx-panels">${panels}</div>
|
<div class="xlsx-panels">${panels}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -1393,6 +1407,183 @@ export function renderXlsxViewer(area, data) {
|
|||||||
window.open(`/api/file/${encodeURIComponent(data.vault)}/download?path=${encodeURIComponent(data.path)}`, "_blank");
|
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 <tr> 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 <mark> 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();
|
safeCreateIcons();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1838,6 +1838,18 @@
|
|||||||
"xlsx.load_error": "Could not load the remaining rows",
|
"xlsx.load_error": "Could not load the remaining rows",
|
||||||
"xlsx.formula_bar_placeholder": "Active cell content",
|
"xlsx.formula_bar_placeholder": "Active cell content",
|
||||||
"xlsx.active_cell": "Cell",
|
"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_cached_values": "cached values",
|
||||||
"xlsx.feature_slicers": "slicers and timelines",
|
"xlsx.feature_slicers": "slicers and timelines",
|
||||||
"xlsx.feature_form_controls": "form controls",
|
"xlsx.feature_form_controls": "form controls",
|
||||||
|
|||||||
@@ -1838,6 +1838,18 @@
|
|||||||
"xlsx.load_error": "Chargement de la suite impossible",
|
"xlsx.load_error": "Chargement de la suite impossible",
|
||||||
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
"xlsx.formula_bar_placeholder": "Contenu de la cellule active",
|
||||||
"xlsx.active_cell": "Cellule",
|
"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_cached_values": "valeurs calculées",
|
||||||
"xlsx.feature_slicers": "segments et chronologies",
|
"xlsx.feature_slicers": "segments et chronologies",
|
||||||
"xlsx.feature_form_controls": "contrôles de formulaire",
|
"xlsx.feature_form_controls": "contrôles de formulaire",
|
||||||
|
|||||||
@@ -11046,6 +11046,50 @@ body.desktop-mode .editor-container {
|
|||||||
.xlsx-formula-input:disabled {
|
.xlsx-formula-input:disabled {
|
||||||
opacity: 0.55;
|
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 {
|
.xlsx-table td.xlsx-dirty {
|
||||||
background: rgba(255, 196, 0, 0.18);
|
background: rgba(255, 196, 0, 0.18);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "obsigate",
|
"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.",
|
"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",
|
"main": "patch.js",
|
||||||
"directories": {
|
"directories": {
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
* editable like the initial ones.
|
* editable like the initial ones.
|
||||||
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
* - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
|
||||||
* from the bar marks the cell dirty; Escape reverts.
|
* 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
|
* 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");
|
assert.equal(td.dataset.orig, "200");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── A13 — sort / filter / find / CSV export ─────────────────────────────────
|
||||||
|
|
||||||
|
const mountGrid = () => {
|
||||||
|
const area = document.getElementById("content-area");
|
||||||
|
area.innerHTML = "";
|
||||||
|
const grid =
|
||||||
|
'<div class="csv-table-wrapper"><table class="csv-table xlsx-table">' +
|
||||||
|
'<thead><tr><th class="xlsx-corner"></th><th>A</th><th>B</th></tr></thead><tbody>' +
|
||||||
|
'<tr><th class="xlsx-rownum">1</th><td data-cell="A1">Banane</td><td data-cell="B1">3</td></tr>' +
|
||||||
|
'<tr><th class="xlsx-rownum">2</th><td data-cell="A2">Abricot</td><td data-cell="B2">10</td></tr>' +
|
||||||
|
'<tr><th class="xlsx-rownum">3</th><td data-cell="A3">Cerise</td><td data-cell="B3">2</td></tr>' +
|
||||||
|
"</tbody></table></div>";
|
||||||
|
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 () => {
|
await test("two windows in a row walk the whole sheet", async () => {
|
||||||
const area = mountTruncated({ total: 1200 });
|
const area = mountTruncated({ total: 1200 });
|
||||||
apiQueue.push({
|
apiQueue.push({
|
||||||
|
|||||||
Reference in New Issue
Block a user