diff --git a/CHANGELOG.md b/CHANGELOG.md index 63f2baa..d51dfcc 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.40.0**. +> [Unreleased](#unreleased). La dernière version livrée est **2.41.0**. --- @@ -14,6 +14,24 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html). --- +## [2.41.0] — 2026-09-29 + +### Ajouté + +- **#154 — Refonte UI/UX de la visionneuse XLSX (Lot 2) : dialogues thémés et conflits non + bloquants.** + Les `confirm()` / `prompt()` natifs sont remplacés par des **dialogues intégrés au thème** + (`showConfirm()` / `showPrompt()` dans `frontend/js/ui.js`, promise-based, réutilisant + `.obsigate-modal-*`) pour toutes les actions de structure du classeur (ajouter / renommer / + dupliquer / supprimer une feuille, insérer / supprimer une ligne ou une colonne) et la + confirmation de perte (`409 xlsx_lossy_content`). Un **conflit d'écriture** (`409 conflict`) + n'interrompt plus l'utilisateur : un **bandeau non bloquant** propose de réessayer en + conservant les modifications. Le bouton **Enregistrer** et l'onglet de la feuille concernée + signalent les modifications non sauvegardées. Tests JSDOM `tests/frontend/xlsx-viewer.test.mjs` + (42) et E2E `tests/e2e/xlsx-viewer.spec.js` (9) adaptés. Aucun changement backend. + +--- + ## [2.40.0] — 2026-09-29 ### Ajouté diff --git a/README.fr.md b/README.fr.md index 59004c5..51a959c 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.40.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.41.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.40.0). +Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.41.0). --- -*Projet : ObsiGate | Version : 2.40.0 | Dernière mise à jour : Septembre 2026* +*Projet : ObsiGate | Version : 2.41.0 | Dernière mise à jour : Septembre 2026* diff --git a/README.md b/README.md index 768c4db..4f54f38 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.40.0-blue.svg)]() +[![Version](https://img.shields.io/badge/Version-2.41.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.40.0). +See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.41.0). --- -*Project: ObsiGate | Version: 2.40.0 | Last updated: September 2026* +*Project: ObsiGate | Version: 2.41.0 | Last updated: September 2026* diff --git a/VERSION b/VERSION index 770060b..2d4c52e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.40.0 +2.41.0 diff --git a/desktop/Cargo.lock b/desktop/Cargo.lock index dc84c8b..aecdfb9 100644 --- a/desktop/Cargo.lock +++ b/desktop/Cargo.lock @@ -2626,7 +2626,7 @@ dependencies = [ [[package]] name = "obsigate-desktop" -version = "2.40.0" +version = "2.41.0" dependencies = [ "chrono", "env_logger", diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 3489b66..a51b65c 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "obsigate-desktop" -version = "2.40.0" +version = "2.41.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 ccfcc24..03cb799 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.40.0", + "version": "2.41.0", "identifier": "com.obsigate.desktop", "build": { "frontendDist": "../frontend", diff --git a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md index aea9b23..e958122 100644 --- a/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md +++ b/docs/GUIDES/RECHERCHE_PDF_EXCALIDRAW.md @@ -159,10 +159,15 @@ réécrire : **valeurs calculées** mises en cache par Excel, segments (slicers), chronologies, contrôles de formulaire, connexions/requêtes, XML personnalisé, signature numérique, commentaires enrichis, macros. L'ouverture affiche alors un bandeau qui les liste, et la première -sauvegarde demande confirmation. Si vous refusez, rien n'est écrit. +sauvegarde demande confirmation dans une fenêtre intégrée au thème de +l'application. Si vous refusez, rien n'est écrit. > Les **graphiques, images et tableaux croisés** sont, eux, bien conservés. +Si le classeur est modifié ailleurs entre-temps (verrou concurrent), ObsiGate +n'interrompt pas votre travail : un bandeau vous propose de **réessayer** +l'enregistrement, vos modifications restant en place. + ### Formules Par sécurité, une valeur saisie commençant par `=` ou `@` est **stockée comme diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index ad915b6..bc9ea0d 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -1,6 +1,6 @@ # ObsiGate — Roadmap -> **Version :** 2.40.0 | **Dernière mise à jour :** 2026-09-29 +> **Version :** 2.41.0 | **Dernière mise à jour :** 2026-09-29 > **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)** @@ -97,10 +97,11 @@ ### 154. Refonte UI/UX de la visionneuse & éditeur XLSX (ruban, grille, inspecteur) - **Effort :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) | **Impact :** 🟡 -- **Statut :** 🔵 **en cours** — **Lot 1 livré le 2026-09-29** (ruban de commandes groupé, onglets - de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non - recalculées, tokens de grille et affordances). Lots suivants : dialogues thémés, inspecteur - droit (dashboard + IA), découpage modulaire + undo/redo. +- **Statut :** 🔵 **en cours** — **Lots 1 & 2 livrés le 2026-09-29** (ruban de commandes groupé, + onglets de feuilles permanents avec bouton « + », badges d'état lecture seule / formules non + recalculées, tokens de grille et affordances ; dialogues thémés `showConfirm`/`showPrompt`, + bandeau de conflit 409 non bloquant, indicateur *dirty*). Lots suivants : inspecteur droit + (dashboard + IA), découpage modulaire + undo/redo. - **Analyse, architecture cible et plan par lots :** [features/xlsx-ui-redesign.md](./features/xlsx-ui-redesign.md) - **Description :** la visionneuse XLSX (#152/#153) est fonctionnelle mais peu conviviale : commandes à plat sans hiérarchie, en-têtes de grille indistincts des cellules, états avancés @@ -110,7 +111,7 @@ - **Sous-tâches :** - [x] **A1** Coquille : barre de commandes groupée, onglets feuilles permanents + « + », badges d'état, tokens de grille et affordances visuelles (Lot 1) - - [ ] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2) + - [x] **A2** Dialogues thémés (modales + toasts) et feedback non bloquant des conflits 409 (Lot 2) - [ ] **A3** Inspecteur droit repliable : Tableau de bord + Assistant IA, lien dashboard ⇄ grille (Lot 3) - [ ] **A4** Découpage `frontend/js/xlsx/*` + undo/redo + défilement via `IntersectionObserver` (Lot 4) diff --git a/docs/features/xlsx-ui-redesign.md b/docs/features/xlsx-ui-redesign.md index f2623c7..7a9b008 100644 --- a/docs/features/xlsx-ui-redesign.md +++ b/docs/features/xlsx-ui-redesign.md @@ -2,8 +2,8 @@ > **Item de roadmap :** [#154 — Refonte UI/UX tableur](../ROADMAP.md) > **Origine :** #152 / #153 (visionneuse XLSX fonctionnelle mais peu conviviale) -> **Statut :** 🔵 **en cours** — Lot 1 livré le 2026-09-29 -> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 · Lot 3 · Lot 4) +> **Statut :** 🔵 **en cours** — Lots 1 & 2 livrés le 2026-09-29 +> **Effort estimé :** 6-9 jours (Lot 1 ✅ · Lot 2 ✅ · Lot 3 · Lot 4) > **Règle de maintenance :** la Roadmap porte les cases à cocher (suivi), cette fiche porte > l'analyse, l'architecture cible et le plan par lots. **Ne pas dupliquer le détail.** @@ -68,12 +68,16 @@ la grille ni le backend. `--grid-border`, `--grid-zebra`) déclinés dark/light + affordances (en-têtes clairs, zébrage, survol, cellule active solide, cellule *dirty* prioritaire au survol). -### Lot 2 — Dialogues thémés & feedback (à venir) +### Lot 2 — Dialogues thémés & feedback ✅ *(2026-09-29)* -- Remplacer `confirm()`/`prompt()` par des **modales thémées** (`.obsigate-modal-*`) et des - **toasts** : structure (feuille/ligne/colonne), perte (409 `xlsx_lossy_content`), conflit - (409 `conflict`) en bandeau non bloquant. -- Indicateur *dirty* sur l'onglet de feuille et la barre. +- **A2.1** Helpers génériques **`showConfirm()` / `showPrompt()`** (`frontend/js/ui.js`), promise-based, + réutilisant les classes `.obsigate-modal-*` (fini `window.confirm()` / `window.prompt()`). +- **A2.2** La visionneuse XLSX utilise ces dialogues pour les actions de structure (ajouter / + renommer / dupliquer / supprimer feuille, insérer / supprimer ligne et colonne) et pour la + confirmation de perte (409 `xlsx_lossy_content`). +- **A2.3** **Conflit de sauvegarde (409 `conflict`)** : bandeau **non bloquant** `.xlsx-banner-conflict` + avec bouton **Réessayer** — les modifications sont conservées. +- **A2.4** Indicateur *dirty* sur le bouton **Enregistrer** et sur l'onglet de la feuille concernée. ### Lot 3 — Inspecteur droit (à venir) @@ -116,3 +120,4 @@ la grille ni le backend. | Date | Événement | |---|---| | 2026-09-29 | Audit UX (3 problèmes) + architecture cible + plan par lots ; **Lot 1** livré (ruban groupé, onglets permanents + « + », badges d'état, tokens de grille) | +| 2026-09-29 | **Lot 2** livré : dialogues thémés (`showConfirm`/`showPrompt`) pour la structure et la confirmation de perte, bandeau de conflit 409 non bloquant avec réessai, indicateur *dirty* (bouton + onglet) | diff --git a/frontend/js/ui.js b/frontend/js/ui.js index 743c5c7..b70aab3 100644 --- a/frontend/js/ui.js +++ b/frontend/js/ui.js @@ -1143,6 +1143,88 @@ const FileOperations = { }; +// --------------------------------------------------------------------------- +// Generic themed dialogs (#154-A2) +// --------------------------------------------------------------------------- +// Promise-based replacements for window.confirm() / window.prompt() so the +// Excel viewer's structure actions and lossy-write confirmations stay inside +// the app theme (and are keyboard accessible) instead of native dialogs. + +function _closeDialog(overlay, resolve, value) { + overlay.classList.remove("active"); + if (overlay._onKey) document.removeEventListener("keydown", overlay._onKey); + setTimeout(() => overlay.remove(), 200); + resolve(value); +} + +function _openDialog(innerHtml) { + const overlay = document.createElement("div"); + overlay.className = "obsigate-modal-overlay"; + const modal = document.createElement("div"); + modal.className = "obsigate-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.innerHTML = innerHtml; + overlay.appendChild(modal); + document.body.appendChild(overlay); + setTimeout(() => overlay.classList.add("active"), 10); + return { overlay, modal }; +} + +/** Themed replacement for window.confirm(). Resolves to a boolean. */ +export function showConfirm({ title = "", message = "", confirmLabel = "", cancelLabel = "", danger = false } = {}) { + return new Promise((resolve) => { + const { overlay, modal } = _openDialog(` +

${escapeHtml(title)}

+
+ `); + const done = (v) => _closeDialog(overlay, resolve, v); + overlay.addEventListener("click", (e) => { if (e.target === overlay) done(false); }); + modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(true)); + modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(false)); + overlay._onKey = (e) => { + if (e.key === "Escape") done(false); + else if (e.key === "Enter") done(true); + }; + document.addEventListener("keydown", overlay._onKey); + setTimeout(() => modal.querySelector('[data-dialog="confirm"]')?.focus(), 20); + }); +} + +/** Themed replacement for window.prompt(). Resolves to the string (or null). */ +export function showPrompt({ title = "", message = "", value = "", placeholder = "", confirmLabel = "", cancelLabel = "" } = {}) { + return new Promise((resolve) => { + const { overlay, modal } = _openDialog(` +

${escapeHtml(title)}

+
+ +
+ `); + const input = modal.querySelector('[data-dialog="input"]'); + const done = (v) => _closeDialog(overlay, resolve, v); + overlay.addEventListener("click", (e) => { if (e.target === overlay) done(null); }); + modal.querySelector('[data-dialog="confirm"]').addEventListener("click", () => done(input.value)); + modal.querySelector('[data-dialog="cancel"]').addEventListener("click", () => done(null)); + overlay._onKey = (e) => { + if (e.key === "Escape") done(null); + else if (e.key === "Enter") done(input.value); + }; + document.addEventListener("keydown", overlay._onKey); + setTimeout(() => { input.focus(); input.select(); }, 20); + }); +} + + // --------------------------------------------------------------------------- // Find in Page Manager // --------------------------------------------------------------------------- diff --git a/frontend/js/viewer.js b/frontend/js/viewer.js index 04ce807..a70f375 100644 --- a/frontend/js/viewer.js +++ b/frontend/js/viewer.js @@ -3,7 +3,7 @@ import { api, AuthManager } from './auth.js'; import { state } from './state.js'; import { escapeHtml, safeCreateIcons, safeHighlight, getFileIcon, openEditor, copyToClipboard, activateInlineEditor, detachInlineEditor } from './utils.js'; import { isInlineEditorActive, queryEditor } from './editor-inline.js'; -import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard } from './ui.js'; +import { TabManager, closeMobileSidebar, ContextMenuManager, RightSidebarManager, showToast, buildFrontmatterCard, showConfirm, showPrompt } from './ui.js'; import { syncActiveFileTreeItem, searchByTag, TagFilterService, refreshSidebarTreePreservingState, focusPathInSidebar } from './sidebar.js'; import { AutocompleteDropdown, performAdvancedSearch } from './search.js'; import { initDashboardTabs } from './sync.js'; @@ -1266,7 +1266,41 @@ export function renderXlsxViewer(area, data) { const saveBtn = area.querySelector("#xlsx-save-btn"); const panelEls = [...area.querySelectorAll(".xlsx-panel")]; const dirtyCount = () => area.querySelectorAll("td.xlsx-dirty").length; - const refreshSaveState = () => { saveBtn.disabled = dirtyCount() === 0; }; + const refreshSaveState = () => { + const dirty = dirtyCount() > 0; + saveBtn.disabled = !dirty; + saveBtn.classList.toggle("is-dirty", dirty); + // #154-A2 — star a sheet tab that holds unsaved edits. + panelEls.forEach((panel) => { + const tab = area.querySelector(`.xlsx-tab[data-sheet="${panel.dataset.sheet}"]`); + if (tab) tab.classList.toggle("xlsx-tab-dirty", panel.querySelectorAll("td.xlsx-dirty").length > 0); + }); + }; + + // #154-A2 — non-blocking conflict banner (409 `conflict`): the edits are kept + // and the user can retry the save without losing their work. + const bannerHost = document.createElement("div"); + bannerHost.className = "xlsx-banner-host"; + area.querySelector(".xlsx-panels").before(bannerHost); + const showConflict = (msg) => { + bannerHost.innerHTML = ""; + const banner = document.createElement("div"); + banner.className = "xlsx-banner xlsx-banner-conflict"; + banner.setAttribute("role", "alert"); + banner.innerHTML = ` + ${escapeHtml(msg)} + + `; + banner.querySelector(".xlsx-banner-retry").addEventListener("click", () => { + bannerHost.innerHTML = ""; + saveBtn.click(); + }); + banner.querySelector(".xlsx-banner-dismiss").addEventListener("click", () => { + bannerHost.innerHTML = ""; + }); + bannerHost.appendChild(banner); + safeCreateIcons(); + }; // #153 A9bis — the first render stops at MAX_ROWS/MAX_COLS; the tail is // fetched window by window from GET …/xlsx/sheet when the user reaches the @@ -1570,7 +1604,13 @@ export function renderXlsxViewer(area, data) { if (err && err.code === "xlsx_lossy_content" && !lossyConfirmed) { 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 }))) throw err; + const proceed = await showConfirm({ + title: t("xlsx.lossy_title"), + message: t("xlsx.lossy_confirm", { features: labels }), + confirmLabel: t("xlsx.lossy_confirm_btn"), + danger: true, + }); + if (!proceed) throw err; lossyConfirmed = true; force = true; continue; @@ -1591,6 +1631,9 @@ export function renderXlsxViewer(area, data) { // A refused confirmation is a decision, not a failure: neutral toast. if (err && err.code === "xlsx_lossy_content") { showToast(t("xlsx.lossy_cancelled"), "info"); + } else if (err && err.code === "conflict") { + // #154-A2 — non-blocking: the edits are kept, the user may retry. + showConflict(t("xlsx.conflict_msg")); } else { showToast(`${t("editor.save_error")}: ${err.message || err}`, "error"); } @@ -1774,13 +1817,17 @@ export function renderXlsxViewer(area, data) { renderXlsxViewer(area, fresh); }; - const structureError = (err, retryActions) => { + const structureError = async (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 - } + const proceed = await showConfirm({ + title: t("xlsx.lossy_title"), + message: t("xlsx.lossy_confirm", { features: labels }), + confirmLabel: t("xlsx.lossy_confirm_btn"), + danger: true, + }); + if (proceed) return putStructure(retryActions, true); // re-emitted with force showToast(t("xlsx.lossy_cancelled"), "info"); return null; } @@ -1788,11 +1835,14 @@ export function renderXlsxViewer(area, data) { return null; }; - // #154-A1 — the “+” button mirrors the structure menu's « Ajouter une + // #154-A1/A2 — the “+” button mirrors the structure menu's « Ajouter une // feuille » item, right where the tabs are (Excel-like). const tabAddBtn = area.querySelector("#xlsx-tab-add"); if (tabAddBtn) tabAddBtn.addEventListener("click", async () => { - const name = prompt(t("xlsx.structure_prompt_add")); + const name = await showPrompt({ + title: t("xlsx.sheet_add"), + message: t("xlsx.structure_prompt_add"), + }); if (!name) return; const actions = [{ op: "sheet_add", name }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } @@ -1820,26 +1870,43 @@ export function renderXlsxViewer(area, data) { menu.appendChild(b); }; item(t("xlsx.sheet_add"), async () => { - const name = prompt(t("xlsx.structure_prompt_add")); + const name = await showPrompt({ + title: t("xlsx.sheet_add"), + message: 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); + const to = await showPrompt({ + title: t("xlsx.sheet_rename"), + message: t("xlsx.structure_prompt_rename"), + value: 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)`); + const as = await showPrompt({ + title: t("xlsx.sheet_duplicate"), + message: t("xlsx.structure_prompt_add"), + value: `${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 okDelete = await showConfirm({ + title: t("xlsx.sheet_delete"), + message: t("xlsx.structure_confirm_delete_sheet", { name: sheetName }), + confirmLabel: t("xlsx.sheet_delete"), + danger: true, + }); + if (!okDelete) return; const actions = [{ op: "sheet_delete", name: sheetName }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); @@ -1850,7 +1917,13 @@ export function renderXlsxViewer(area, data) { 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 okRow = await showConfirm({ + title: t("xlsx.row_delete"), + message: t("xlsx.structure_confirm_row", { n: parsed.row }), + confirmLabel: t("xlsx.row_delete"), + danger: true, + }); + if (!okRow) return; const actions = [{ op: "row_delete", sheet: sheetName, at: parsed.row }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); @@ -1859,7 +1932,13 @@ export function renderXlsxViewer(area, data) { 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 okCol = await showConfirm({ + title: t("xlsx.col_delete"), + message: t("xlsx.structure_confirm_col", { n: columnName(parsed.col) }), + confirmLabel: t("xlsx.col_delete"), + danger: true, + }); + if (!okCol) return; const actions = [{ op: "col_delete", sheet: sheetName, at: parsed.col }]; try { await putStructure(actions); } catch (err) { structureError(err, actions); } }); diff --git a/frontend/locales/en.json b/frontend/locales/en.json index f2ebfa0..b78efc8 100644 --- a/frontend/locales/en.json +++ b/frontend/locales/en.json @@ -1827,6 +1827,9 @@ "xlsx.lossy_hint": "ObsiGate cannot preserve these elements: saving will ask for your confirmation.", "xlsx.lossy_confirm": "Save anyway? The following will be lost: {features}", "xlsx.lossy_cancelled": "Save cancelled", + "xlsx.lossy_confirm_btn": "Save anyway", + "xlsx.conflict_msg": "The workbook was changed elsewhere in the meantime. Your edits are kept: retry the save.", + "xlsx.conflict_retry": "Retry", "xlsx.formula_toggle_title": "Treat “=” and “@” as formulas (off by default)", "xlsx.cached_value_title": "Last value calculated by Excel", "xlsx.tabs_add_sheet": "Add a sheet", diff --git a/frontend/locales/fr.json b/frontend/locales/fr.json index b1c14fd..d7891b4 100644 --- a/frontend/locales/fr.json +++ b/frontend/locales/fr.json @@ -1827,6 +1827,9 @@ "xlsx.lossy_hint": "Ces éléments ne peuvent pas être conservés par ObsiGate : une sauvegarde vous demandera confirmation.", "xlsx.lossy_confirm": "Enregistrer quand même ? Les éléments suivants seront perdus : {features}", "xlsx.lossy_cancelled": "Sauvegarde annulée", + "xlsx.lossy_confirm_btn": "Enregistrer quand même", + "xlsx.conflict_msg": "Le classeur a été modifié ailleurs entre-temps. Vos modifications sont conservées : réessayez l'enregistrement.", + "xlsx.conflict_retry": "Réessayer", "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.tabs_add_sheet": "Ajouter une feuille", diff --git a/frontend/style.css b/frontend/style.css index 88eafeb..dafa80d 100644 --- a/frontend/style.css +++ b/frontend/style.css @@ -11051,6 +11051,55 @@ body.desktop-mode .editor-container { .xlsx-status-formula { color: var(--accent); } +/* #154-A2 — unsaved-change feedback: the primary button and the owning tab. */ +.xlsx-save-primary.is-dirty { + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 35%, transparent); +} +.xlsx-tab-dirty::after { + content: "•"; + margin-left: 5px; + color: var(--warning, #e0a800); +} +/* #154-A2 — non-blocking banners (conflict, …). */ +.xlsx-banner-host { + display: flex; + flex-direction: column; + gap: 8px; +} +.xlsx-banner { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border: 1px solid var(--border); + border-left: 3px solid var(--accent); + border-radius: 4px; + background: var(--surface); + color: var(--text-secondary); + font-size: 0.82rem; +} +.xlsx-banner-conflict { + border-left-color: var(--warning, #e0a800); +} +.xlsx-banner-icon { + width: 16px; + height: 16px; + flex: 0 0 auto; + color: var(--warning, #e0a800); +} +.xlsx-banner-text { + flex: 1; + min-width: 0; +} +.xlsx-banner-dismiss { + padding: 2px 8px; +} +.modal-confirm-text { + color: var(--text-primary); + font-size: 0.9rem; + line-height: 1.5; + white-space: pre-line; +} .xlsx-table { background: var(--grid-bg); } diff --git a/package.json b/package.json index 176efef..27c658b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsigate", - "version": "2.40.0", + "version": "2.41.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 2908481..d055ce6 100644 --- a/tests/e2e/xlsx-viewer.spec.js +++ b/tests/e2e/xlsx-viewer.spec.js @@ -116,12 +116,6 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (# await login(page); await openFixture(page); - let dialogMessage = null; - page.on('dialog', async (dialog) => { - dialogMessage = dialog.message(); - await dialog.accept(); - }); - const cell = page.locator('#content-area td[data-cell="A2"]'); await cell.click(); await cell.fill('Total confirmé'); @@ -131,7 +125,11 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (# await expect(save).toBeEnabled(); await save.click(); - await expect.poll(() => dialogMessage, { timeout: 10000 }).toContain('segments'); + // #154-A2 — the lossy confirmation is a themed in-app dialog, not a native one. + const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal'); + await expect(dialog).toBeVisible({ timeout: 10000 }); + await expect(dialog).toContainText('segments'); + await dialog.locator('[data-dialog="confirm"]').click(); await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); // La cellule reste modifiée côté UI (plus de marque « sale »). @@ -145,13 +143,19 @@ test.describe('Excel viewer — garde-fous d\'écriture et valeurs calculées (# const toggle = page.locator('#xlsx-formula-btn'); await expect(toggle).toHaveAttribute('aria-pressed', 'false'); - // 409 → confirmation, puis reprise avec force (le toggle reste désactivé). - page.on('dialog', (dialog) => dialog.accept()); + // 409 → confirmation thémée, puis reprise avec force (le toggle reste désactivé). const cell = page.locator('#content-area td[data-cell="B2"]'); await cell.click(); await cell.fill('=B1*3'); await cell.press('Enter'); await page.locator('#xlsx-save-btn').click(); + const dialog = page.locator('.obsigate-modal-overlay .obsigate-modal'); + // A lossy workbook asks for confirmation. A previous test's save may already + // have dropped the slicers part, in which case the PUT succeeds directly. + await dialog.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {}); + if (await dialog.isVisible()) { + await dialog.locator('[data-dialog="confirm"]').click(); + } await expect(page.locator('.toast-success')).toBeVisible({ timeout: 10000 }); }); }); diff --git a/tests/frontend/xlsx-viewer.test.mjs b/tests/frontend/xlsx-viewer.test.mjs index 44236d0..f2eb5cd 100644 --- a/tests/frontend/xlsx-viewer.test.mjs +++ b/tests/frontend/xlsx-viewer.test.mjs @@ -105,6 +105,9 @@ async function test(name, fn) { if (apiQueue.length) return apiQueue.shift(); return { ok: true, status: 200, body: { status: "ok" } }; }; + // #154-A2 — themed dialogs live in document.body; clear any left behind by a + // previous test so the helpers always reach the current one. + document.querySelectorAll(".obsigate-modal-overlay").forEach((n) => n.remove()); try { await fn(); console.log(` ✓ ${name}`); @@ -165,6 +168,17 @@ function editCell(area, ref, text) { return td; } +/** #154-A2 — the themed dialog is the last overlay in document.body. */ +const lastDialog = () => { + const overlays = document.querySelectorAll(".obsigate-modal-overlay"); + return overlays[overlays.length - 1] || null; +}; +const clickDialog = (which) => { + const btn = lastDialog()?.querySelector(`[data-dialog="${which}"]`); + assert.ok(btn, `dialog button "${which}" is present`); + btn.click(); +}; + const lossyError = { ok: false, status: 409, @@ -248,12 +262,17 @@ await test("409 xlsx_lossy_content asks once then retries with force", async () nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); - assert.equal(confirmCalls, 1); + // #154-A2 — a themed dialog replaces window.confirm(), and no retry happens + // before the user answers. + const dialog = lastDialog(); + assert.ok(dialog, "a themed confirmation is shown"); + assert.ok(dialog.textContent.includes(FR["xlsx.feature_slicers"]), dialog.textContent); + assert.equal(calls.length, 1, "nothing is retried before the answer"); + clickDialog("confirm"); + await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 2); assert.equal(calls[0].body.force, false); assert.equal(calls[1].body.force, true); - // The prompt names the features the backend reported. - assert.ok(confirmPrompts[0].includes(FR["xlsx.feature_slicers"]), confirmPrompts[0]); // Save succeeded → cells are no longer dirty. assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); }); @@ -264,27 +283,52 @@ await test("confirming once is enough for the following saves", async () => { nextResponse = () => (calls.length === 1 ? lossyError : { ok: true, status: 200, body: {} }); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); + clickDialog("confirm"); + await new Promise((r) => setTimeout(r, 10)); editCell(area, "A1", "2"); area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); - assert.equal(confirmCalls, 1, "the user is not asked twice"); assert.equal(calls.length, 3); assert.equal(calls[2].body.force, true); + // Wait out the dialog close animation, then confirm none is left. + await new Promise((r) => setTimeout(r, 250)); + assert.equal(document.querySelectorAll(".obsigate-modal-overlay").length, 0, "the user is not asked twice"); }); await test("refusing the confirmation writes nothing and keeps the cells dirty", async () => { const area = mount({ lossy: ["slicers"] }); editCell(area, "A1", "250"); nextResponse = () => lossyError; - confirmAnswer = false; area.querySelector("#xlsx-save-btn").click(); await new Promise((r) => setTimeout(r, 10)); - assert.equal(confirmCalls, 1); + clickDialog("cancel"); + await new Promise((r) => setTimeout(r, 10)); assert.equal(calls.length, 1, "no retry after a refusal"); assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); assert.equal(area.querySelector("#xlsx-save-btn").disabled, false); }); +await test("a 409 conflict shows a non-blocking retry banner and keeps the edits", async () => { + const area = mount(); + editCell(area, "A1", "250"); + nextResponse = () => ({ ok: false, status: 409, body: { detail: "busy", code: "conflict" } }); + area.querySelector("#xlsx-save-btn").click(); + await new Promise((r) => setTimeout(r, 10)); + const banner = area.querySelector(".xlsx-banner-conflict"); + assert.ok(banner, "a conflict banner is shown"); + assert.ok(banner.textContent.includes(FR["xlsx.conflict_msg"]), banner.textContent); + // The edit is preserved and the save button is usable again. + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 1); + assert.equal(area.querySelector("#xlsx-save-btn").disabled, false); + // The retry re-runs the save. + nextResponse = () => ({ ok: true, status: 200, body: {} }); + banner.querySelector(".xlsx-banner-retry").click(); + await new Promise((r) => setTimeout(r, 10)); + assert.equal(calls.length, 2); + assert.equal(area.querySelectorAll("td.xlsx-dirty").length, 0); + assert.equal(area.querySelector(".xlsx-banner-conflict"), null, "banner cleared on retry"); +}); + await test("a non-409 failure is not retried", async () => { const area = mount(); editCell(area, "A1", "250"); @@ -582,29 +626,25 @@ await test("CSV export downloads the visible sheet without the cached shadows", 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"]); + const addBtn = [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]); addBtn.click(); await new Promise((r) => setTimeout(r, 5)); - globalThis.prompt = realPrompt; + // #154-A2 — a themed prompt replaces window.prompt(). + const input = lastDialog()?.querySelector('[data-dialog="input"]'); + assert.ok(input, "a themed prompt asks for the sheet name"); + input.value = "Feuille 2"; + clickDialog("confirm"); + await new Promise((r) => setTimeout(r, 10)); 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", @@ -626,9 +666,7 @@ await test("sheet_delete confirms and is refused on the last sheet", async () => 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"; + // The prompt is answered through the themed dialog. apiQueue.push({ ok: false, status: 409, body: { detail: "…", code: "xlsx_lossy_content", details: { features: ["slicers"] } }, @@ -639,11 +677,15 @@ await test("the 409 lossy flow re-emits with force after confirmation", async () 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(); + [...area.querySelectorAll(".xlsx-structure-item")].find((b) => b.textContent === FR["xlsx.sheet_add"]).click(); + await new Promise((r) => setTimeout(r, 5)); + lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2"; + clickDialog("confirm"); + await new Promise((r) => setTimeout(r, 10)); + const lossyDialog = lastDialog(); + assert.ok(lossyDialog && lossyDialog.textContent.includes(FR["xlsx.feature_slicers"]), "a lossy confirmation is shown"); + clickDialog("confirm"); 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); }); @@ -743,8 +785,6 @@ await test("a csv shows no tab strip and no + button", () => { await test("the + button adds a sheet through the structure endpoint", async () => { const area = mount(); - const realPrompt = globalThis.prompt; - globalThis.prompt = () => "Feuille 2"; apiQueue.push({ ok: true, status: 200, body: { status: "ok" } }); // PUT apiQueue.push({ ok: true, status: 200, @@ -752,7 +792,9 @@ await test("the + button adds a sheet through the structure endpoint", async () }); // re-read area.querySelector("#xlsx-tab-add").click(); await new Promise((r) => setTimeout(r, 5)); - globalThis.prompt = realPrompt; + lastDialog().querySelector('[data-dialog="input"]').value = "Feuille 2"; + clickDialog("confirm"); + await new Promise((r) => setTimeout(r, 10)); 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" }]);