`, info-bulle FR/EN)
@@ -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 ; A5-A12 4-6 j, A13-A17 2-4 j) | 6-11 jours restants |
+| ⚪ 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 |
| **Total chemin critique** | **#77 fin + #87** | **~4-6 jours** |
---
diff --git a/docs/features/xlsx-viewer.md b/docs/features/xlsx-viewer.md
index 7e5f1c9..f707e73 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), reste A6-A9 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 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.**
@@ -15,8 +15,9 @@
| Couche | Fichier | Rôle |
|---|---|---|
| Lecture | `backend/xlsx_reader.py` | `render_sheets()` → un tableau HTML par feuille (openpyxl `read_only=True`, `data_only=False`) |
-| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html}]` |
-| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets` |
+| Endpoint lecture | `backend/routers/files_read.py:241-265` | `GET /api/file/{vault}?path=…` → `is_xlsx: true` + `xlsx_sheets: [{name, html, rows, cols, total_*, max_*, truncated}]` |
+| Endpoint fenêtre | `backend/routers/files_read.py` | `GET /api/file/{vault}/xlsx/sheet?path=&sheet=&offset=&limit=` (#153 A9) — une fenêtre de lignes, vraies coordonnées A1 |
+| Schéma API | `backend/schemas.py:286-290` | `is_xlsx`, `xlsx_sheets`, `XlsxSheetWindowResponse` |
| Écriture | `backend/services/mutations.py:227-320` | `edit_xlsx_cells()` (backup, refs A1 validées, coercion `str`→`int`/`float`) |
| Endpoint écriture | `backend/routers/files_write.py:116-148` | `PUT /api/file/{vault}/xlsx/save` (1 à 500 cellules / requête) |
| Documentation API | `backend/openapi_docs.py:184-187` | exemple d'appel `xlsx/save` |
@@ -105,7 +106,7 @@ restent à faire (A7).
| R2 | Écriture non atomique (`wb.save()` en place) → classeur corrompu si crash | `mutations.edit_xlsx_cells` | **A2** — `.tmp` + `os.replace` | 🟢 livré (BUG-086) |
| R3 | Concurrence : deux éditions (onglets, watcher + IA) → dernier écrivain gagne | `mutations.edit_xlsx_cells` | **A3** — verrou par chemin, **409** `conflict` | 🟢 livré (BUG-087) |
| R4 | **Injection de formule** : une saisie `=cmd\|…`, `=HYPERLINK(…)` est stockée comme formule par openpyxl → DDE à l'ouverture dans Excel | `mutations._write_cell` | **A4** — forçage texte (`data_type="s"`), opt-in `allow_formula` | 🟢 livré (BUG-088) |
-| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | ⚪ à faire |
+| R5 | Troncature silencieuse au-delà de 500×40 | `xlsx_reader.MAX_ROWS/MAX_COLS` | A8 / A9 | 🟢 bandeau + dimensions exposées (BUG-090) ; le chargement paresseux par fenêtres sert les lignes au-delà du plafond |
## 5. Backlog #153 — sous-tâches
@@ -149,13 +150,26 @@ couverture) · effort en jours-homme de développement + tests.
- [ ] **A7 — Navigation clavier & barre de formule.** `Tab`/`Maj+Tab`/`Entrée`/flèches, cellule
active affichée (nom A1), `Maj+Entrée` pour le multiligne, copier une plage, focus visible
et compatible mobile (≥ 44 px, `tests/e2e/mobile-editor.spec.js`).
-- [ ] **A8 — `thead` sticky + indicateur de troncature (R5).** Ligne d'en-têtes figlée au
- défilement vertical ; bandeau « feuille tronquée à 500 lignes × 40 colonnes » ; libellés
- FR/EN.
-- [ ] **A9 — Chargement paresseux par feuille (supprime le plafond).** Endpoint
- `GET /api/file/{vault}/xlsx/sheet?sheet=N&offset=&limit=` (`response_model` +
- `backend/openapi_docs.py`), rendu à la demande avec défilement virtuel, bouton « charger
- tout ».
+- [x] **A8 — `thead` sticky + indicateur de troncature (R5) — livré 2026-09-28 (BUG-090).**
+ Ligne d'en-têtes figlée au défilement vertical (`thead th { top: 0 }` ; `top: auto` sur les
+ numéros de ligne, sans quoi ils s'empilent en haut à gauche) ; `render_sheets()` expose
+ `total_rows`/`total_cols` (dimensions déclarées), `max_rows`/`max_cols` (plafonds) et
+ `truncated` — le bandeau « feuille tronquée » annonce le **plafond atteint** et non la
+ taille élaguée (une feuille creuse rend 1×1 tout en couvrant 500 lignes) ; libellés
+ `xlsx.truncated_*` FR/EN. *Vérifié :* `TestXlsxTruncationNotice` (4), `xlsx-viewer.test.mjs`
+ (4 nouveaux), E2E sur `test_vault/sample-xlsx-large.xlsx` (520 lignes).
+- [x] **A9 — Chargement paresseux par feuille (côté API).** Endpoint
+ `GET /api/file/{vault}/xlsx/sheet?sheet=&offset=&limit=` (`XlsxSheetWindowResponse`,
+ exemple dans `backend/openapi_docs.py`) : une fenêtre de 1 à 1 000 lignes (plafond
+ `MAX_WINDOW_ROWS`, `limit>1000` → 422), `has_more` pour paginer, valeurs calculées A12
+ incluses. Les numéros de ligne et `data-cell` restent les coordonnées A1 réelles de la
+ feuille (`_table(..., row_offset=offset)`) : une fenêtre est indistinguishable d'un rendu
+ 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.
- [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
@@ -208,3 +222,4 @@ couverture) · effort en jours-homme de développement + tests.
| 2026-09-27 | Périmètre de perte **remesuré** sur openpyxl 3.1.5 : graphiques / images / TCD sont préservés, seules les valeurs en cache et quelques parties exotiques sont perdues |
| 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 |
diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js
index 3cae602..b2069b7 100644
--- a/frontend/js/viewer.js
+++ b/frontend/js/viewer.js
@@ -1005,6 +1005,35 @@ export function renderVideoViewer(area, data) {
// confirmation before retrying with `force: true`. A value starting with "=" or
// "@" is stored as text unless the user turns the formula toggle on, so a typed
// `=cmd|…` cannot execute when the file is later opened in Excel.
+// #153 A8 — a sheet bigger than the render caps used to be silently cut: the
+// user saw a short table and no way to tell the rest of the workbook still
+// existed. The backend now reports the real dimensions of every sheet, so the
+// note states exactly what is hidden (and that those cells are not editable
+// here — the workbook itself is untouched). A payload without those fields
+// (older cache) simply shows no note.
+function truncationNote(sheet) {
+ // The cap is the real "shown" figure, not `rows`/`cols`: those are post-trim
+ // (a sparse sheet renders 1x1) while the note must say how far the view
+ // reaches.
+ const cap = { rows: Number(sheet.max_rows) || 0, cols: Number(sheet.max_cols) || 0 };
+ const reasons = [];
+ if (Number(sheet.total_rows) > cap.rows) {
+ reasons.push(t("xlsx.truncated_rows", { shown: cap.rows, total: Number(sheet.total_rows) }));
+ }
+ if (Number(sheet.total_cols) > cap.cols) {
+ reasons.push(t("xlsx.truncated_cols", { shown: cap.cols, total: Number(sheet.total_cols) }));
+ }
+ if (!reasons.length) return "";
+ return `
+
+
+ ${escapeHtml(t("xlsx.truncated_title"))}
+ ${escapeHtml(reasons.join(" "))}
+ ${escapeHtml(t("xlsx.truncated_hint"))}
+
+
`;
+}
+
export function renderXlsxViewer(area, data) {
const sheets = data.xlsx_sheets || [];
const lossy = data.xlsx_lossy_features || [];
@@ -1019,7 +1048,7 @@ export function renderXlsxViewer(area, data) {
).join("")}`
: "";
const panels = sheets.map((s, i) =>
- `${s.html}
`
+ `${truncationNote(s)}${s.html}
`
).join("");
const lossWarning = lossy.length
? `
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 13ab19a..8b60aa3 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -1829,6 +1829,10 @@
"xlsx.lossy_cancelled": "Save cancelled",
"xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)",
"xlsx.cached_value_title": "Last value calculated by Excel",
+ "xlsx.truncated_title": "Truncated sheet",
+ "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.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 140bc8b..2239b92 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -1829,6 +1829,10 @@
"xlsx.lossy_cancelled": "Sauvegarde annulée",
"xlsx.formula_toggle_title": "Interpréter « = » et « @ » comme des formules (désactivé par défaut)",
"xlsx.cached_value_title": "Dernière valeur calculée par Excel",
+ "xlsx.truncated_title": "Feuille tronquée",
+ "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.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 c153b08..08ae8a3 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -10967,12 +10967,25 @@ body.desktop-mode .editor-container {
border-right: 1px solid var(--border-light, var(--border));
position: sticky;
left: 0;
- z-index: 1;
+ /* #153 A8 — `top: auto` is load-bearing: `.csv-table th` pins EVERY `th`
+ at `top: 0`, so a row number left sticky on both axes piles up in the
+ top-left corner instead of tracking its own row. */
+ top: auto;
+ z-index: 2;
}
.xlsx-table th.xlsx-corner {
left: 0;
top: 0;
- z-index: 2;
+ z-index: 4;
+}
+/* #153 A8 — the column headers stay visible while the sheet scrolls down.
+ Declared explicitly (and not inherited from `.csv-table th`) so the stacking
+ order is intentional: thead (3) < row numbers (2) < corner (4). */
+.xlsx-table thead th {
+ position: sticky;
+ top: 0;
+ z-index: 3;
+ background: var(--surface);
}
.xlsx-table td[contenteditable] {
cursor: text;
@@ -11051,6 +11064,31 @@ body.desktop-mode .editor-container {
color: var(--text-secondary);
opacity: 0.85;
}
+/* #153 A8 — "feuille tronquée" notice. Deliberately NOT the `.xlsx-warning`
+ look: that one is a data-loss alert, this one only says part of the sheet is
+ out of view. */
+.xlsx-truncated {
+ display: flex;
+ align-items: flex-start;
+ gap: 8px;
+ padding: 8px 10px;
+ margin-bottom: 8px;
+ border: 1px solid var(--border);
+ border-left: 3px solid var(--accent, #4a90d9);
+ border-radius: 4px;
+ background: var(--bg-secondary);
+ color: var(--text-secondary);
+ font-size: 0.82rem;
+ line-height: 1.45;
+}
+.xlsx-truncated-icon {
+ width: 16px;
+ height: 16px;
+ flex: 0 0 auto;
+ margin-top: 1px;
+ color: var(--accent, #4a90d9);
+}
+
.xlsx-formula-toggle {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-weight: 600;
diff --git a/package.json b/package.json
index ac4c8e2..002dc39 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsigate",
- "version": "2.30.0",
+ "version": "2.31.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/test_vault/sample-xlsx-large.xlsx b/test_vault/sample-xlsx-large.xlsx
new file mode 100644
index 0000000..a222a6d
Binary files /dev/null and b/test_vault/sample-xlsx-large.xlsx differ
diff --git a/tests/e2e/xlsx-viewer.spec.js b/tests/e2e/xlsx-viewer.spec.js
index 69fa24e..612dcde 100644
--- a/tests/e2e/xlsx-viewer.spec.js
+++ b/tests/e2e/xlsx-viewer.spec.js
@@ -14,6 +14,12 @@
* - the f(x) toggle is off by default, so "=B1*3" is stored as text ;
* - the value Excel last computed is shown under the formula (#153 A12).
*
+ * Second describe block — `test_vault/sample-xlsx-large.xlsx` (520 rows) :
+ * - a sheet over the render caps SAYS it instead of looking complete (#153 A8) ;
+ * - the column headers stay pinned while the sheet scrolls (#153 A8) ;
+ * - `GET …/xlsx/sheet?offset=500` serves the rows the caps used to hide,
+ * with the real A1 coordinates (#153 A9).
+ *
* The fixture is restored byte-for-byte in `afterAll` so a local run never
* dirties the working copy.
*
@@ -27,6 +33,10 @@ import path from 'node:path';
const BASE = process.env.BASE_URL || 'http://localhost:2029';
const VAULT = 'TestVault';
const FIXTURE = 'sample-xlsx-lossy.xlsx';
+// #153 A8 — 520 rows x 3 columns: the sheet exceeds the 500-row render cap, so
+// the viewer must SAY so. Generated once with openpyxl (header + 519 lines) and
+// committed next to the other fixture; nothing in the suite writes to it.
+const LARGE = 'sample-xlsx-large.xlsx';
// Playwright runs from the repository root (run-e2e-local.* / CI both do).
const FIXTURE_PATH = path.resolve(process.cwd(), 'test_vault', FIXTURE);
@@ -45,7 +55,11 @@ async function login(page) {
}
async function openFixture(page) {
- const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${FIXTURE}"]`);
+ return openXlsx(page, FIXTURE);
+}
+
+async function openXlsx(page, file) {
+ const treeItem = page.locator(`.tree-item[data-vault="${VAULT}"][data-path="${file}"]`);
if (!(await treeItem.count())) {
await page.locator(`.tree-item.vault-item[data-vault="${VAULT}"]`).first().click();
await treeItem.waitFor({ state: 'attached', timeout: 8000 });
@@ -141,3 +155,59 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (#
await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 });
});
});
+
+// ── A8 — troncature annoncée + en-têtes figés ───────────────────────────────
+
+test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
+ test('annonce la feuille tronquée au lieu de la couper en silence', async ({ page }) => {
+ await login(page);
+ await openXlsx(page, LARGE);
+
+ const note = page.locator('#content-area .xlsx-truncated');
+ await expect(note).toBeVisible();
+ // Libellé traduit (jamais de texte UI backend, jamais de couleur en dur).
+ await expect(note).toContainText('Feuille tronquée');
+ await expect(note).toContainText('500 lignes affichées sur 520');
+
+ // La dernière ligne rendue est la 500e ; les suivantes ne sont pas là.
+ await expect(page.locator('#content-area td[data-cell="A500"]')).toHaveCount(1);
+ await expect(page.locator('#content-area td[data-cell="A501"]')).toHaveCount(0);
+ });
+
+ test('garde les en-têtes de colonnes visibles au défilement', async ({ page }) => {
+ await login(page);
+ await openXlsx(page, LARGE);
+
+ const header = page.locator('#content-area .xlsx-table thead th').nth(1);
+ const before = await header.boundingBox();
+
+ await page.locator('#content-area .csv-table-wrapper').evaluate((el) => { el.scrollTop = 800; });
+ await expect.poll(async () => (await header.boundingBox()).y, { timeout: 5000 })
+ .toBeLessThanOrEqual(before.y + 1);
+
+ // Les numéros de ligne ne se superposent pas en haut à gauche (le `top: auto`
+ // de A8) et la première ligne de données reste lisible sous l'en-tête.
+ const first = await page.locator('#content-area th.xlsx-rownum').first().boundingBox();
+ const second = await page.locator('#content-area th.xlsx-rownum').nth(1).boundingBox();
+ expect(second.y - first.y).toBeGreaterThan(4);
+ });
+
+ test('l\'endpoint de fenêtre sert les lignes au-delà du plafond (#153 A9)', async ({ page }) => {
+ await login(page);
+ await openXlsx(page, LARGE);
+
+ const res = await page.request.get(
+ `${BASE}/api/file/${VAULT}/xlsx/sheet?path=${encodeURIComponent(LARGE)}&sheet=Journal&offset=500&limit=50`
+ );
+ expect(res.status()).toBe(200);
+ const win = await res.json();
+ expect(win.total_rows).toBe(520);
+ expect(win.offset).toBe(500);
+ expect(win.has_more).toBe(false);
+ // Les coordonnées A1 sont celles de la feuille, pas celles de la fenêtre :
+ // la ligne 520 est servie comme A520, pas comme A20.
+ expect(win.html).toContain('data-cell="A520"');
+ expect(win.html).toContain('Operation 519');
+ expect(win.html).not.toContain('data-cell="A1"');
+ });
+});
diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs
index 6e58711..adbbb29 100644
--- a/tests/frontend/xlsx-viewer.test.mjs
+++ b/tests/frontend/xlsx-viewer.test.mjs
@@ -7,6 +7,7 @@
* workbook gets 409 `xlsx_lossy_content`, asks for confirmation and
* 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.
*
* Usage: node tests/frontend/xlsx-viewer.test.mjs
*/
@@ -116,14 +117,14 @@ const sheetHtml = (value) =>
`
| 1 | ${value} |
` +
"