diff --git a/docs/STORAGE_SCHEMA.md b/docs/STORAGE_SCHEMA.md index 4779a9f..6750400 100644 --- a/docs/STORAGE_SCHEMA.md +++ b/docs/STORAGE_SCHEMA.md @@ -24,21 +24,31 @@ Clés principales dans `kv` : "setting:autoBackup": { "enabled": false, "directoryHandle": null, - "lastBackup": "" + "lastBackup": "", + "lastBackupSignature": "", + "hasUnsavedChanges": false } } ``` -`setting:autoBackup` conserve le `FileSystemDirectoryHandle` choisi par l'utilisateur -et le dernier timestamp de réussite. Le handle n'est jamais inclus dans les exports. +`setting:autoBackup` conserve le `FileSystemDirectoryHandle` choisi par l'utilisateur, +le dernier timestamp de réussite, l'empreinte du dernier contenu sauvegardé et un +indicateur de modifications non sauvegardées. Ces valeurs ne sont jamais incluses +dans les exports. Les sauvegardes utilisent le format de l'export global, sont nommées avec la date locale du navigateur et conservent les dix fichiers `sokkog-global-YYYY-MM-DD.json` les plus récents. La vérification au démarrage ne demande pas de permission et ne crée pas les jours manquants lorsque l'application était fermée. Une nouvelle sauvegarde automatique est ignorée si son contenu est identique à la version la plus récente, en excluant uniquement l'horodatage d'export. Si le navigateur ne -conserve pas l'autorisation du dossier entre deux sessions, l'interface propose -une réautorisation explicite ; le démarrage n'ouvre aucune demande de permission. +conserve pas l'autorisation du dossier entre deux sessions, l'indicateur passe à +`true` après chaque écriture toolbox confirmée et évite de générer un export au +démarrage. Une alerte persistante propose alors la sauvegarde. Son bouton fournit +l'interaction nécessaire à la demande système ; une fois l'accès accordé, +l'application vérifie précisément le contenu réel du dossier avant toute écriture. +L'indicateur revient à `false` uniquement lorsque le contenu courant est sauvegardé +ou confirmé identique. Si l'autorisation est toujours accordée, cette vérification +reste silencieuse et démarre automatiquement. Entrée type dans `modules` : diff --git a/e2e/toolbox.spec.js b/e2e/toolbox.spec.js index 6aa09ef..4626b60 100644 --- a/e2e/toolbox.spec.js +++ b/e2e/toolbox.spec.js @@ -1,5 +1,5 @@ // Rôle : couvre les parcours navigateur principaux des toolboxes locales. -/* global window, CustomEvent */ +/* global window, navigator, CustomEvent, FileSystemDirectoryHandle */ import { expect, test } from "@playwright/test"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -249,6 +249,8 @@ test("opens the local automatic backup settings modal", async ({ page }) => { await expect(dialog).toBeVisible(); await expect(dialog.getByText("Cette fonctionnalité n’est pas disponible dans tous les navigateurs.")).toBeVisible(); await expect(dialog.getByText("Opera GX", { exact: true })).toBeVisible(); + await expect(dialog.getByText("Fonctionnement de l’autorisation", { exact: true })).toBeVisible(); + await expect(dialog.getByText("la popup système ne peut apparaître qu’après un clic de votre part.")).toBeVisible(); await expect(dialog.getByText("10 versions sont conservées.", { exact: true })).toBeVisible(); await expect(dialog.getByText("Dossier configuré :", { exact: true })).toBeVisible(); await expect(dialog.getByText("Aucun dossier", { exact: true })).toBeVisible(); @@ -258,6 +260,96 @@ test("opens the local automatic backup settings modal", async ({ page }) => { await expect(dialog).toHaveCount(0); }); +test("offers an actionable backup alert without requesting permission at startup", async ({ page }) => { + await page.evaluate(async () => { + const db = await import("/src/utils/indexedDbStorage.js"); + const directoryHandle = await navigator.storage.getDirectory(); + await db.setSetting("autoBackup", { + enabled: true, + directoryHandle, + lastBackup: "", + lastBackupSignature: "", + hasUnsavedChanges: false + }); + }); + await page.addInitScript(() => { + window.backupPermissionRequests = 0; + window.backupPermissionGranted = false; + Object.defineProperty(FileSystemDirectoryHandle.prototype, "queryPermission", { + configurable: true, + value: async () => window.backupPermissionGranted ? "granted" : "prompt" + }); + Object.defineProperty(FileSystemDirectoryHandle.prototype, "requestPermission", { + configurable: true, + value: async () => { + window.backupPermissionRequests += 1; + window.backupPermissionGranted = true; + return "granted"; + } + }); + }); + await page.reload(); + + await page.goto("/#/toolboxes"); + await expect(page.getByText("Des modifications ont été détectées dans vos toolboxes.")).toHaveCount(0); + await createToolbox(page, "Toolbox à sauvegarder"); + + const reminder = page.getByRole("status").filter({ hasText: "Des modifications ont été détectées dans vos toolboxes." }); + await expect(reminder).toBeVisible(); + await expect(page.getByRole("dialog", { name: "Sauvegarde automatique" })).toHaveCount(0); + expect(await page.evaluate(() => window.backupPermissionRequests)).toBe(0); + + await reminder.getByRole("button", { name: "Sauvegarder", exact: true }).click(); + await expect(reminder).toHaveCount(0); + expect(await page.evaluate(() => window.backupPermissionRequests)).toBe(1); + await expect.poll(async () => page.evaluate(async () => { + const db = await import("/src/utils/indexedDbStorage.js"); + return (await db.getSetting("autoBackup", {})).hasUnsavedChanges; + })).toBe(false); +}); + +test("backs up silently when folder access is still granted", async ({ page }) => { + await page.evaluate(async () => { + const db = await import("/src/utils/indexedDbStorage.js"); + const directoryHandle = await navigator.storage.getDirectory(); + for await (const [name, handle] of directoryHandle.entries()) { + if (handle.kind === "file" && /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/.test(name)) await directoryHandle.removeEntry(name); + } + await db.setSetting("autoBackup", { + enabled: true, + directoryHandle, + lastBackup: "", + lastBackupSignature: "", + hasUnsavedChanges: false + }); + }); + await page.addInitScript(() => { + window.backupPermissionRequests = 0; + Object.defineProperty(FileSystemDirectoryHandle.prototype, "queryPermission", { + configurable: true, + value: async () => "granted" + }); + Object.defineProperty(FileSystemDirectoryHandle.prototype, "requestPermission", { + configurable: true, + value: async () => { + window.backupPermissionRequests += 1; + return "granted"; + } + }); + }); + await page.reload(); + + await expect.poll(async () => page.evaluate(async () => { + const directoryHandle = await navigator.storage.getDirectory(); + for await (const [name, handle] of directoryHandle.entries()) { + if (handle.kind === "file" && /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/.test(name)) return true; + } + return false; + })).toBe(true); + await expect(page.getByText("Des modifications ont été détectées dans vos toolboxes.")).toHaveCount(0); + expect(await page.evaluate(() => window.backupPermissionRequests)).toBe(0); +}); + test("edits a table with formulas and persists values", async ({ page }) => { await createToolbox(page, "Table E2E Toolbox"); await addTool(page, "Tableau"); diff --git a/tests/auto-backup.test.mjs b/tests/auto-backup.test.mjs index b42da0b..eaf04a1 100644 --- a/tests/auto-backup.test.mjs +++ b/tests/auto-backup.test.mjs @@ -1,7 +1,16 @@ // Rôle : vérifie les règles de nommage, d’écriture et de rétention des sauvegardes locales. import { test } from "node:test"; import assert from "node:assert/strict"; -import { backupContentsEqual, getBackupFileName, getLocalDateKey, isFileSystemAccessSupported, saveBackup } from "../website/src/features/toolboxes/storage/autoBackup.js"; +import { + backupContentsEqual, + getBackupContentSignature, + getBackupFileName, + getLocalDateKey, + hasBackupDataChanged, + isFileSystemAccessSupported, + normalizeAutoBackupConfig, + saveBackup +} from "../website/src/features/toolboxes/storage/autoBackup.js"; class FakeDirectory { constructor(names = []) { @@ -48,6 +57,12 @@ test("feature detection does not rely on the user agent", () => { assert.equal(isFileSystemAccessSupported({}), false); }); +test("legacy enabled configurations start dirty while explicit backup state is preserved", () => { + assert.equal(normalizeAutoBackupConfig().hasUnsavedChanges, false); + assert.equal(normalizeAutoBackupConfig({ enabled: true, directoryHandle: {} }).hasUnsavedChanges, true); + assert.equal(normalizeAutoBackupConfig({ enabled: true, directoryHandle: {}, hasUnsavedChanges: false }).hasUnsavedChanges, false); +}); + test("backup names use the browser local date", () => { const date = new Date(2026, 8, 24, 0, 5); assert.equal(getLocalDateKey(date), "2026-09-24"); @@ -79,6 +94,19 @@ test("automatic backup skips a new day when the export is unchanged", async () = assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false); }); +test("content signatures ignore the export timestamp and detect toolbox changes", async () => { + const previous = JSON.stringify({ version: 1, exportedAt: "2026-09-23T08:00:00.000Z", toolboxes: [{ id: "one", name: "Avant" }], modules: {}, links: {} }); + const sameData = JSON.stringify({ version: 1, exportedAt: "2026-09-24T08:00:00.000Z", toolboxes: [{ id: "one", name: "Avant" }], modules: {}, links: {} }); + const changedData = JSON.stringify({ version: 1, exportedAt: "2026-09-24T08:00:00.000Z", toolboxes: [{ id: "one", name: "Après" }], modules: {}, links: {} }); + const previousSignature = await getBackupContentSignature(previous, {}); + const sameSignature = await getBackupContentSignature(sameData, {}); + const changedSignature = await getBackupContentSignature(changedData, {}); + + assert.equal(previousSignature, sameSignature); + assert.equal(hasBackupDataChanged({ lastBackupSignature: previousSignature, currentSignature: sameSignature }), false); + assert.equal(hasBackupDataChanged({ lastBackupSignature: previousSignature, currentSignature: changedSignature }), true); +}); + test("manual backup replaces the day and retains ten non-consecutive versions", async () => { const names = [ "sokkog-global-2024-01-01.json", "sokkog-global-2024-06-01.json", "sokkog-global-2025-01-01.json", diff --git a/tests/helpers/data-validation.mjs b/tests/helpers/data-validation.mjs index 427ea18..0a37c7a 100644 --- a/tests/helpers/data-validation.mjs +++ b/tests/helpers/data-validation.mjs @@ -227,6 +227,10 @@ export function validateSiteContent(site) { "toolboxes.autoBackup.title", "toolboxes.autoBackup.description", "toolboxes.autoBackup.compatibleLabel", + "toolboxes.autoBackup.permissionTitle", + "toolboxes.autoBackup.permissionDescription", + "toolboxes.autoBackup.reminderMessage", + "toolboxes.autoBackup.reminderAction", "toolboxes.autoBackup.folderLabel", "toolboxes.autoBackup.enabled", "toolboxes.autoBackup.toggleHelp", @@ -905,6 +909,7 @@ export function validateSiteContent(site) { assertStringArray(site, "toolboxes.storageHelp.items", 3); assertStringArray(site, "toolboxes.autoBackup.browsers", 2); + assertStringArray(site, "toolboxes.autoBackup.permissionSteps", 3); const librarySummaryItems = valueAt(site, "library.summaryItems"); assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array"); diff --git a/website/public/data/site.json b/website/public/data/site.json index 5fcef3c..9b44624 100644 --- a/website/public/data/site.json +++ b/website/public/data/site.json @@ -1823,13 +1823,22 @@ "Google Chrome", "Microsoft Edge" ], + "permissionTitle": "Fonctionnement de l’autorisation", + "permissionDescription": "Opera GX peut redemander l’accès après la fermeture du navigateur. Pour des raisons de sécurité, la popup système ne peut apparaître qu’après un clic de votre part.", + "permissionSteps": [ + "Si l’accès est toujours accordé, la vérification et la sauvegarde éventuelle restent silencieuses.", + "L’application mémorise localement les modifications confirmées. Si l’accès a expiré, une alerte vous propose alors de les sauvegarder.", + "Cliquez sur « Sauvegarder » dans cette alerte : le navigateur redemande l’accès, puis l’application vérifie le dossier avant toute écriture." + ], + "reminderMessage": "Des modifications ont été détectées dans vos toolboxes. Voulez-vous créer une sauvegarde locale ?", + "reminderAction": "Sauvegarder", "folderLabel": "Dossier configuré :", "enabled": "Activer la sauvegarde automatique", "toggleHelp": "Une sauvegarde automatique est créée au maximum une fois par jour lorsque l’application est ouverte.", "chooseFolder": "Choisir un dossier", "changeFolder": "Changer de dossier", - "reauthorize": "Réautoriser l’accès", - "permissionRequired": "L’accès au dossier doit être réautorisé pour reprendre les sauvegardes. Utilisez « Réautoriser l’accès » ci-dessous.", + "reauthorize": "Autoriser et vérifier", + "permissionRequired": "L’accès au dossier doit être redonné pour effectuer les vérifications et reprendre les sauvegardes.", "saveNow": "Sauvegarder maintenant", "noFolder": "Aucun dossier", "never": "Jamais", diff --git a/website/src/components/AppOverlays.jsx b/website/src/components/AppOverlays.jsx index bb56eac..4b7ec5e 100644 --- a/website/src/components/AppOverlays.jsx +++ b/website/src/components/AppOverlays.jsx @@ -85,6 +85,8 @@ export function AppOverlays({ id={notification.id} message={notification.message} durationMs={notification.durationMs} + actionLabel={notification.actionLabel} + onAction={notification.onAction} onClose={dismissNotification} /> ))} diff --git a/website/src/components/ToolboxModals.jsx b/website/src/components/ToolboxModals.jsx index affe521..f14f19e 100644 --- a/website/src/components/ToolboxModals.jsx +++ b/website/src/components/ToolboxModals.jsx @@ -24,15 +24,20 @@ export function ConfirmModal({ title, message, confirmLabel = "Confirmer", cance ); } -export function NotificationToast({ id, message, durationMs = 120000, onClose }) { +export function NotificationToast({ id, message, durationMs = 120000, actionLabel = "", onAction, onClose }) { useEffect(() => { + if (durationMs <= 0) return undefined; const timeoutId = window.setTimeout(() => onClose(id), durationMs); return () => window.clearTimeout(timeoutId); }, [durationMs, id, onClose]); return ( -
+
{message} + {actionLabel && onAction && } @@ -103,6 +108,11 @@ export function AutoBackupModal({ backup, content, onClose }) { {content.compatibleLabel}
+
+ {content.permissionTitle} +

{content.permissionDescription}

+
    {content.permissionSteps.map((step) =>
  1. {step}
  2. )}
+
{!backup.supported ?

{content.unsupported}

: ( <>
@@ -121,7 +131,7 @@ export function AutoBackupModal({ backup, content, onClose }) { {backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") &&

{content.permissionRequired}

} {backup.error &&

{backup.error}

} diff --git a/website/src/features/toolboxes/storage/autoBackup.js b/website/src/features/toolboxes/storage/autoBackup.js index d603e0c..155a569 100644 --- a/website/src/features/toolboxes/storage/autoBackup.js +++ b/website/src/features/toolboxes/storage/autoBackup.js @@ -1,6 +1,7 @@ // Rôle : gère les sauvegardes JSON locales via la File System Access API. export const AUTO_BACKUP_SETTING = "autoBackup"; +export const AUTO_BACKUP_DATA_CHANGED_EVENT = "sokkog:auto-backup-data-changed"; export const AUTO_BACKUP_FILE_PATTERN = /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/; const AUTO_BACKUP_LOCK = "sokkog-auto-backup"; @@ -23,7 +24,11 @@ export function normalizeAutoBackupConfig(value) { return { enabled: value?.enabled === true, directoryHandle: value?.directoryHandle || null, - lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : "" + lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : "", + lastBackupSignature: typeof value?.lastBackupSignature === "string" ? value.lastBackupSignature : "", + hasUnsavedChanges: typeof value?.hasUnsavedChanges === "boolean" + ? value.hasUnsavedChanges + : Boolean(value?.enabled && value?.directoryHandle) }; } @@ -82,6 +87,29 @@ function comparableBackupContents(contents) { } } +export async function getBackupContentSignature(contents, cryptoScope = globalThis.crypto) { + const comparable = comparableBackupContents(contents); + if (comparable === null) return ""; + try { + if (cryptoScope?.subtle) { + const digest = await cryptoScope.subtle.digest("SHA-256", new TextEncoder().encode(comparable)); + return `sha256-${[...new Uint8Array(digest)].map((value) => value.toString(16).padStart(2, "0")).join("")}`; + } + } catch { + // Le hash de secours reste déterministe lorsque Web Crypto est indisponible. + } + let hash = 2166136261; + for (let index = 0; index < comparable.length; index += 1) { + hash ^= comparable.charCodeAt(index); + hash = Math.imul(hash, 16777619); + } + return `fnv1a-${(hash >>> 0).toString(16).padStart(8, "0")}-${comparable.length}`; +} + +export function hasBackupDataChanged({ lastBackupSignature = "", currentSignature = "" }) { + return Boolean(currentSignature) && currentSignature !== lastBackupSignature; +} + export function backupContentsEqual(left, right) { const comparableLeft = comparableBackupContents(left); const comparableRight = comparableBackupContents(right); @@ -113,7 +141,15 @@ export async function saveBackup({ directoryHandle, generateJson, date = new Dat return withBackupLock(async () => { try { const existing = await listBackupFiles(directoryHandle); - if (!force && existing.includes(filename)) return { ok: true, created: false, filename }; + if (!force && existing.includes(filename)) { + let contentSignature = ""; + try { + contentSignature = await getBackupContentSignature(await readBackupFile(directoryHandle, filename)); + } catch { + // L'existence réelle du fichier suffit à respecter la limite quotidienne. + } + return { ok: true, created: false, filename, contentSignature }; + } const contents = await generateJson(); if (!force && existing.length) { const latestFilename = sortBackupFiles(existing)[0]; diff --git a/website/src/features/toolboxes/storage/useAutoBackup.js b/website/src/features/toolboxes/storage/useAutoBackup.js index 5585a98..e397127 100644 --- a/website/src/features/toolboxes/storage/useAutoBackup.js +++ b/website/src/features/toolboxes/storage/useAutoBackup.js @@ -4,8 +4,11 @@ import { createGlobalExportPayload } from "./toolboxStorage.js"; import { getSetting, setSetting } from "../../../utils/indexedDbStorage.js"; import { AUTO_BACKUP_SETTING, + AUTO_BACKUP_DATA_CHANGED_EVENT, + getBackupContentSignature, getBackupFileName, getDirectoryPermission, + getLocalDateKey, isFileSystemAccessSupported, normalizeAutoBackupConfig, saveBackup @@ -22,25 +25,47 @@ export function useAutoBackup(store) { const [busy, setBusy] = useState(false); const [permission, setPermission] = useState("unknown"); const [error, setError] = useState(""); + const [backupNeeded, setBackupNeeded] = useState(false); + const configRef = useRef(config); + const configWriteRef = useRef(Promise.resolve()); + const mutationVersionRef = useRef(0); + const storeRef = useRef(store); const runRef = useRef(false); const startedRef = useRef(false); - const persist = useCallback(async (next) => { - const normalized = normalizeAutoBackupConfig(next); + useEffect(() => { + storeRef.current = store; + }, [store]); + + const persist = useCallback(async (update) => { + const current = configRef.current; + const value = typeof update === "function" ? update(current) : { ...current, ...update }; + const normalized = normalizeAutoBackupConfig(value); + configRef.current = normalized; setConfig(normalized); + const write = configWriteRef.current.then(() => setSetting(AUTO_BACKUP_SETTING, normalized)); + configWriteRef.current = write.catch(() => {}); try { - await setSetting(AUTO_BACKUP_SETTING, normalized); + await write; } catch (persistError) { setError(persistError?.message || "Impossible d'enregistrer les préférences de sauvegarde."); } return normalized; }, []); + const generateJson = useCallback(async () => { + const snapshot = await storeRef.current.getExportSnapshot(undefined, { onError: () => {} }); + if (!snapshot) throw new Error("Impossible de préparer les données à sauvegarder."); + return serializeGlobalExport(snapshot); + }, []); + useEffect(() => { let cancelled = false; getSetting(AUTO_BACKUP_SETTING, normalizeAutoBackupConfig()).then((value) => { if (!cancelled) { - setConfig(normalizeAutoBackupConfig(value)); + const normalized = normalizeAutoBackupConfig(value); + configRef.current = normalized; + setConfig(normalized); setLoaded(true); } }).catch((loadError) => { @@ -52,25 +77,53 @@ export function useAutoBackup(store) { return () => { cancelled = true; }; }, []); - const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle, configOverride = config } = {}) => { + useEffect(() => { + if (!loaded) return undefined; + function markBackupDirty() { + mutationVersionRef.current += 1; + if (configRef.current.hasUnsavedChanges) return; + void persist((current) => ({ ...current, hasUnsavedChanges: true })); + } + window.addEventListener(AUTO_BACKUP_DATA_CHANGED_EVENT, markBackupDirty); + return () => window.removeEventListener(AUTO_BACKUP_DATA_CHANGED_EVENT, markBackupDirty); + }, [loaded, persist]); + + const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle } = {}) => { if (!supported || !directoryHandle || runRef.current) return { ok: false, reason: "unavailable" }; runRef.current = true; setBusy(true); try { + let generatedContents = ""; + let exportedMutationVersion = null; const result = await saveBackup({ directoryHandle, force, generateJson: async () => { - const snapshot = await store.getExportSnapshot(undefined, { onError: () => {} }); - if (!snapshot) throw new Error("Impossible de préparer les données à sauvegarder."); - return serializeGlobalExport(snapshot); + generatedContents = await generateJson(); + exportedMutationVersion = mutationVersionRef.current; + return generatedContents; } }); setPermission(result.permission || await getDirectoryPermission(directoryHandle)); if (result.ok && !result.cleanupError) setError(""); - if (result.ok && result.created) { - const next = { ...configOverride, directoryHandle, lastBackup: new Date().toISOString() }; - await persist(next); + if (result.ok) { + setBackupNeeded(false); + const contentSignature = result.contentSignature || (generatedContents + ? await getBackupContentSignature(generatedContents) + : ""); + const shouldPersist = result.created || result.unchanged || Boolean(contentSignature); + const backedUpCurrentData = (result.created || result.unchanged) + && exportedMutationVersion !== null + && exportedMutationVersion === mutationVersionRef.current; + if (shouldPersist) { + await persist((current) => ({ + ...current, + directoryHandle, + lastBackup: result.created ? new Date().toISOString() : current.lastBackup, + lastBackupSignature: contentSignature || current.lastBackupSignature, + hasUnsavedChanges: backedUpCurrentData ? false : current.hasUnsavedChanges + })); + } } if (!result.ok) setError(result.reason === "permission" ? "" : result.error?.message || "La sauvegarde locale a échoué."); if (result.ok && result.cleanupError) setError(result.cleanupError); @@ -82,7 +135,7 @@ export function useAutoBackup(store) { runRef.current = false; setBusy(false); } - }, [config, persist, store, supported]); + }, [config, generateJson, persist, supported]); useEffect(() => { if (!loaded || !supported || !store.ready || !config.enabled || !config.directoryHandle || startedRef.current) return; @@ -95,25 +148,42 @@ export function useAutoBackup(store) { void getDirectoryPermission(config.directoryHandle).then(setPermission); }, [config.directoryHandle, loaded]); + useEffect(() => { + if (!loaded || !store.ready || !supported || !config.enabled || !config.directoryHandle + || (permission !== "prompt" && permission !== "denied")) return undefined; + let cancelled = false; + void (async () => { + await Promise.resolve(); + if (config.lastBackup && getLocalDateKey(new Date(config.lastBackup)) === getLocalDateKey()) { + if (!cancelled) setBackupNeeded(false); + return; + } + if (!cancelled) setBackupNeeded(config.hasUnsavedChanges); + })(); + return () => { cancelled = true; }; + }, [config.directoryHandle, config.enabled, config.hasUnsavedChanges, config.lastBackup, loaded, permission, store.ready, supported]); + const chooseDirectory = useCallback(async () => { if (!supported) return false; try { const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" }); - const next = await persist({ ...config, directoryHandle }); + const next = await persist({ directoryHandle }); setPermission("granted"); - if (next.enabled) void run({ directoryHandle, configOverride: next }); + setBackupNeeded(false); + if (next.enabled) void run({ directoryHandle }); return true; } catch (chooseError) { if (chooseError?.name !== "AbortError") setError(chooseError?.message || "Impossible de choisir ce dossier."); return false; } - }, [config, persist, run, supported]); + }, [persist, run, supported]); const requestPermission = useCallback(async () => { if (!config.directoryHandle?.requestPermission) return false; try { const result = await config.directoryHandle.requestPermission({ mode: "readwrite" }); setPermission(result); + if (result === "granted") setBackupNeeded(false); if (result === "granted" && config.enabled) void run(); return result === "granted"; } catch (permissionError) { @@ -123,16 +193,18 @@ export function useAutoBackup(store) { }, [config.directoryHandle, config.enabled, run]); const setEnabled = useCallback(async (enabled) => { - const next = await persist({ ...config, enabled }); + const next = await persist({ enabled }); + if (!enabled) setBackupNeeded(false); if (enabled && next.directoryHandle) { const currentPermission = await getDirectoryPermission(next.directoryHandle); setPermission(currentPermission); - if (currentPermission === "granted") void run({ configOverride: next }); + if (currentPermission === "granted") void run({ directoryHandle: next.directoryHandle }); } - }, [config, persist, run]); + }, [persist, run]); return { supported, + backupNeeded, config, busy, permission, diff --git a/website/src/features/toolboxes/storage/useIndexedToolboxes.js b/website/src/features/toolboxes/storage/useIndexedToolboxes.js index 6515207..5bf5d96 100644 --- a/website/src/features/toolboxes/storage/useIndexedToolboxes.js +++ b/website/src/features/toolboxes/storage/useIndexedToolboxes.js @@ -25,6 +25,7 @@ import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleDa import { createPersistenceQueue } from "./persistenceQueue.js"; import { createQuotaRefresh } from "./quotaRefresh.js"; import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js"; +import { AUTO_BACKUP_DATA_CHANGED_EVENT } from "./autoBackup.js"; function normalizeToolboxes(toolboxes) { return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean); @@ -102,10 +103,16 @@ export function useIndexedToolboxes(onError) { setLoadAttempt((attempt) => attempt + 1); } - function enqueue(apply, persist) { + function enqueue(apply, persist, { marksBackupDirty = true } = {}) { if (!writableRef.current) return Promise.resolve(false); const result = queue.enqueue(apply, persist, reportStorageError); - void result.then((success) => { if (success) refreshQuota(); }); + void result.then((success) => { + if (!success) return; + refreshQuota(); + if (marksBackupDirty && typeof window !== "undefined") { + window.dispatchEvent(new window.Event(AUTO_BACKUP_DATA_CHANGED_EVENT)); + } + }); return result; } @@ -209,7 +216,8 @@ export function useIndexedToolboxes(onError) { const toastPosition = position === "left" ? "left" : "right"; enqueue( (current) => ({ ...current, toastPosition }), - () => dbSetSetting("toastPosition", toastPosition) + () => dbSetSetting("toastPosition", toastPosition), + { marksBackupDirty: false } ); } diff --git a/website/src/main.jsx b/website/src/main.jsx index 2e4d74e..539dffb 100644 --- a/website/src/main.jsx +++ b/website/src/main.jsx @@ -1,6 +1,6 @@ // Rôle : point d'entrée React, assemble données, routes, shell et overlays. /* eslint-disable react-refresh/only-export-components -- point d'entrée Vite sans export de composant. */ -import { lazy, StrictMode, Suspense, useCallback, useEffect, useState } from "react"; +import { lazy, StrictMode, Suspense, useCallback, useEffect, useRef, useState } from "react"; import { createRoot } from "react-dom/client"; import "./styles/main.scss"; import { AppOverlays } from "./components/AppOverlays.jsx"; @@ -24,6 +24,7 @@ const DEFAULT_NOTIFICATION_DURATION_MS = 120000; const SHORT_NOTIFICATION_DURATION_MS = 5000; function getNotificationDuration(message, options = {}) { + if (options.persistent) return 0; if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs)); const normalized = String(message || "").toLowerCase(); return /\bcopi[ée]|\bcoll[ée]|presse-papiers/.test(normalized) ? SHORT_NOTIFICATION_DURATION_MS : DEFAULT_NOTIFICATION_DURATION_MS; @@ -42,16 +43,26 @@ function App() { const [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState(""); const [notifications, setNotifications] = useState([]); const [storageError, setStorageError] = useState(""); + const backupReminderIdRef = useRef(""); const handleStorageError = useCallback((message) => setStorageError(message), []); const store = useIndexedToolboxes(handleStorageError); const autoBackup = useAutoBackup(store); + const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup; const guideChecklistSession = useGuideChecklistSession(store); const notify = useCallback((message, options = {}) => { + const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`; setNotifications((current) => [ - { id: `${Date.now()}-${Math.random().toString(36).slice(2)}`, message, durationMs: getNotificationDuration(message, options) }, + { + id, + message, + durationMs: getNotificationDuration(message, options), + actionLabel: options.actionLabel, + onAction: options.onAction + }, ...current ].slice(0, 5)); + return id; }, []); const dismissNotification = useCallback((notificationId) => { @@ -67,6 +78,21 @@ function App() { return () => window.removeEventListener("sokkog:notify", handleNotification); }, [notify]); + useEffect(() => { + if (!backupNeeded) { + if (backupReminderIdRef.current) dismissNotification(backupReminderIdRef.current); + backupReminderIdRef.current = ""; + return; + } + if (backupReminderIdRef.current || !siteContent?.toolboxes?.autoBackup) return; + const content = siteContent.toolboxes.autoBackup; + backupReminderIdRef.current = notify(content.reminderMessage, { + persistent: true, + actionLabel: content.reminderAction, + onAction: () => void requestBackupPermission() + }); + }, [backupNeeded, dismissNotification, notify, requestBackupPermission, siteContent]); + const t = (key, { capitalize = false } = {}) => { const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " "); return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value; diff --git a/website/src/styles/overlays/_modals.scss b/website/src/styles/overlays/_modals.scss index 5e0d73d..b561caa 100644 --- a/website/src/styles/overlays/_modals.scss +++ b/website/src/styles/overlays/_modals.scss @@ -98,6 +98,35 @@ font-size: var(--font-size-sm); } +.auto-backup-permission-guide { + margin-bottom: var(--space-5); + padding: var(--space-4); + border: 1px solid rgba(34, 211, 238, 0.2); + border-radius: var(--radius-md); + background: rgba(34, 211, 238, 0.06); +} + +.auto-backup-permission-guide strong { + color: var(--color-text-primary); +} + +.auto-backup-permission-guide p { + margin: var(--space-2) 0 var(--space-3); + color: var(--color-text-secondary); + font-size: var(--font-size-sm); + line-height: 1.5; +} + +.auto-backup-permission-guide ol { + display: grid; + gap: 6px; + margin: 0; + padding-left: 20px; + color: var(--color-text-muted); + font-size: var(--font-size-sm); + line-height: 1.45; +} + .auto-backup-folder { display: flex; align-items: stretch; diff --git a/website/src/styles/overlays/_notifications.scss b/website/src/styles/overlays/_notifications.scss index 3fbf898..ec3725e 100644 --- a/website/src/styles/overlays/_notifications.scss +++ b/website/src/styles/overlays/_notifications.scss @@ -17,6 +17,14 @@ min-width: 0; } +.notification-toast.has-action { + grid-template-columns: minmax(0, 1fr) auto 34px; +} + +.notification-toast .notification-toast-action { + white-space: nowrap; +} + .notification-toast button .ui-icon { width: 16px; height: 16px;