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.
-[]()
+[]()
[](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.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.
-[]()
+[]()
[](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.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(`
`, "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 `| ${r} | Ligne ${r} |
`;
+ }).join("") +
+ "
";
+
+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);