From 472ea9d309fedeec0a6c445829a0d020f9998e19 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 28 Sep 2026 11:52:39 -0400 Subject: [PATCH] =?UTF-8?q?feat:=20chargement=20=C3=A0=20la=20demande=20de?= =?UTF-8?q?s=20lignes=20cach=C3=A9es=20des=20tableurs=20#153?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A9bis : sous une feuille tronquée, un pied de page « N lignes affichées sur M · Charger la suite » fetch la fenêtre suivante (limit=500) au clic ou à l'approche du bas du tableau (sentinelle de défilement, marge 120px). Les lignes ajoutées passent par le même pipeline d'édition que le rendu initial (setupCell factorisé) : éditables et sauvegardables immédiatement. Fetch échoué → bouton restauré (retry) + toast ; feuille complète → pied de page masqué (class done). Vérifié : xlsx-viewer.test.mjs 19/19 (5 nouveaux, contre-preuve wireLazyRows désactivé → 5 échecs), E2E 8/8 (A520 visible et éditable après clic), validate-imports 40 modules, unit.test.mjs 12/12, i18n parity. 🤖 Generated with Codebuff Co-Authored-By: Codebuff --- CHANGELOG.md | 17 +++- README.fr.md | 6 +- README.md | 6 +- VERSION | 2 +- desktop/Cargo.lock | 2 +- desktop/Cargo.toml | 2 +- desktop/tauri.conf.json | 2 +- docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md | 8 +- docs/ROADMAP.md | 6 +- docs/features/xlsx-viewer.md | 18 +++- frontend/js/viewer.js | 90 ++++++++++++++-- frontend/locales/en.json | 3 + frontend/locales/fr.json | 3 + frontend/style.css | 24 +++++ package.json | 2 +- tests/e2e/xlsx-viewer.spec.js | 21 ++++ tests/frontend/xlsx-viewer.test.mjs | 130 +++++++++++++++++++++++- 17 files changed, 309 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ca104dc..819f5fa 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.31.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.32.0**. --- @@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.32.0] — 2026-09-28 + +--- + ## [2.31.0] — 2026-09-28 ### Correction @@ -37,9 +41,14 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). et numéros de ligne de la feuille — une fenêtre se comporte exactement comme le rendu complet. Erreurs typées : 404 feuille inconnue, 415 fichier non-`.xlsx`. La lecture des valeurs calculées en cache (#153 - A12) s'applique aussi aux fenêtres. Le défilement virtuel côté UI - reste à faire ; l'endpoint rend les lignes au-delà du plafond déjà - accessibles aux clients API. + A12) s'applique aussi aux fenêtres. +- **#153 A9bis — « Charger la suite » sous une feuille tronquée.** Un + pied de page annonce la progression et fetch la fenêtre suivante au + clic ou à l'approche du bas du tableau (sentinelle de défilement). + Les lignes ajoutées passent par le même pipeline d'édition que le + rendu initial : éditables et sauvegardables immédiatement. Un fetch + échoué restore le bouton (retry possible) ; feuille complète → pied + de page masqué. --- diff --git a/README.fr.md b/README.fr.md index 8ca6edd..5feaa70 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.31.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.32.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.31.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.32.0). --- -*Projet : ObsiGate | Version : 2.31.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.32.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index ac9bc82..8798cd3 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.31.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.32.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.31.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.32.0). --- -*Project: ObsiGate | Version: 2.31.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.32.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index bafceb3..7cca401 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.31.0 +2.32.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index 972b422..8aede14 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.31.0" +version = "2.32.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 62e0dbd..0e15ea7 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.31.0" +version = "2.32.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 35ca0e5..2173143 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.31.0", + "version": "2.32.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index 2d4aba0..bf85437 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -196,9 +196,11 @@ curl "http://localhost:2020/api/file/Recettes/xlsx/sheet?path=budget.xlsx&sheet= ### Limites -- L'affichage intégré reste plafonné à **500 lignes × 40 colonnes** par - feuille (le défilement automatique au-delà est en préparation) ; les - lignes cachées restent accessibles via l'endpoint ci-dessus. +- L'affichage intégré démarre à **500 lignes × 40 colonnes** par feuille ; + sous une feuille plus grande, le bouton **« Charger la suite »** (ou le + défilement vers le bas du tableau) ajoute les lignes suivantes par + fenêtres de 500 — elles deviennent aussitôt éditables et + sauvegardables. - Styles, formats de nombre, cellules fusionnées et volets figés ne sont pas rendus. - Formats non gérés : `.xls`, `.xlsm` (macros), `.ods`. diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index bc30e99..523a9be 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.31.0 | **Dernière mise à jour :** 2026-09-28 +> **Version :** 2.32.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)** @@ -47,7 +47,7 @@ ### 153. Visionneuse & édition XLSX — complétude (fidélité, recherche, IA, UX, formats) - **Effort :** 8-13 jours (P0 ✅ 2-3 j · P1 : 4-6 j · P2 : 2-4 j) | **Impact :** 🟡 -- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9 livrés le 2026-09-28** (avec BUG-090, défilement virtuel A9bis à venir), reste A6-A7 puis A13-A17 +- **Statut :** 🔵 en cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17 - **Analyse, risques et critères d'acceptation :** [features/xlsx-viewer.md](./features/xlsx-viewer.md) - **Description :** #152 (visionneuse XLSX, 2.27.0) lit et édite correctement la **grille de valeurs** d'un `.xlsx`, mais l'ensemble supporté est étroit : valeurs seulement (ni structure, @@ -218,7 +218,7 @@ | 🔵 Finitions | #77 Desktop : 6 tests E2E **manuels** ([protocole](./DESKTOP_E2E_CHECKLIST.md)) — signature Windows non retenue (décision 2026-09-26) | ~0,5-1 jour | | ⚪ P4 reporté | #73 Sync — **reporté (décision 2026-09-26)**, hors chemin critique | 6-8 jours si réactivé | | ⚪ P0/P1 prioritaire | #87 CI/CD (BUG-035 → BUG-040 corrigés, #86 livré) | ~3-5 jours | -| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; P1 ✅ A5, A8-A10, A12 — reste A6-A7 ; A13-A17 2-4 j) | 3-5 jours restants | +| ⚪ P0/P1/P2 backlog | #153 Visionneuse & édition XLSX — complétude (P0 ✅ A1-A4 ; P1 ✅ A5, A8-A10, A12, A9bis — reste A6-A7 ; A13-A17 2-4 j) | 2-4 jours restants | | **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** | --- diff --git a/docs/features/xlsx-viewer.md b/docs/features/xlsx-viewer.md index f707e73..01dee79 100644 --- a/docs/features/xlsx-viewer.md +++ b/docs/features/xlsx-viewer.md @@ -3,7 +3,7 @@ > **Item de roadmap :** [#153 — Visionneuse & édition XLSX — complétude](../ROADMAP.md) > **Origine :** #152 (visionneuse XLSX, livrée en 2.27.0 — voir > [archive/COMPLETED_v1-v2.md](../archive/COMPLETED_v1-v2.md)) -> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9 livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17 +> **Statut :** 🔵 En cours — **P0 livré le 2026-09-27** (BUG-085 → BUG-088), **A5/A10/A12 livrés le 2026-09-28** (avec BUG-089), **A8/A9/A9bis livrés le 2026-09-28** (avec BUG-090), reste A6-A7 puis A13-A17 > **Effort estimé :** 8-13 jours au total (P0 ✅ 2-3 j · P1 4-6 j · P2 2-4 j) > **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte > l'analyse, les risques et les critères d'acceptation. **Ne pas dupliquer le détail.** @@ -167,9 +167,18 @@ couverture) · effort en jours-homme de développement + tests. complet et une édition dans la fenêtre cible la bonne cellule. Erreurs : 404 feuille inconnue / fichier absent, 415 non-`.xlsx`. *Vérifié :* `TestXlsxSheetWindow` (11), **contre-preuve** (neutraliser l'offset → 3 tests échouent), E2E « l'endpoint de fenêtre - sert les lignes au-delà du plafond ». *Reste :* défilement virtuel côté UI + bouton « - charger tout » (le viewer garde son rendu complet ≤ 500×40, mais le bandeau A8 dit la - vérité) ; suivi dans la Roadmap. + sert les lignes au-delà du plafond ». +- [x] **A9bis — Chargement à la demande côté UI.** Sous une feuille tronquée, un pied de page + « N lignes affichées sur M · Charger la suite » apparaît : cliquer — ou approcher du bas + du tableau (sentinelle de défilement, marge 120 px) — fetch la fenêtre suivante + (`limit=500`) et l'insère dans la table. Les lignes ajoutées passent par le **même** + pipeline d'édition que le rendu initial (`setupCell` factorisé : contenteditable, dirty, + Échap, collage monoligne, info-bulle valeurs calculées) et sont donc sauvegardables + immédiatement. Un fetch échoué restore le libellé du pied de page (retry possible) et + toast l'erreur ; feuille complète → pied de page masqué (`class="done"`). + *Vérifié :* `xlsx-viewer.test.mjs` 19/19 (5 nouveaux), **contre-preuve** (désactiver + `wireLazyRows` → 5 tests échouent), E2E « le bouton charger la suite ajoute les lignes + cachées » sur `sample-xlsx-large.xlsx` (A520 visible et éditable après clic). - [x] **A10 — Types et formats de saisie.** `_coerce_xlsx_value()` reconnait les booléens (`true`/`vrai`/`oui`/`yes` et leurs négatifs) et les dates FR `JJ/MM/AAAA` (+ `HH:MM`), jour-first comme Excel en locale française : `01/02/2026` = 1ᵉʳ février. Une saisie @@ -223,3 +232,4 @@ couverture) · effort en jours-homme de développement + tests. | 2026-09-27 | **P0 livré** (BUG-085 → BUG-088) : `xlsx_lossy_features` + 409 `xlsx_lossy_content`, écriture atomique, verrou par fichier, formules stockées en texte par défaut | | 2026-09-28 | **A5 + A10 + A12 livrés** : le contenu des cellules est indexé (recherche), la saisie est typée (booléens, dates FR), la valeur calculée s'affiche sous la formule. **BUG-089** corrigé au passage (reindex manuel ≠ reconstruction de l'index inversé ; `backend/search.py` lisait l'index par valeur) | | 2026-09-28 | **A8 + A9 livrés** (BUG-090) : la troncature d'une feuille est annoncée (bandeau + dimensions dans la réponse de lecture), les en-têtes restent visibles au défilement, et `GET …/xlsx/sheet` sert une fenêtre de lignes avec les vraies coordonnées A1 — les lignes au-delà du plafond redeviennent accessibles aux clients API. Défilement virtuel côté UI à suivre | +| 2026-09-28 | **A9bis livré** : « Charger la suite » + sentinelle de défilement sous une feuille tronquée ; les lignes ajoutées sont éditables et sauvegardables immédiatement (même pipeline que le rendu initial) | diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index b2069b7..1fce999 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -1085,14 +1085,80 @@ export function renderXlsxViewer(area, data) { const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; }; - // #153 A12 — the backend marks the last value Excel computed; the wording is - // translated here so the tooltip follows the UI language. - area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => { - el.title = t("xlsx.cached_value_title"); - }); + // #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is + // fetched window by window from GET …/xlsx/sheet when the user reaches the + // end of a truncated sheet (scroll sentinel) or clicks « Charger la suite ». + // Appended rows reuse the exact same edit pipeline as the initial render. + const wireLazyRows = (panel) => { + const meta = sheets[Number(panel.dataset.sheet)] || {}; + if (!meta.truncated) return; + const wrapper = panel.querySelector(".csv-table-wrapper"); + const table = panel.querySelector(".xlsx-table tbody"); + if (!wrapper || !table) return; - // Editable cells: Enter blurs, Escape reverts, paste stays single-line. - area.querySelectorAll(".xlsx-table td").forEach((td) => { + let offset = Number(meta.rows) || 0; + const total = Number(meta.total_rows) || 0; + let loading = false; + let done = offset >= total; + + const foot = document.createElement("div"); + foot.className = "xlsx-load-more"; + const refreshFoot = () => { + foot.textContent = done + ? "" + : `${t("xlsx.truncated_rows", { shown: offset, total })} · ${t("xlsx.load_more")}`; + foot.classList.toggle("done", done); + }; + refreshFoot(); + foot.addEventListener("click", () => { if (!done) loadMore(); }); + wrapper.insertAdjacentElement("afterend", foot); + + const appendWindow = (win) => { + const doc = new DOMParser().parseFromString(`${win.html}
`, "text/html"); + // No `tbody` selector: the fragment embeds its own wrapper div, so the + // parse yields bare `` inside `` (the rows we want) — while + // any `tbody` in the fragment belongs to the *embedded* wrapper table. + doc.querySelectorAll("tr").forEach((tr) => table.appendChild(tr)); + offset = win.offset + win.rows; + done = !win.has_more; + refreshFoot(); + // The new rows must behave like the initial ones: contenteditable, + // dirty tracking, cached-value tooltip, icons in the fresh footnote. + panel.querySelectorAll("tbody tr:not([data-wired]) td").forEach(setupCell); + panel.querySelectorAll("tbody tr").forEach((tr) => tr.setAttribute("data-wired", "1")); + const cachedEls = panel.querySelectorAll(".xlsx-cached[data-cached-value]"); + cachedEls.forEach((el) => { if (!el.title) el.title = t("xlsx.cached_value_title"); }); + safeCreateIcons(); + }; + + const loadMore = async () => { + if (loading || done) return; + loading = true; + const label = foot.textContent; + foot.textContent = t("xlsx.loading_more"); + try { + const win = await api( + `/api/file/${encodeURIComponent(data.vault)}/xlsx/sheet?path=${encodeURIComponent(data.path)}` + + `&sheet=${encodeURIComponent(sheets[Number(panel.dataset.sheet)].name)}` + + `&offset=${offset}&limit=500`, + ); + appendWindow(win); + } catch (err) { + foot.textContent = label; // restore: a failed fetch must not eat the button + showToast(`${t("xlsx.load_error")}: ${err.message || err}`, "error"); + } finally { + loading = false; + } + }; + + // Scroll sentinel: near the bottom of the wrapper, pull the next window. + wrapper.addEventListener("scroll", () => { + if (loading || done) return; + if (wrapper.scrollTop + wrapper.clientHeight >= wrapper.scrollHeight - 120) loadMore(); + }); + }; + + const setupCell = (td) => { td.contentEditable = "true"; td.spellcheck = false; td.dataset.orig = td.textContent; @@ -1113,8 +1179,18 @@ export function renderXlsxViewer(area, data) { const text = (e.clipboardData || window.clipboardData).getData("text").replace(/\r?\n/g, " "); document.execCommand("insertText", false, text); }); + }; + + // #153 A12 — the backend marks the last value Excel computed; the wording is + // translated here so the tooltip follows the UI language. + area.querySelectorAll(".xlsx-cached[data-cached-value]").forEach((el) => { + el.title = t("xlsx.cached_value_title"); }); + // Editable cells: Enter blurs, Escape reverts, paste stays single-line. + area.querySelectorAll(".xlsx-table td").forEach(setupCell); + panelEls.forEach(wireLazyRows); + area.querySelectorAll(".xlsx-tab").forEach((tab) => { tab.addEventListener("click", () => { const idx = tab.dataset.sheet; diff --git a/frontend/locales/en.json b/frontend/locales/en.json index 8b60aa3..5b159cd 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1833,6 +1833,9 @@ "xlsx.truncated_rows": "{shown} of {total} rows displayed.", "xlsx.truncated_cols": "{shown} of {total} columns displayed.", "xlsx.truncated_hint": "Cells outside the displayed area cannot be edited here; the workbook is unchanged.", + "xlsx.load_more": "Load more", + "xlsx.loading_more": "Loading…", + "xlsx.load_error": "Could not load the remaining 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 2239b92..561723b 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1833,6 +1833,9 @@ "xlsx.truncated_rows": "{shown} lignes affichées sur {total}.", "xlsx.truncated_cols": "{shown} colonnes affichées sur {total}.", "xlsx.truncated_hint": "Les cellules hors de l'affichage ne sont pas éditables ici ; le classeur n'est pas modifié.", + "xlsx.load_more": "Charger la suite", + "xlsx.loading_more": "Chargement…", + "xlsx.load_error": "Chargement de la suite impossible", "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 08ae8a3..179db2e 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -11089,6 +11089,30 @@ body.desktop-mode .editor-container { color: var(--accent, #4a90d9); } +/* #153 A9bis — “charger la suite” footnote under a truncated sheet. Also the + scroll sentinel target: clickable whole, disabled look once the sheet is + fully loaded. */ +.xlsx-load-more { + display: block; + margin: 6px 0 10px; + padding: 6px 12px; + border: 1px dashed var(--border); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-secondary); + font-size: 0.82rem; + text-align: center; + cursor: pointer; + user-select: none; +} +.xlsx-load-more:hover { + border-color: var(--accent, #4a90d9); + color: var(--text-primary); +} +.xlsx-load-more.done { + display: none; +} + .xlsx-formula-toggle { font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace; font-weight: 600; diff --git a/package.json b/package.json index 002dc39..99dd440 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.31.0", + "version": "2.32.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/e2e/xlsx-viewer.spec.js b/tests/e2e/xlsx-viewer.spec.js index 612dcde..0de9ac4 100644 --- a/tests/e2e/xlsx-viewer.spec.js +++ b/tests/e2e/xlsx-viewer.spec.js @@ -210,4 +210,25 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => { expect(win.html).toContain('Operation 519'); expect(win.html).not.toContain('data-cell="A1"'); }); + + test('le bouton « charger la suite » ajoute les lignes cachées (#153 A9bis)', async ({ page }) => { + await login(page); + await openXlsx(page, LARGE); + + // La ligne 500 est la dernière rendue ; le pied de page l'annonce. + const foot = page.locator('#content-area .xlsx-load-more'); + await expect(foot).toBeVisible(); + await expect(foot).toContainText('Charger la suite'); + await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0); + + // Un clic fetch la suite (offset 500, 20 lignes) et l'insère dans la table. + await foot.click(); + await expect(page.locator('#content-area td[data-cell="A520"]')).toBeVisible({ timeout: 10000 }); + // Une ligne nouvellement arrivée est éditable comme les autres. + const cell = page.locator('#content-area td[data-cell="A520"]'); + await cell.click(); + await expect(cell).toBeFocused(); + // Tout est chargé → le pied de page est masqué. + await expect(foot).toBeHidden(); + }); }); diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index adbbb29..4b243d8 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -8,6 +8,9 @@ * retries with `force: true` (or gives up when refused); * - A4 : the f(x) toggle flips `allow_formula` in the save payload. * - A8 : a sheet bigger than the render caps shows the truncation notice. + * - A9bis : the tail of a truncated sheet is fetched window by window from + * GET …/xlsx/sheet (scroll sentinel + click), and the appended rows are + * editable like the initial ones. * * Usage: node tests/frontend/xlsx-viewer.test.mjs */ @@ -36,6 +39,7 @@ const dom = new JSDOM( const w = dom.window; globalThis.window = w; globalThis.document = w.document; +globalThis.DOMParser = w.DOMParser; globalThis.HTMLElement = w.HTMLElement; globalThis.Element = w.Element; globalThis.Node = w.Node; @@ -90,7 +94,11 @@ async function test(name, fn) { confirmCalls = 0; confirmPrompts = []; confirmAnswer = true; - nextResponse = () => ({ ok: true, status: 200, body: { status: "ok" } }); + apiQueue = []; + nextResponse = () => { + if (apiQueue.length) return apiQueue.shift(); + return { ok: true, status: 200, body: { status: "ok" } }; + }; try { await fn(); console.log(` ✓ ${name}`); @@ -117,6 +125,11 @@ const sheetHtml = (value) => `` + "
1${value}
"; +// The JSDOM fetch double serves locale files; everything else is a recorded +// API call answered by `nextResponse`. `apiQueue` lets a test script the +// successive windows a lazy-loading sheet will request. +let apiQueue = []; + function mount({ lossy = [], sheet = {} } = {}) { const area = document.getElementById("content-area"); area.innerHTML = ""; @@ -130,6 +143,13 @@ function mount({ lossy = [], sheet = {} } = {}) { return area; } +/** Mount a sheet flagged truncated so wireLazyRows() arms the footnote. */ +function mountTruncated({ total = 520, rows = 1 } = {}) { + return mount({ + sheet: { rows, cols: 3, total_rows: total, total_cols: 3, max_rows: 500, max_cols: 40, truncated: total > 500 }, + }); +} + /** Mark a cell dirty the way a user edit would. */ function editCell(area, ref, text) { const td = area.querySelector(`td[data-cell="${ref}"]`); @@ -312,6 +332,114 @@ await test("a payload without the dimensions shows no notice", () => { assert.ok(!area.textContent.includes("NaN")); }); +// ── A9bis — lazy loading of the truncated tail ────────────────────────────── + +const windowHtml = (from, to) => + '
' + + "" + + Array.from({ length: to - from + 1 }, (_, i) => { + const r = from + i; + return ``; + }).join("") + + "
${r}Ligne ${r}
"; + +await test("a truncated sheet gets a load-more footnote, a normal one does not", () => { + const truncated = mountTruncated(); + assert.ok(truncated.querySelector(".xlsx-load-more"), "footnote absent"); + assert.ok(truncated.querySelector(".xlsx-load-more").textContent.includes(FR["xlsx.load_more"])); + + const plain = mount({ sheet: { rows: 10, cols: 2, total_rows: 10, total_cols: 2, max_rows: 500, max_cols: 40, truncated: false } }); + assert.equal(plain.querySelector(".xlsx-load-more"), null); +}); + +await test("clicking the footnote fetches the next window with the right query", async () => { + const area = mountTruncated(); + apiQueue.push({ + ok: true, + status: 200, + body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 2, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 3) }, + }); + area.querySelector(".xlsx-load-more").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(calls.length, 1); + assert.match(calls[0].url, /\/api\/file\/V\/xlsx\/sheet\?path=data\.xlsx&sheet=Feuille1&offset=1&limit=500/); + // Rows 2 and 3 landed in the table with their real coordinates. + assert.ok(area.querySelector('td[data-cell="A2"]')); + assert.ok(area.querySelector('td[data-cell="A3"]')); + assert.ok(area.querySelector('th.xlsx-rownum') && area.textContent.includes("Ligne 3")); + // Everything loaded → the footnote is hidden (kept in the DOM, class `done`). + const foot = area.querySelector(".xlsx-load-more"); + assert.ok(foot, "the footnote element survives"); + assert.equal(foot.classList.contains("done"), true); + assert.ok(!foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent); +}); + +await test("appended rows are editable and tracked as dirty", async () => { + const area = mountTruncated(); + apiQueue.push({ + ok: true, + status: 200, + body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) }, + }); + area.querySelector(".xlsx-load-more").click(); + await new Promise((r) => setTimeout(r, 5)); + editCell(area, "A2", "modifié"); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.equal(calls.length, 2, "the save went out"); + assert.equal(calls[1].url.includes("/xlsx/save"), true); + assert.deepEqual(calls[1].body.cells, { A2: "modifié" }); + assert.equal(calls[1].body.sheet, "Feuille1"); +}); + +await test("a failed window fetch keeps the footnote and shows an error toast", async () => { + const area = mountTruncated(); + apiQueue.push({ ok: false, status: 500, body: { detail: "boom" } }); + area.querySelector(".xlsx-load-more").click(); + await new Promise((r) => setTimeout(r, 5)); + const foot = area.querySelector(".xlsx-load-more"); + assert.ok(foot, "footnote must survive a failed fetch"); + assert.ok(foot.textContent.includes(FR["xlsx.load_more"]), foot.textContent); + // `api()` itself toasts the failure (shared behaviour, asserted in E2E); + // here we assert the local consequence: the footnote keeps its label. + // Retrying works once the server answers again. + apiQueue.push({ + ok: true, + status: 200, + body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 1, total_rows: 3, truncated: true, has_more: false, html: windowHtml(2, 2) }, + }); + foot.click(); + await new Promise((r) => setTimeout(r, 5)); + assert.ok(area.querySelector('td[data-cell="A2"]')); + assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true); +}); + +await test("two windows in a row walk the whole sheet", async () => { + const area = mountTruncated({ total: 1200 }); + apiQueue.push({ + ok: true, + status: 200, + body: { sheet: "Feuille1", offset: 1, limit: 500, rows: 500, total_rows: 1200, truncated: true, has_more: true, html: windowHtml(2, 501) }, + }); + area.querySelector(".xlsx-load-more").click(); + await new Promise((r) => setTimeout(r, 5)); + assert.ok(area.querySelector('td[data-cell="A501"]')); + // The footnote still shows, with the updated progress. + let foot = area.querySelector(".xlsx-load-more"); + assert.ok(foot, "more rows remain"); + assert.ok(foot.textContent.includes("501"), foot.textContent); + + apiQueue.push({ + ok: true, + status: 200, + body: { sheet: "Feuille1", offset: 501, limit: 500, rows: 200, total_rows: 1200, truncated: true, has_more: false, html: windowHtml(502, 701) }, + }); + foot.click(); + await new Promise((r) => setTimeout(r, 5)); + assert.ok(area.querySelector('td[data-cell="A701"]')); + assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true); +}); + // ── Report ────────────────────────────────────────────────────────────────── console.log(`\n${passCount}/${testCount} tests passed\n`); process.exit(passCount === testCount ? 0 : 1);