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. -[![Version](https://img.shields.io/badge/Version-2.35.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.36.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -976,8 +976,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l ## 📝 Changelog -Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.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. -[![Version](https://img.shields.io/badge/Version-2.35.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.36.0-blue.svg)]() [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) [![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/) [![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/) @@ -1151,8 +1151,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE) ## 📝 Changelog -See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.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()