diff --git a/CHANGELOG.md b/CHANGELOG.md
index bcb04b6..1ad390f 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.33.0**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.34.0**.
---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [2.34.0] — 2026-09-28
+
+---
+
## [2.33.0] — 2026-09-28
---
diff --git a/README.fr.md b/README.fr.md
index be17edc..a207018 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.33.0).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.34.0).
---
-*Projet : ObsiGate | Version : 2.33.0 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.34.0 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index e1f55f4..dee9f60 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.33.0).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.34.0).
---
-*Project: ObsiGate | Version: 2.33.0 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.34.0 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index 3afbaeb..dc15918 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.33.0
+2.34.0
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index 6fffc16..ae655e2 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.33.0"
+version = "2.34.0"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 6a6386e..a3eef3f 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.33.0"
+version = "2.34.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 464c6df..ac22010 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.33.0",
+ "version": "2.34.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 6cc3aa3..647e80c 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.33.0 | **Dernière mise à jour :** 2026-09-28
+> **Version :** 2.34.0 | **Dernière mise à jour :** 2026-09-28
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js
index 1fce999..984603b 100644
--- a/frontend/js/viewer.js
+++ b/frontend/js/viewer.js
@@ -1077,6 +1077,12 @@ export function renderXlsxViewer(area, data) {
${lossWarning}
+
+ A1
+
+
+
${panels}
`;
@@ -1158,21 +1164,135 @@ export function renderXlsxViewer(area, data) {
});
};
+ // ── #153 A7 — keyboard navigation & formula bar ────────────────────────
+ // One active cell per viewer: clicking or arrowing into a cell shows its
+ // A1 name in the bar; the input mirrors the cell text and typing there
+ // edits the cell live (Enter commits, Escape reverts, then re-focuses).
+ let activeTd = null;
+ const activeCellEl = area.querySelector("#xlsx-active-cell");
+ const formulaInput = area.querySelector("#xlsx-formula-input");
+ const syncing = { value: false }; // guard against input-event feedback loops
+
+ const cellName = (td) => td?.dataset.cell || "";
+
+ const setActiveCell = (td) => {
+ activeTd = td || null;
+ if (area.querySelector("td.xlsx-active")) area.querySelector("td.xlsx-active").classList.remove("xlsx-active");
+ if (!td) {
+ activeCellEl.textContent = "—";
+ formulaInput.value = "";
+ formulaInput.disabled = true;
+ return;
+ }
+ td.classList.add("xlsx-active");
+ activeCellEl.textContent = cellName(td);
+ formulaInput.disabled = false;
+ formulaInput.value = td.textContent;
+ };
+
+ const syncFormulaBar = (td) => {
+ if (td !== activeTd) return;
+ if (!syncing.value) formulaInput.value = td.textContent;
+ };
+
+ // Parse an A1 reference into its (row, col) parts.
+ const parseRef = (ref) => {
+ const m = /^([A-Z]+)(\d+)$/.exec(ref || "");
+ if (!m) return null;
+ let col = 0;
+ for (const ch of m[1]) col = col * 26 + (ch.charCodeAt(0) - 64);
+ return { row: Number(m[2]), col };
+ };
+
+ const findTd = (panel, row, col) =>
+ panel.querySelector(`td[data-cell="${columnName(col)}${row}"]`);
+
+ const columnName = (col) => {
+ let name = "";
+ while (col > 0) {
+ const rem = (col - 1) % 26;
+ name = String.fromCharCode(65 + rem) + name;
+ col = Math.floor((col - 1) / 26);
+ }
+ return name;
+ };
+
+ const moveActive = (td, key, forward = true) => {
+ const ref = parseRef(cellName(td));
+ if (!ref) return;
+ let { row, col } = ref;
+ if (key === "Tab") col += forward ? 1 : -1;
+ else if (key === "ArrowRight") col += 1;
+ else if (key === "ArrowLeft") col -= 1;
+ else if (key === "ArrowDown") row += 1;
+ else if (key === "ArrowUp") row -= 1;
+ if (row < 1 || col < 1) return;
+ const panel = td.closest(".xlsx-panel");
+ const next = findTd(panel, row, col);
+ if (!next) return; // edge of the rendered window: no wrap
+ td.blur();
+ next.focus();
+ setActiveCell(next);
+ };
+
+ formulaInput.addEventListener("input", () => {
+ if (!activeTd) return;
+ syncing.value = true;
+ activeTd.textContent = formulaInput.value;
+ syncing.value = false;
+ activeTd.classList.add("xlsx-dirty");
+ refreshSaveState();
+ });
+ formulaInput.addEventListener("keydown", (e) => {
+ if (!activeTd) return;
+ if (e.key === "Enter") {
+ e.preventDefault();
+ activeTd.blur();
+ activeTd.focus();
+ } else if (e.key === "Escape") {
+ e.preventDefault();
+ activeTd.textContent = activeTd.dataset.orig;
+ activeTd.classList.remove("xlsx-dirty");
+ syncFormulaBar(activeTd);
+ refreshSaveState();
+ activeTd.focus();
+ }
+ });
+
const setupCell = (td) => {
td.contentEditable = "true";
td.spellcheck = false;
+ // Focusable without a pointing device: JSDOM requires it to fire focus
+ // events on contenteditable cells, and a keyboard user tabbing into the
+ // table from outside lands on the first cell thanks to it.
+ td.tabIndex = 0;
td.dataset.orig = td.textContent;
td.addEventListener("input", () => {
td.classList.add("xlsx-dirty");
+ syncFormulaBar(td);
refreshSaveState();
});
+ td.addEventListener("focus", () => setActiveCell(td));
td.addEventListener("keydown", (e) => {
- if (e.key === "Enter") { e.preventDefault(); td.blur(); }
+ if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); td.blur(); }
if (e.key === "Escape") {
td.textContent = td.dataset.orig;
td.classList.remove("xlsx-dirty");
+ syncFormulaBar(td);
refreshSaveState();
}
+ if (e.key === "Tab" || e.key === "ArrowUp" || e.key === "ArrowDown"
+ || e.key === "ArrowLeft" || e.key === "ArrowRight") {
+ // Excel-like: arrows and Tab move to the neighbour cell. Direction is
+ // the key's own (Left/Up = back, Right/Down = forward); Tab follows
+ // Shift. Prevented so Tab never leaves the table and arrows never
+ // move the caret (they move the SELECTION instead).
+ e.preventDefault();
+ const forward = e.key === "Tab"
+ ? !e.shiftKey
+ : (e.key === "ArrowRight" || e.key === "ArrowDown");
+ moveActive(td, e.key, forward);
+ }
});
td.addEventListener("paste", (e) => {
e.preventDefault();
@@ -1187,9 +1307,11 @@ export function renderXlsxViewer(area, data) {
el.title = t("xlsx.cached_value_title");
});
- // Editable cells: Enter blurs, Escape reverts, paste stays single-line.
+ // Editable cells: Enter blurs, Escape reverts, arrows/Tab navigate.
area.querySelectorAll(".xlsx-table td").forEach(setupCell);
panelEls.forEach(wireLazyRows);
+ // The formula bar starts disabled: nothing is selected yet.
+ setActiveCell(null);
area.querySelectorAll(".xlsx-tab").forEach((tab) => {
tab.addEventListener("click", () => {
@@ -1253,6 +1375,7 @@ export function renderXlsxViewer(area, data) {
td.classList.remove("xlsx-dirty");
td.dataset.orig = td.textContent;
});
+ if (activeTd) syncFormulaBar(activeTd);
refreshSaveState();
showToast(t("editor.saved"), "success");
} catch (err) {
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 2fcb663..3ec3332 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -1836,6 +1836,8 @@
"xlsx.load_more": "Load more",
"xlsx.loading_more": "Loading…",
"xlsx.load_error": "Could not load the remaining rows",
+ "xlsx.formula_bar_placeholder": "Active cell content",
+ "xlsx.active_cell": "Cell",
"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 ae8f524..59ef5b6 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -1836,6 +1836,8 @@
"xlsx.load_more": "Charger la suite",
"xlsx.loading_more": "Chargement…",
"xlsx.load_error": "Chargement de la suite impossible",
+ "xlsx.formula_bar_placeholder": "Contenu de la cellule active",
+ "xlsx.active_cell": "Cellule",
"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 179db2e..455ac76 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -10996,6 +10996,56 @@ body.desktop-mode .editor-container {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: -2px;
}
+/* #153 A7 — the active cell keeps its outline even when focus moves to the
+ formula bar, so the user never loses track of what the bar edits. */
+.xlsx-table td.xlsx-active:not(:focus) {
+ outline: 2px dashed var(--accent, #4a90d9);
+ outline-offset: -2px;
+}
+
+/* #153 A7 — formula bar under the toolbar: [ A1 | > | input ] */
+.xlsx-formula-bar {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ margin-bottom: 8px;
+}
+.xlsx-active-cell {
+ min-width: 52px;
+ padding: 4px 8px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--surface);
+ color: var(--text-primary);
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ font-size: 0.8rem;
+ text-align: center;
+ font-variant-numeric: tabular-nums;
+}
+.xlsx-formula-sep {
+ width: 14px;
+ height: 14px;
+ flex: 0 0 auto;
+ color: var(--text-muted);
+}
+.xlsx-formula-input {
+ flex: 1;
+ min-width: 0;
+ padding: 5px 10px;
+ border: 1px solid var(--border);
+ border-radius: 4px;
+ background: var(--surface);
+ color: var(--text-primary);
+ font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
+ font-size: 0.82rem;
+}
+.xlsx-formula-input:focus {
+ outline: 2px solid var(--accent, #4a90d9);
+ outline-offset: -2px;
+}
+.xlsx-formula-input:disabled {
+ opacity: 0.55;
+}
.xlsx-table td.xlsx-dirty {
background: rgba(255, 196, 0, 0.18);
}
diff --git a/package.json b/package.json
index c6ef765..f2099d1 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsigate",
- "version": "2.33.0",
+ "version": "2.34.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 0de9ac4..2908481 100644
--- a/tests/e2e/xlsx-viewer.spec.js
+++ b/tests/e2e/xlsx-viewer.spec.js
@@ -231,4 +231,28 @@ test.describe('Excel viewer — troncature et navigation (#153 A8/A9)', () => {
// Tout est chargé → le pied de page est masqué.
await expect(foot).toBeHidden();
});
+
+ test('barre de formule et navigation clavier (#153 A7)', async ({ page }) => {
+ await login(page);
+ await openXlsx(page, LARGE);
+
+ // Un clic sur une cellule active la barre avec son nom et son contenu.
+ const cell = page.locator('#content-area td[data-cell="B2"]');
+ await cell.click();
+ await expect(page.locator('#xlsx-active-cell')).toHaveText('B2');
+ const bar = page.locator('#xlsx-formula-input');
+ await expect(bar).toHaveValue(/Operation 1/);
+
+ // Les flèches déplacent la cellule active.
+ await cell.press('ArrowDown');
+ await expect(page.locator('#xlsx-active-cell')).toHaveText('B3');
+ await page.locator('#content-area td[data-cell="B3"]').press('ArrowRight');
+ await expect(page.locator('#xlsx-active-cell')).toHaveText('C3');
+
+ // Éditer depuis la barre marque la cellule dirty, Échap annule.
+ await bar.fill('Operation 2 modifiee');
+ await expect(page.locator('#content-area td[data-cell="C3"]')).toHaveClass(/xlsx-dirty/);
+ await page.locator('#content-area td[data-cell="C3"]').press('Escape');
+ await expect(page.locator('#content-area td.xlsx-dirty')).toHaveCount(0);
+ });
});
diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs
index 4b243d8..f3607d6 100644
--- a/tests/frontend/xlsx-viewer.test.mjs
+++ b/tests/frontend/xlsx-viewer.test.mjs
@@ -11,6 +11,8 @@
* - 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.
+ * - A7 : formula bar mirrors the active cell; arrows/Tab navigate; editing
+ * from the bar marks the cell dirty; Escape reverts.
*
* Usage: node tests/frontend/xlsx-viewer.test.mjs
*/
@@ -121,8 +123,9 @@ await initI18n();
const sheetHtml = (value) =>
'
' +
- '
A
' +
- `
1
${value}
` +
+ '
A
B
' +
+ `
1
${value}
B1
` +
+ '
2
A2
B2
' +
"
";
// The JSDOM fetch double serves locale files; everything else is a recorded
@@ -414,6 +417,80 @@ await test("a failed window fetch keeps the footnote and shows an error toast",
assert.equal(area.querySelector(".xlsx-load-more").classList.contains("done"), true);
});
+// ── A7 — formula bar & keyboard navigation ──────────────────────────────────
+
+await test("the formula bar starts empty and disabled", () => {
+ const area = mount();
+ assert.equal(area.querySelector("#xlsx-active-cell").textContent, "—");
+ assert.equal(area.querySelector("#xlsx-formula-input").disabled, true);
+});
+
+await test("focusing a cell shows its name and content in the bar", () => {
+ const area = mount();
+ const td = area.querySelector('td[data-cell="A1"]');
+ td.dispatchEvent(new w.Event("focus", { bubbles: false }));
+ // JSDOM does not run the default focus behaviour on dispatchEvent, so go
+ // through the real API:
+ td.focus();
+ assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
+ assert.equal(area.querySelector("#xlsx-formula-input").value, "100");
+ assert.equal(area.querySelector("#xlsx-formula-input").disabled, false);
+});
+
+await test("typing in the bar edits the cell live and marks it dirty", () => {
+ const area = mount();
+ const td = area.querySelector('td[data-cell="A1"]');
+ td.focus();
+ const input = area.querySelector("#xlsx-formula-input");
+ input.value = "depuis la barre";
+ input.dispatchEvent(new w.Event("input", { bubbles: true }));
+ assert.equal(td.textContent, "depuis la barre");
+ assert.equal(td.classList.contains("xlsx-dirty"), true);
+ assert.equal(area.querySelector("#xlsx-save-btn").disabled, false);
+ // The save payload carries the cell edit.
+ area.querySelector("#xlsx-save-btn").click();
+ return new Promise((r) => setTimeout(r, 5)).then(() => {
+ assert.deepEqual(calls[0].body.cells, { A1: "depuis la barre" });
+ });
+});
+
+await test("Tab and arrows move to the neighbour cell", () => {
+ const area = mount();
+ const a1 = area.querySelector('td[data-cell="A1"]');
+ a1.focus();
+ a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
+ assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B1");
+ const b1 = area.querySelector('td[data-cell="B1"]');
+ b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowDown", bubbles: true }));
+ assert.equal(area.querySelector("#xlsx-active-cell").textContent, "B2");
+ const b2 = area.querySelector('td[data-cell="B2"]');
+ b2.dispatchEvent(new w.KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }));
+ assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A2");
+});
+
+await test("Enter commits and Shift+Tab goes backwards", () => {
+ const area = mount();
+ const a1 = area.querySelector('td[data-cell="A1"]');
+ a1.focus();
+ a1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", bubbles: true }));
+ const b1 = area.querySelector('td[data-cell="B1"]');
+ b1.dispatchEvent(new w.KeyboardEvent("keydown", { key: "Tab", shiftKey: true, bubbles: true }));
+ assert.equal(area.querySelector("#xlsx-active-cell").textContent, "A1");
+});
+
+await test("a saved edit from the bar resets the dirty flag and orig value", async () => {
+ const area = mount();
+ const td = area.querySelector('td[data-cell="A1"]');
+ td.focus();
+ const input = area.querySelector("#xlsx-formula-input");
+ input.value = "200";
+ input.dispatchEvent(new w.Event("input", { bubbles: true }));
+ area.querySelector("#xlsx-save-btn").click();
+ await new Promise((r) => setTimeout(r, 5));
+ assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0);
+ assert.equal(td.dataset.orig, "200");
+});
+
await test("two windows in a row walk the whole sheet", async () => {
const area = mountTruncated({ total: 1200 });
apiQueue.push({