diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8825061..a3a4c24 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.35.0**.
+> [Unreleased](#unreleased). La dernière version livrée est **2.36.0**.
---
@@ -14,6 +14,10 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
+## [2.36.0] — 2026-09-28
+
+---
+
## [2.35.0] — 2026-09-28
---
diff --git a/README.fr.md b/README.fr.md
index 79313b4..1c79b42 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.35.0).
+Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.36.0).
---
-*Projet : ObsiGate | Version : 2.35.0 | Dernière mise à jour : Septembre 2026*
+*Projet : ObsiGate | Version : 2.36.0 | Dernière mise à jour : Septembre 2026*
diff --git a/README.md b/README.md
index a6c5634..4e6827e 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.35.0).
+See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.36.0).
---
-*Project: ObsiGate | Version: 2.35.0 | Last updated: September 2026*
+*Project: ObsiGate | Version: 2.36.0 | Last updated: September 2026*
diff --git a/VERSION b/VERSION
index aa5388f..3a05135 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2.35.0
+2.36.0
diff --git a/backend/routers/files_write.py b/backend/routers/files_write.py
index 9c22f97..1b2fc72 100644
--- a/backend/routers/files_write.py
+++ b/backend/routers/files_write.py
@@ -64,6 +64,9 @@ from backend.services.mutations import (
from backend.services.mutations import (
move_path as service_move_path,
)
+from backend.services.mutations import (
+ mutate_xlsx_structure as service_mutate_xlsx_structure,
+)
from backend.services.mutations import (
rename_directory as service_rename_directory,
)
@@ -174,6 +177,61 @@ def api_file_xlsx_save(
return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": result["size"]}
+@router.put("/api/file/{vault_name}/xlsx/structure", response_model=FileSaveResponse)
+def api_file_xlsx_structure(
+ vault_name: str,
+ path: str = Query(..., description="Relative path to the .xlsx file"),
+ body: dict = Body(
+ ...,
+ description=(
+ '{"actions": [{"op": "sheet_add", "name": "X"}, '
+ '{"op": "row_insert", "sheet": "X", "at": 2, "count": 1}], '
+ '"force": false}'
+ ),
+ ),
+ current_user=Depends(require_auth),
+):
+ """Apply structural changes to an .xlsx workbook (#153 A14).
+
+ ``actions`` is an ordered list applied in one locked, atomic rewrite:
+ ``sheet_add`` (``name``, optional ``at`` 0-based), ``sheet_rename``
+ (``from``/``to``), ``sheet_delete`` (refused on the last sheet),
+ ``sheet_duplicate`` (``name``/``as``) and ``row_insert``/``row_delete``/
+ ``col_insert``/``col_delete`` (``sheet``, 1-based ``at``, ``count``).
+
+ Without ``force`` the call fails **409** ``xlsx_lossy_content`` when the
+ workbook carries features openpyxl cannot rewrite (same gate as the cell
+ edits). A backup is created before the archive is replaced.
+
+ Args:
+ vault_name: Name of the vault.
+ path: Relative path to the ``.xlsx`` file.
+ body: JSON body with ``actions`` (1 to 50) and optional ``force``.
+
+ Returns:
+ ``FileSaveResponse`` confirming the write.
+ """
+ if not check_vault_access(vault_name, current_user):
+ raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
+
+ actions = body.get("actions")
+ if not isinstance(actions, list) or not actions or len(actions) > 50:
+ raise HTTPException(status_code=400, detail="Actions invalides (1 à 50 par requête)")
+ raw_force = body.get("force", False)
+ if not isinstance(raw_force, bool):
+ raise HTTPException(status_code=400, detail="Flag invalide: force")
+
+ result = service_mutate_xlsx_structure(
+ vault_name, path, actions, force=raw_force
+ )
+ log_file_save(
+ current_user["username"], vault_name, path,
+ len(actions),
+ current_user.get("_request_ip", "unknown"),
+ )
+ return {"status": "ok", "vault": result["vault"], "path": result["path"], "size": len(result["applied"])}
+
+
@router.delete("/api/file/{vault_name}", response_model=FileDeleteResponse)
async def api_file_delete(vault_name: str, path: str = Query(..., description="Relative path to file"), current_user=Depends(require_auth)):
"""Delete a file from the vault.
diff --git a/backend/services/mutations.py b/backend/services/mutations.py
index 590aec6..7d1b32a 100644
--- a/backend/services/mutations.py
+++ b/backend/services/mutations.py
@@ -455,6 +455,193 @@ def edit_xlsx_cells(
}
+def mutate_xlsx_structure(
+ vault_name: str,
+ path: str,
+ actions: list[dict[str, Any]],
+ *,
+ backup: bool = True,
+ force: bool = False,
+) -> dict[str, Any]:
+ """Apply structural changes to an ``.xlsx`` workbook (#153 A14).
+
+ ``actions`` is an ordered list — the workbook is loaded once and every
+ action is applied in sequence inside the same per-file lock and the same
+ atomic replace, so a half-applied batch can never reach the disk:
+
+ * ``{"op": "sheet_add", "name": "X", "at": 1}`` — new sheet (at =
+ optional 0-based position);
+ * ``{"op": "sheet_rename", "from": "X", "to": "Y"}``;
+ * ``{"op": "sheet_delete", "name": "X"}`` — refused when it is the
+ last sheet (an openpyxl workbook must keep one);
+ * ``{"op": "sheet_duplicate", "name": "X", "as": "Y"}`` — values,
+ styles and merged ranges are copied (not the data-dependent objects);
+ * ``{"op": "row_insert"|"row_delete"|"col_insert"|"col_delete",
+ "sheet": "X", "at": N, "count": k}`` — 1-based position, default 1.
+
+ All of it rides the same guards as the cell edits (P0): per-file lock,
+ ``.tmp`` + ``os.replace`` atomic write and the ``force`` gate on lossy
+ round-trips. The UI proposes these actions with an explicit confirmation
+ — deletions are NOT recoverable from the viewer (only via the ``.bak``).
+ """
+ root = get_vault_root(vault_name)
+ _ensure_writable(root)
+ file_path = resolve_safe_path(root, path)
+
+ if not file_path.exists() or not file_path.is_file():
+ raise ServiceError(
+ f"File not found: {path}",
+ code="not_found",
+ status=404,
+ details={"vault": vault_name, "path": path},
+ )
+
+ if not actions or len(actions) > 50:
+ raise ServiceError(
+ "Invalid actions (1 to 50 per request)", code="invalid", status=400
+ )
+
+ if not force:
+ from backend.xlsx_reader import inspect_workbook
+
+ lossy = inspect_workbook(file_path)
+ if lossy:
+ raise ServiceError(
+ "Restructuring this workbook would drop features ObsiGate "
+ "cannot preserve; retry with force=true after confirmation",
+ code="xlsx_lossy_content",
+ status=409,
+ details={"path": path, "features": lossy},
+ )
+
+ with _xlsx_write_lock(str(file_path)):
+ from openpyxl import load_workbook
+ from openpyxl.worksheet.copier import WorksheetCopy
+
+ try:
+ wb = load_workbook(file_path)
+ except Exception as exc:
+ raise ServiceError(
+ f"Cannot open workbook: {exc}", code="invalid", status=400
+ ) from exc
+
+ rel_path = _rel(root, file_path)
+ applied: list[str] = []
+ try:
+ for i, action in enumerate(actions):
+ op = action.get("op")
+ try:
+ if op == "sheet_add":
+ name = str(action.get("name", "")).strip()
+ if not name or name in wb.sheetnames:
+ raise ServiceError(
+ f"Nom de feuille invalide ou déjà pris: {name!r}",
+ code="invalid", status=400,
+ )
+ ws = wb.create_sheet(name[:31])
+ at = action.get("at")
+ # create_sheet appends at the end: shift left by the
+ # distance between the last index and the target.
+ if isinstance(at, int) and 0 <= at < len(wb.sheetnames):
+ wb.move_sheet(ws, offset=at - (len(wb.sheetnames) - 1))
+ applied.append(f"sheet_add:{ws.title}")
+ elif op == "sheet_rename":
+ src, dst = str(action.get("from", "")), str(action.get("to", "")).strip()
+ if src not in wb.sheetnames or not dst or dst in wb.sheetnames:
+ raise ServiceError(
+ f"Renommage invalide: {src!r} -> {dst!r}",
+ code="invalid", status=400,
+ )
+ wb[src].title = dst[:31]
+ applied.append(f"sheet_rename:{src}->{dst}")
+ elif op == "sheet_delete":
+ name = str(action.get("name", ""))
+ if name not in wb.sheetnames:
+ raise ServiceError(
+ f"Feuille introuvable: {name}", code="invalid", status=400
+ )
+ if len(wb.sheetnames) <= 1:
+ raise ServiceError(
+ "Impossible de supprimer la dernière feuille",
+ code="invalid", status=400,
+ )
+ del wb[name]
+ applied.append(f"sheet_delete:{name}")
+ elif op == "sheet_duplicate":
+ name = str(action.get("name", ""))
+ new_name = str(action.get("as", "")).strip()
+ if name not in wb.sheetnames or not new_name or new_name in wb.sheetnames:
+ raise ServiceError(
+ f"Duplication invalide: {name!r} -> {new_name!r}",
+ code="invalid", status=400,
+ )
+ # WorksheetCopy is the documented dup path (openpyxl
+ # 3.1); it copies values, styles and merges — not
+ # charts/images, which openpyxl itself cannot clone.
+ copy = wb.create_sheet(new_name[:31])
+ WorksheetCopy(wb[name], copy).copy_worksheet()
+ applied.append(f"sheet_duplicate:{name}->{copy.title}")
+ elif op in ("row_insert", "row_delete", "col_insert", "col_delete"):
+ sheet = str(action.get("sheet", ""))
+ if sheet not in wb.sheetnames:
+ raise ServiceError(
+ f"Feuille introuvable: {sheet}", code="invalid", status=400
+ )
+ ws = wb[sheet]
+ at = action.get("at", 1)
+ count = action.get("count", 1)
+ if not isinstance(at, int) or at < 1 or not isinstance(count, int) or count < 1:
+ raise ServiceError(
+ "Position 'at' / 'count' invalides", code="invalid", status=400
+ )
+ if op == "row_insert":
+ ws.insert_rows(at, count)
+ elif op == "row_delete":
+ ws.delete_rows(at, count)
+ elif op == "col_insert":
+ ws.insert_cols(at, count)
+ else:
+ ws.delete_cols(at, count)
+ applied.append(f"{op}:{sheet}@{at}x{count}")
+ else:
+ raise ServiceError(
+ f"Action inconnue: {op!r}", code="invalid", status=400
+ )
+ except ServiceError:
+ raise
+ except Exception as exc:
+ raise ServiceError(
+ f"Action {i + 1} ({op}) a échoué: {exc}",
+ code="invalid", status=400,
+ ) from exc
+ except ServiceError:
+ wb.close()
+ raise
+
+ if backup:
+ create_backup(file_path, vault_name, rel_path)
+
+ tmp_path = file_path.with_name(f"{file_path.name}.{os.getpid()}.tmp")
+ try:
+ wb.save(tmp_path)
+ os.replace(tmp_path, file_path)
+ except Exception:
+ tmp_path.unlink(missing_ok=True)
+ wb.close()
+ raise
+ wb.close()
+
+ logger.info(
+ f"XLSX structure: {vault_name}/{rel_path} {applied}"
+ )
+ return {
+ "success": True,
+ "vault": vault_name,
+ "path": rel_path,
+ "applied": applied,
+ }
+
+
def append_to_file(
vault_name: str,
path: str,
diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock
index 3ea1257..b53fa82 100644
--- a/desktop/Cargo.lock
+++ b/desktop/Cargo.lock
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
-version = "2.35.0"
+version = "2.36.0"
dependencies = [
"chrono",
"env_logger",
diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml
index 3dd2301..d65de8b 100644
--- a/desktop/Cargo.toml
+++ b/desktop/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
-version = "2.35.0"
+version = "2.36.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 1aab8c6..9931ba5 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.35.0",
+ "version": "2.36.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 772dcf5..f08aaaf 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
-> **Version :** 2.35.0 | **Dernière mise à jour :** 2026-09-28
+> **Version :** 2.36.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 8f7e68e..be11a41 100644
--- a/frontend/js/viewer.js
+++ b/frontend/js/viewer.js
@@ -1077,6 +1077,9 @@ export function renderXlsxViewer(area, data) {
+
${lossWarning}
@@ -1557,6 +1560,109 @@ export function renderXlsxViewer(area, data) {
runFind();
});
+ // ── #153 A14 — workbook structure menu (sheets, rows, columns) ─────────
+ // Every action is an explicit user gesture (prompt/confirm) and goes to
+ // PUT …/xlsx/structure — one locked, atomic rewrite with a backup.
+ const visibleSheetIndex = () =>
+ Number((visiblePanel() || panelEls[0])?.dataset.sheet) || 0;
+
+ const putStructure = async (actions, force = false) => {
+ await api(
+ `/api/file/${encodeURIComponent(data.vault)}/xlsx/structure?path=${encodeURIComponent(data.path)}`,
+ {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ actions, force }),
+ },
+ );
+ showToast(t("xlsx.structure_saved"), "success");
+ // Re-render from the server so the viewer shows the new structure.
+ const fresh = await api(
+ `/api/file/${encodeURIComponent(data.vault)}?path=${encodeURIComponent(data.path)}`,
+ );
+ renderXlsxViewer(area, fresh);
+ };
+
+ const structureError = (err, retryActions) => {
+ if (err && err.code === "xlsx_lossy_content") {
+ const features = (err.details && err.details.features) || lossy;
+ const labels = features.map((f) => t("xlsx.feature_" + f)).join(", ");
+ if (confirm(t("xlsx.lossy_confirm", { features: labels }))) {
+ return putStructure(retryActions, true); // re-emitted with force
+ }
+ showToast(t("xlsx.lossy_cancelled"), "info");
+ return null;
+ }
+ showToast(`${t("xlsx.structure_error")}: ${err.message || err}`, "error");
+ return null;
+ };
+
+ // Structure menu: built on demand, positioned under the button.
+ area.querySelector("#xlsx-structure-btn").addEventListener("click", (e) => {
+ const old = area.querySelector(".xlsx-structure-menu");
+ if (old) { old.remove(); return; }
+ const idx = visibleSheetIndex();
+ const sheetName = sheets[idx]?.name || "";
+ const activeRef = cellName(activeTd && activeTd.closest(".xlsx-panel") === visiblePanel() ? activeTd : null);
+ const parsed = parseRef(activeRef);
+ const menu = document.createElement("div");
+ menu.className = "xlsx-structure-menu";
+ const item = (label, fn) => {
+ const b = document.createElement("button");
+ b.type = "button";
+ b.className = "btn-action xlsx-structure-item";
+ b.textContent = label;
+ b.addEventListener("click", () => { menu.remove(); fn(); });
+ menu.appendChild(b);
+ };
+ item(t("xlsx.sheet_add"), async () => {
+ const name = prompt(t("xlsx.structure_prompt_add"));
+ if (!name) return;
+ const actions = [{ op: "sheet_add", name }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ item(t("xlsx.sheet_rename"), async () => {
+ const to = prompt(t("xlsx.structure_prompt_rename"), sheetName);
+ if (!to || to === sheetName) return;
+ const actions = [{ op: "sheet_rename", from: sheetName, to }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ item(t("xlsx.sheet_duplicate"), async () => {
+ const as = prompt(t("xlsx.structure_prompt_add"), `${sheetName} (copie)`);
+ if (!as) return;
+ const actions = [{ op: "sheet_duplicate", name: sheetName, as }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ item(t("xlsx.sheet_delete"), async () => {
+ if (sheets.length <= 1) { showToast(t("xlsx.last_sheet"), "info"); return; }
+ if (!confirm(t("xlsx.structure_confirm_delete_sheet", { name: sheetName }))) return;
+ const actions = [{ op: "sheet_delete", name: sheetName }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ if (parsed) {
+ menu.appendChild(Object.assign(document.createElement("div"), { className: "xlsx-structure-sep" }));
+ item(t("xlsx.row_insert"), async () => {
+ const actions = [{ op: "row_insert", sheet: sheetName, at: parsed.row }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ item(t("xlsx.row_delete"), async () => {
+ if (!confirm(t("xlsx.structure_confirm_row", { n: parsed.row }))) return;
+ const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ item(t("xlsx.col_insert"), async () => {
+ const actions = [{ op: "col_insert", sheet: sheetName, at: parsed.col }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ item(t("xlsx.col_delete"), async () => {
+ if (!confirm(t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }))) return;
+ const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }];
+ try { await putStructure(actions); } catch (err) { structureError(err, actions); }
+ });
+ }
+ e.target.closest(".xlsx-toolbar").appendChild(menu);
+ });
+
// CSV export of the visible sheet (post-trim, pre-save data).
area.querySelector("#xlsx-csv-btn").addEventListener("click", () => {
const panel = visiblePanel();
diff --git a/frontend/locales/en.json b/frontend/locales/en.json
index 5d8390d..25d8814 100644
--- a/frontend/locales/en.json
+++ b/frontend/locales/en.json
@@ -1850,6 +1850,24 @@
"xlsx.sort_applied": "Sort applied on {col} — display only, the workbook is unchanged",
"xlsx.sort_reset": "Reset sort and filter",
"xlsx.filter_placeholder": "Filter rows…",
+ "xlsx.structure_btn": "Sheet structure",
+ "xlsx.structure_title": "Edit the workbook structure",
+ "xlsx.sheet_add": "Add a sheet",
+ "xlsx.sheet_rename": "Rename the current sheet",
+ "xlsx.sheet_duplicate": "Duplicate the current sheet",
+ "xlsx.sheet_delete": "Delete the current sheet",
+ "xlsx.row_insert": "Insert a row above",
+ "xlsx.row_delete": "Delete the active cell's row",
+ "xlsx.col_insert": "Insert a column to the left",
+ "xlsx.col_delete": "Delete the active cell's column",
+ "xlsx.structure_prompt_add": "Name of the new sheet:",
+ "xlsx.structure_prompt_rename": "New name of the sheet:",
+ "xlsx.structure_confirm_delete_sheet": "Permanently delete the sheet “{name}”? This changes the file (a backup is created).",
+ "xlsx.structure_confirm_row": "Delete row {n}? This changes the file (a backup is created).",
+ "xlsx.structure_confirm_col": "Delete column {n}? This changes the file (a backup is created).",
+ "xlsx.structure_saved": "Structure updated",
+ "xlsx.structure_error": "Could not change the structure",
+ "xlsx.last_sheet": "The last sheet cannot be deleted",
"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 dbb7edd..7b49203 100644
--- a/frontend/locales/fr.json
+++ b/frontend/locales/fr.json
@@ -1850,6 +1850,24 @@
"xlsx.sort_applied": "Tri appliqué sur {col} — l'affichage seul, le classeur n'est pas modifié",
"xlsx.sort_reset": "Réinitialiser le tri et le filtre",
"xlsx.filter_placeholder": "Filtrer les lignes…",
+ "xlsx.structure_btn": "Structure de la feuille",
+ "xlsx.structure_title": "Modifier la structure du classeur",
+ "xlsx.sheet_add": "Ajouter une feuille",
+ "xlsx.sheet_rename": "Renommer la feuille courante",
+ "xlsx.sheet_duplicate": "Dupliquer la feuille courante",
+ "xlsx.sheet_delete": "Supprimer la feuille courante",
+ "xlsx.row_insert": "Insérer une ligne au-dessus",
+ "xlsx.row_delete": "Supprimer la ligne de la cellule active",
+ "xlsx.col_insert": "Insérer une colonne à gauche",
+ "xlsx.col_delete": "Supprimer la colonne de la cellule active",
+ "xlsx.structure_prompt_add": "Nom de la nouvelle feuille :",
+ "xlsx.structure_prompt_rename": "Nouveau nom de la feuille :",
+ "xlsx.structure_confirm_delete_sheet": "Supprimer définitivement la feuille « {name} » ? Cette action modifie le fichier (un backup est créé).",
+ "xlsx.structure_confirm_row": "Supprimer la ligne {n} ? Cette action modifie le fichier (un backup est créé).",
+ "xlsx.structure_confirm_col": "Supprimer la colonne {n} ? Cette action modifie le fichier (un backup est créé).",
+ "xlsx.structure_saved": "Structure mise à jour",
+ "xlsx.structure_error": "Modification de la structure impossible",
+ "xlsx.last_sheet": "Impossible de supprimer la dernière feuille",
"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 69a9f66..e241fe5 100644
--- a/frontend/style.css
+++ b/frontend/style.css
@@ -10931,6 +10931,7 @@ body.desktop-mode .editor-container {
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
+ position: relative; /* anchors the A14 structure menu */
}
.xlsx-toolbar-actions {
margin-left: auto;
@@ -11090,6 +11091,41 @@ body.desktop-mode .editor-container {
outline: 2px solid var(--accent, #4a90d9);
outline-offset: 1px;
}
+
+/* #153 A14 — structure menu (sheets / rows / columns) */
+.xlsx-structure-menu {
+ position: absolute;
+ z-index: 30;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+ min-width: 240px;
+ margin-top: 4px;
+ padding: 6px;
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ background: var(--surface);
+ box-shadow: 0 8px 24px var(--shadow, rgba(0, 0, 0, 0.25));
+}
+.xlsx-structure-item {
+ text-align: left;
+ border: none;
+ background: transparent;
+ color: var(--text-primary);
+}
+.xlsx-structure-item:hover {
+ background: var(--bg-secondary);
+}
+.xlsx-structure-sep {
+ height: 1px;
+ margin: 4px 0;
+ background: var(--border);
+}
+
+/* JSDOM shims for the tests that click anchors */
+mark {
+ font: inherit;
+}
.xlsx-table td.xlsx-dirty {
background: rgba(255, 196, 0, 0.18);
}
diff --git a/package.json b/package.json
index d28e23c..15eb3a0 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "obsigate",
- "version": "2.35.0",
+ "version": "2.36.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/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs
index a3c970e..1d8fd41 100644
--- a/tests/frontend/xlsx-viewer.test.mjs
+++ b/tests/frontend/xlsx-viewer.test.mjs
@@ -15,6 +15,8 @@
* from the bar marks the cell dirty; Escape reverts.
* - A13 : header click sorts the rendered rows, the filter hides rows, the
* find highlights matches, CSV export downloads the visible sheet.
+ * - A14 : the structure menu sends one PUT …/xlsx/structure with the action,
+ * then re-renders from the server; destructive actions confirm first.
*
* Usage: node tests/frontend/xlsx-viewer.test.mjs
*/
@@ -576,6 +578,76 @@ await test("CSV export downloads the visible sheet without the cached shadows",
assert.equal(anchor.download, "Fruits.csv");
});
+// ── A14 — structure menu ───────────────────────────────────────────────────
+
+await test("sheet_add asks for a name, PUTs the action and re-renders", async () => {
+ const area = mount();
+ // The prompt is resolved through the module scope: stub it globally.
+ const realPrompt = globalThis.prompt;
+ globalThis.prompt = () => "Feuille 2";
+ apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT
+ apiQueue.push({
+ ok: true, status: 200,
+ body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille 2", html: sheetHtml("neuf") }], xlsx_lossy_features: [] },
+ }); // re-read
+ area.querySelector("#xlsx-structure-btn").click();
+ const items = [...area.querySelectorAll(".xlsx-structure-item")];
+ const addBtn = items.find((b) => b.textContent === FR["xlsx.sheet_add"]);
+ addBtn.click();
+ await new Promise((r) => setTimeout(r, 5));
+ globalThis.prompt = realPrompt;
+ assert.equal(calls.length, 2);
+ assert.match(calls[0].url, /\/xlsx\/structure\?path=data\.xlsx/);
+ assert.deepEqual(calls[0].body.actions, [{ op: "sheet_add", name: "Feuille 2" }]);
+ assert.equal(calls[0].body.force, false);
+ // The viewer re-rendered from the server payload (a single sheet → no tabs).
+ assert.ok(
+ area.querySelector("#content-area, .xlsx-viewer") || area,
+ "the viewer was rebuilt",
+ );
+ assert.ok(
+ area.querySelector('td[data-cell="A1"]')?.textContent === "neuf",
+ "the re-render shows the fresh payload",
+ );
+});
+
+await test("sheet_delete confirms and is refused on the last sheet", async () => {
+ const area = mount();
+ const delBtn = () => {
+ area.querySelector("#xlsx-structure-btn").click();
+ const items = [...area.querySelectorAll(".xlsx-structure-item")];
+ const b = items.find((x) => x.textContent === FR["xlsx.sheet_delete"]);
+ b.click();
+ };
+ // One sheet only → blocked before even confirming (no network call).
+ delBtn();
+ assert.equal(calls.length, 0, "nothing sent: last sheet");
+});
+
+await test("the 409 lossy flow re-emits with force after confirmation", async () => {
+ const area = mount();
+ // The prompt is resolved through the module scope: stub it globally.
+ const realPrompt = globalThis.prompt;
+ globalThis.prompt = () => "Feuille 2";
+ apiQueue.push({
+ ok: false, status: 409,
+ body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } },
+ });
+ apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // retry w/ force
+ apiQueue.push({
+ ok: true, status: 200,
+ body: { is_xlsx: true, vault: "V", path: "data.xlsx", xlsx_sheets: [{ name: "Feuille1", html: sheetHtml("1") }], xlsx_lossy_features: [] },
+ });
+ area.querySelector("#xlsx-structure-btn").click();
+ const items = [...area.querySelectorAll(".xlsx-structure-item")];
+ items.find((b) => b.textContent === FR["xlsx.sheet_add"]).click();
+ await new Promise((r) => setTimeout(r, 10));
+ globalThis.prompt = realPrompt;
+ assert.equal(confirmCalls, 1, "the user is asked about the lossy write");
+ assert.equal(calls.length, 3);
+ assert.equal(calls[1].body.force, true);
+});
+
await test("two windows in a row walk the whole sheet", async () => {
const area = mountTruncated({ total: 1200 });
apiQueue.push({
diff --git a/tests/test_xlsx_structure.py b/tests/test_xlsx_structure.py
new file mode 100644
index 0000000..24ad4f5
--- /dev/null
+++ b/tests/test_xlsx_structure.py
@@ -0,0 +1,188 @@
+"""Structural mutations of an .xlsx workbook (#153 A14): service + endpoint.
+
+Covers sheet add/rename/delete/duplicate and row/col insert/delete, the
+atomicity of the batch (one locked rewrite) and the shared P0 guards
+(lossy 409 gate, backup, path safety).
+"""
+
+from __future__ import annotations
+
+import zipfile
+from pathlib import Path
+
+import pytest
+
+openpyxl = pytest.importorskip("openpyxl")
+
+VAULT = "TestVault"
+
+
+@pytest.fixture
+def book(test_vault_dir: str) -> str:
+ path = Path(test_vault_dir) / "struct.xlsx"
+ wb = openpyxl.Workbook()
+ ws = wb.active
+ ws.title = "Data"
+ ws.append(["Nom", "Valeur"])
+ ws.append(["a", 1])
+ ws.append(["b", 2])
+ wb.create_sheet("Vide")
+ wb.save(path)
+ return str(path)
+
+
+def _put(client, path="struct.xlsx", actions=None, **extra):
+ return client.put(
+ f"/api/file/{VAULT}/xlsx/structure",
+ params={"path": path},
+ json={"actions": actions, **extra},
+ )
+
+
+def _wb(path):
+ wb = openpyxl.load_workbook(path)
+ try:
+ return wb
+ finally:
+ pass
+
+
+class TestSheetOps:
+ def test_add_rename_delete_sheet(self, client, book):
+ resp = _put(client, actions=[
+ {"op": "sheet_add", "name": "Extra", "at": 0},
+ {"op": "sheet_rename", "from": "Vide", "to": "Renommée"},
+ ])
+ assert resp.status_code == 200
+ wb = openpyxl.load_workbook(book)
+ assert wb.sheetnames[0] == "Extra" # inserted at position 0
+ assert "Renommée" in wb.sheetnames and "Vide" not in wb.sheetnames
+ wb.close()
+
+ resp = _put(client, actions=[{"op": "sheet_delete", "name": "Extra"}])
+ assert resp.status_code == 200
+ wb = openpyxl.load_workbook(book)
+ assert "Extra" not in wb.sheetnames
+ wb.close()
+
+ def test_delete_last_sheet_refused(self, client, test_vault_dir):
+ (Path(test_vault_dir) / "solo.xlsx").write_bytes(book_bytes("Solo"))
+ resp = _put(client, path="solo.xlsx", actions=[
+ {"op": "sheet_delete", "name": "Solo"},
+ ])
+ assert resp.status_code == 400
+
+ def test_duplicate_copies_values(self, client, book):
+ resp = _put(client, actions=[
+ {"op": "sheet_duplicate", "name": "Data", "as": "Data copie"},
+ ])
+ assert resp.status_code == 200
+ wb = openpyxl.load_workbook(book)
+ assert wb["Data copie"]["A1"].value == "Nom"
+ assert wb["Data copie"]["B3"].value == 2
+ wb.close()
+
+
+def book_bytes(sheet_name: str) -> bytes:
+ import io
+
+ wb = openpyxl.Workbook()
+ wb.active.title = sheet_name
+ buf = io.BytesIO()
+ wb.save(buf)
+ return buf.getvalue()
+
+
+class TestRowColOps:
+ def test_row_insert_shifts_and_delete_removes(self, client, book):
+ resp = _put(client, actions=[
+ {"op": "row_insert", "sheet": "Data", "at": 2, "count": 1},
+ ])
+ assert resp.status_code == 200
+ wb = openpyxl.load_workbook(book)
+ ws = wb["Data"]
+ assert ws["A2"].value is None # the new blank row
+ assert ws["A3"].value == "a" # shifted down
+ wb.close()
+
+ resp = _put(client, actions=[
+ {"op": "row_delete", "sheet": "Data", "at": 2, "count": 1},
+ ])
+ assert resp.status_code == 200
+ wb = openpyxl.load_workbook(book)
+ assert wb["Data"]["A2"].value == "a"
+ wb.close()
+
+ def test_col_insert_and_delete(self, client, book):
+ assert _put(client, actions=[
+ {"op": "col_insert", "sheet": "Data", "at": 2},
+ ]).status_code == 200
+ wb = openpyxl.load_workbook(book)
+ assert wb["Data"]["B1"].value is None
+ assert wb["Data"]["C1"].value == "Valeur"
+ wb.close()
+
+ assert _put(client, actions=[
+ {"op": "col_delete", "sheet": "Data", "at": 2},
+ ]).status_code == 200
+ wb = openpyxl.load_workbook(book)
+ assert wb["Data"]["B1"].value == "Valeur"
+ wb.close()
+
+
+class TestGuards:
+ def test_unknown_sheet_is_400(self, client, book):
+ resp = _put(client, actions=[{"op": "row_insert", "sheet": "Nope", "at": 1}])
+ assert resp.status_code == 400
+
+ def test_unknown_op_is_400(self, client, book):
+ resp = _put(client, actions=[{"op": "sheet_explode", "name": "X"}])
+ assert resp.status_code == 400
+
+ def test_bad_position_is_400(self, client, book):
+ resp = _put(client, actions=[
+ {"op": "row_insert", "sheet": "Data", "at": "deux"},
+ ])
+ assert resp.status_code == 400
+
+ def test_empty_actions_is_400(self, client, book):
+ assert _put(client, actions=[]).status_code == 400
+
+ def test_lossy_workbook_refused_without_force(self, client, test_vault_dir):
+ """Same 409 gate as the cell edits (A1)."""
+ path = Path(test_vault_dir) / "lossy-struct.xlsx"
+ wb = openpyxl.Workbook()
+ wb.active.title = "S"
+ wb["S"]["A1"] = "=A2" # no cached value -> add one via the raw XML
+ wb.save(path)
+ with zipfile.ZipFile(path) as zf:
+ items = {n: zf.read(n) for n in zf.namelist()}
+ sheet = next(n for n in items if n.startswith("xl/worksheets/sheet"))
+ items[sheet] = items[sheet].decode("utf-8").replace(
+ "A2", "A27"
+ ).encode("utf-8")
+ with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as zf:
+ for name, blob in items.items():
+ zf.writestr(name, blob)
+
+ resp = _put(client, path="lossy-struct.xlsx", actions=[
+ {"op": "sheet_add", "name": "X"},
+ ])
+ assert resp.status_code == 409
+ assert resp.json()["code"] == "xlsx_lossy_content"
+
+ resp = _put(client, path="lossy-struct.xlsx", actions=[
+ {"op": "sheet_add", "name": "X"},
+ ], force=True)
+ assert resp.status_code == 200
+
+ def test_atomicity_one_bad_action_writes_nothing(self, client, book):
+ """A batch with a valid action followed by a bad one writes nothing."""
+ resp = _put(client, actions=[
+ {"op": "sheet_add", "name": "Temp"},
+ {"op": "sheet_delete", "name": "Inexistante"},
+ ])
+ assert resp.status_code == 400
+ wb = openpyxl.load_workbook(book)
+ assert "Temp" not in wb.sheetnames
+ wb.close()