Improve local backup permissions and change tracking
All checks were successful
Deploy Sokko G / deploy (push) Successful in 19s

This commit is contained in:
Shinuwa 2026-09-25 10:45:12 +02:00
parent 34e542be28
commit ee823d2c37
13 changed files with 372 additions and 37 deletions

View file

@ -24,21 +24,31 @@ Clés principales dans `kv` :
"setting:autoBackup": { "setting:autoBackup": {
"enabled": false, "enabled": false,
"directoryHandle": null, "directoryHandle": null,
"lastBackup": "" "lastBackup": "",
"lastBackupSignature": "",
"hasUnsavedChanges": false
} }
} }
``` ```
`setting:autoBackup` conserve le `FileSystemDirectoryHandle` choisi par l'utilisateur `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. 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 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` 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 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 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 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 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 conserve pas l'autorisation du dossier entre deux sessions, l'indicateur passe à
une réautorisation explicite ; le démarrage n'ouvre aucune demande de permission. `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` : Entrée type dans `modules` :

View file

@ -1,5 +1,5 @@
// Rôle : couvre les parcours navigateur principaux des toolboxes locales. // 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 { expect, test } from "@playwright/test";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; 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).toBeVisible();
await expect(dialog.getByText("Cette fonctionnalité n’est pas disponible dans tous les navigateurs.")).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("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("10 versions sont conservées.", { exact: true })).toBeVisible();
await expect(dialog.getByText("Dossier configuré :", { exact: true })).toBeVisible(); await expect(dialog.getByText("Dossier configuré :", { exact: true })).toBeVisible();
await expect(dialog.getByText("Aucun dossier", { 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); 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 }) => { test("edits a table with formulas and persists values", async ({ page }) => {
await createToolbox(page, "Table E2E Toolbox"); await createToolbox(page, "Table E2E Toolbox");
await addTool(page, "Tableau"); await addTool(page, "Tableau");

View file

@ -1,7 +1,16 @@
// Rôle : vérifie les règles de nommage, d’écriture et de rétention des sauvegardes locales. // Rôle : vérifie les règles de nommage, d’écriture et de rétention des sauvegardes locales.
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; 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 { class FakeDirectory {
constructor(names = []) { constructor(names = []) {
@ -48,6 +57,12 @@ test("feature detection does not rely on the user agent", () => {
assert.equal(isFileSystemAccessSupported({}), false); 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", () => { test("backup names use the browser local date", () => {
const date = new Date(2026, 8, 24, 0, 5); const date = new Date(2026, 8, 24, 0, 5);
assert.equal(getLocalDateKey(date), "2026-09-24"); 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); 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 () => { test("manual backup replaces the day and retains ten non-consecutive versions", async () => {
const names = [ const names = [
"sokkog-global-2024-01-01.json", "sokkog-global-2024-06-01.json", "sokkog-global-2025-01-01.json", "sokkog-global-2024-01-01.json", "sokkog-global-2024-06-01.json", "sokkog-global-2025-01-01.json",

View file

@ -227,6 +227,10 @@ export function validateSiteContent(site) {
"toolboxes.autoBackup.title", "toolboxes.autoBackup.title",
"toolboxes.autoBackup.description", "toolboxes.autoBackup.description",
"toolboxes.autoBackup.compatibleLabel", "toolboxes.autoBackup.compatibleLabel",
"toolboxes.autoBackup.permissionTitle",
"toolboxes.autoBackup.permissionDescription",
"toolboxes.autoBackup.reminderMessage",
"toolboxes.autoBackup.reminderAction",
"toolboxes.autoBackup.folderLabel", "toolboxes.autoBackup.folderLabel",
"toolboxes.autoBackup.enabled", "toolboxes.autoBackup.enabled",
"toolboxes.autoBackup.toggleHelp", "toolboxes.autoBackup.toggleHelp",
@ -905,6 +909,7 @@ export function validateSiteContent(site) {
assertStringArray(site, "toolboxes.storageHelp.items", 3); assertStringArray(site, "toolboxes.storageHelp.items", 3);
assertStringArray(site, "toolboxes.autoBackup.browsers", 2); assertStringArray(site, "toolboxes.autoBackup.browsers", 2);
assertStringArray(site, "toolboxes.autoBackup.permissionSteps", 3);
const librarySummaryItems = valueAt(site, "library.summaryItems"); const librarySummaryItems = valueAt(site, "library.summaryItems");
assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array"); assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array");

View file

@ -1823,13 +1823,22 @@
"Google Chrome", "Google Chrome",
"Microsoft Edge" "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é :", "folderLabel": "Dossier configuré :",
"enabled": "Activer la sauvegarde automatique", "enabled": "Activer la sauvegarde automatique",
"toggleHelp": "Une sauvegarde automatique est créée au maximum une fois par jour lorsque l’application est ouverte.", "toggleHelp": "Une sauvegarde automatique est créée au maximum une fois par jour lorsque l’application est ouverte.",
"chooseFolder": "Choisir un dossier", "chooseFolder": "Choisir un dossier",
"changeFolder": "Changer de dossier", "changeFolder": "Changer de dossier",
"reauthorize": "Réautoriser l’accès", "reauthorize": "Autoriser et vérifier",
"permissionRequired": "L’accès au dossier doit être réautorisé pour reprendre les sauvegardes. Utilisez « Réautoriser l’accès » ci-dessous.", "permissionRequired": "L’accès au dossier doit être redonné pour effectuer les vérifications et reprendre les sauvegardes.",
"saveNow": "Sauvegarder maintenant", "saveNow": "Sauvegarder maintenant",
"noFolder": "Aucun dossier", "noFolder": "Aucun dossier",
"never": "Jamais", "never": "Jamais",

View file

@ -85,6 +85,8 @@ export function AppOverlays({
id={notification.id} id={notification.id}
message={notification.message} message={notification.message}
durationMs={notification.durationMs} durationMs={notification.durationMs}
actionLabel={notification.actionLabel}
onAction={notification.onAction}
onClose={dismissNotification} onClose={dismissNotification}
/> />
))} ))}

View file

@ -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(() => { useEffect(() => {
if (durationMs <= 0) return undefined;
const timeoutId = window.setTimeout(() => onClose(id), durationMs); const timeoutId = window.setTimeout(() => onClose(id), durationMs);
return () => window.clearTimeout(timeoutId); return () => window.clearTimeout(timeoutId);
}, [durationMs, id, onClose]); }, [durationMs, id, onClose]);
return ( return (
<div className="notification-toast nds-toast" role="status"> <div className={`notification-toast nds-toast${actionLabel && onAction ? " has-action" : ""}`} role="status">
<span>{message}</span> <span>{message}</span>
{actionLabel && onAction && <button className="nds-button notification-toast-action" data-size="xs" data-variant="primary" type="button" onClick={() => {
onAction();
onClose(id);
}}>{actionLabel}</button>}
<button className="nds-icon-button" data-size="xs" type="button" onClick={() => onClose(id)} aria-label="Fermer l'alerte" title="Fermer"> <button className="nds-icon-button" data-size="xs" type="button" onClick={() => onClose(id)} aria-label="Fermer l'alerte" title="Fermer">
<Icon name="close" /> <Icon name="close" />
</button> </button>
@ -103,6 +108,11 @@ export function AutoBackupModal({ backup, content, onClose }) {
<strong>{content.compatibleLabel}</strong> <strong>{content.compatibleLabel}</strong>
<ul>{content.browsers.map((browser) => <li key={browser}>{browser}</li>)}</ul> <ul>{content.browsers.map((browser) => <li key={browser}>{browser}</li>)}</ul>
</div> </div>
<div className="auto-backup-permission-guide">
<strong>{content.permissionTitle}</strong>
<p>{content.permissionDescription}</p>
<ol>{content.permissionSteps.map((step) => <li key={step}>{step}</li>)}</ol>
</div>
{!backup.supported ? <p className="nds-inline-notice" data-variant="warning" role="status">{content.unsupported}</p> : ( {!backup.supported ? <p className="nds-inline-notice" data-variant="warning" role="status">{content.unsupported}</p> : (
<> <>
<div className="auto-backup-folder"> <div className="auto-backup-folder">
@ -121,7 +131,7 @@ export function AutoBackupModal({ backup, content, onClose }) {
{backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <p className="nds-inline-notice" data-variant="warning" role="status">{content.permissionRequired}</p>} {backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <p className="nds-inline-notice" data-variant="warning" role="status">{content.permissionRequired}</p>}
{backup.error && <p className="nds-inline-notice" data-variant="danger" role="alert">{backup.error}</p>} {backup.error && <p className="nds-inline-notice" data-variant="danger" role="alert">{backup.error}</p>}
<footer className="nds-modal-footer auto-backup-modal-footer"> <footer className="nds-modal-footer auto-backup-modal-footer">
{backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <button className="nds-button" data-size="sm" type="button" disabled={backup.busy} onClick={() => void backup.requestPermission()}>{content.reauthorize}</button>} {backup.config.directoryHandle && (backup.permission === "prompt" || backup.permission === "denied") && <button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={backup.busy} onClick={() => void backup.requestPermission()}>{content.reauthorize}</button>}
<button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={backup.busy || !backup.config.directoryHandle || backup.permission !== "granted"} onClick={() => void backup.saveNow()}>{content.saveNow}</button> <button className="nds-button" data-size="sm" data-variant="primary" type="button" disabled={backup.busy || !backup.config.directoryHandle || backup.permission !== "granted"} onClick={() => void backup.saveNow()}>{content.saveNow}</button>
</footer> </footer>
</> </>

View file

@ -1,6 +1,7 @@
// Rôle : gère les sauvegardes JSON locales via la File System Access API. // Rôle : gère les sauvegardes JSON locales via la File System Access API.
export const AUTO_BACKUP_SETTING = "autoBackup"; 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$/; export const AUTO_BACKUP_FILE_PATTERN = /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/;
const AUTO_BACKUP_LOCK = "sokkog-auto-backup"; const AUTO_BACKUP_LOCK = "sokkog-auto-backup";
@ -23,7 +24,11 @@ export function normalizeAutoBackupConfig(value) {
return { return {
enabled: value?.enabled === true, enabled: value?.enabled === true,
directoryHandle: value?.directoryHandle || null, 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) { export function backupContentsEqual(left, right) {
const comparableLeft = comparableBackupContents(left); const comparableLeft = comparableBackupContents(left);
const comparableRight = comparableBackupContents(right); const comparableRight = comparableBackupContents(right);
@ -113,7 +141,15 @@ export async function saveBackup({ directoryHandle, generateJson, date = new Dat
return withBackupLock(async () => { return withBackupLock(async () => {
try { try {
const existing = await listBackupFiles(directoryHandle); 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(); const contents = await generateJson();
if (!force && existing.length) { if (!force && existing.length) {
const latestFilename = sortBackupFiles(existing)[0]; const latestFilename = sortBackupFiles(existing)[0];

View file

@ -4,8 +4,11 @@ import { createGlobalExportPayload } from "./toolboxStorage.js";
import { getSetting, setSetting } from "../../../utils/indexedDbStorage.js"; import { getSetting, setSetting } from "../../../utils/indexedDbStorage.js";
import { import {
AUTO_BACKUP_SETTING, AUTO_BACKUP_SETTING,
AUTO_BACKUP_DATA_CHANGED_EVENT,
getBackupContentSignature,
getBackupFileName, getBackupFileName,
getDirectoryPermission, getDirectoryPermission,
getLocalDateKey,
isFileSystemAccessSupported, isFileSystemAccessSupported,
normalizeAutoBackupConfig, normalizeAutoBackupConfig,
saveBackup saveBackup
@ -22,25 +25,47 @@ export function useAutoBackup(store) {
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [permission, setPermission] = useState("unknown"); const [permission, setPermission] = useState("unknown");
const [error, setError] = useState(""); 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 runRef = useRef(false);
const startedRef = useRef(false); const startedRef = useRef(false);
const persist = useCallback(async (next) => { useEffect(() => {
const normalized = normalizeAutoBackupConfig(next); 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); setConfig(normalized);
const write = configWriteRef.current.then(() => setSetting(AUTO_BACKUP_SETTING, normalized));
configWriteRef.current = write.catch(() => {});
try { try {
await setSetting(AUTO_BACKUP_SETTING, normalized); await write;
} catch (persistError) { } catch (persistError) {
setError(persistError?.message || "Impossible d'enregistrer les préférences de sauvegarde."); setError(persistError?.message || "Impossible d'enregistrer les préférences de sauvegarde.");
} }
return normalized; 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(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
getSetting(AUTO_BACKUP_SETTING, normalizeAutoBackupConfig()).then((value) => { getSetting(AUTO_BACKUP_SETTING, normalizeAutoBackupConfig()).then((value) => {
if (!cancelled) { if (!cancelled) {
setConfig(normalizeAutoBackupConfig(value)); const normalized = normalizeAutoBackupConfig(value);
configRef.current = normalized;
setConfig(normalized);
setLoaded(true); setLoaded(true);
} }
}).catch((loadError) => { }).catch((loadError) => {
@ -52,25 +77,53 @@ export function useAutoBackup(store) {
return () => { cancelled = true; }; 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" }; if (!supported || !directoryHandle || runRef.current) return { ok: false, reason: "unavailable" };
runRef.current = true; runRef.current = true;
setBusy(true); setBusy(true);
try { try {
let generatedContents = "";
let exportedMutationVersion = null;
const result = await saveBackup({ const result = await saveBackup({
directoryHandle, directoryHandle,
force, force,
generateJson: async () => { generateJson: async () => {
const snapshot = await store.getExportSnapshot(undefined, { onError: () => {} }); generatedContents = await generateJson();
if (!snapshot) throw new Error("Impossible de préparer les données à sauvegarder."); exportedMutationVersion = mutationVersionRef.current;
return serializeGlobalExport(snapshot); return generatedContents;
} }
}); });
setPermission(result.permission || await getDirectoryPermission(directoryHandle)); setPermission(result.permission || await getDirectoryPermission(directoryHandle));
if (result.ok && !result.cleanupError) setError(""); if (result.ok && !result.cleanupError) setError("");
if (result.ok && result.created) { if (result.ok) {
const next = { ...configOverride, directoryHandle, lastBackup: new Date().toISOString() }; setBackupNeeded(false);
await persist(next); 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) setError(result.reason === "permission" ? "" : result.error?.message || "La sauvegarde locale a échoué.");
if (result.ok && result.cleanupError) setError(result.cleanupError); if (result.ok && result.cleanupError) setError(result.cleanupError);
@ -82,7 +135,7 @@ export function useAutoBackup(store) {
runRef.current = false; runRef.current = false;
setBusy(false); setBusy(false);
} }
}, [config, persist, store, supported]); }, [config, generateJson, persist, supported]);
useEffect(() => { useEffect(() => {
if (!loaded || !supported || !store.ready || !config.enabled || !config.directoryHandle || startedRef.current) return; 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); void getDirectoryPermission(config.directoryHandle).then(setPermission);
}, [config.directoryHandle, loaded]); }, [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 () => { const chooseDirectory = useCallback(async () => {
if (!supported) return false; if (!supported) return false;
try { try {
const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" }); const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" });
const next = await persist({ ...config, directoryHandle }); const next = await persist({ directoryHandle });
setPermission("granted"); setPermission("granted");
if (next.enabled) void run({ directoryHandle, configOverride: next }); setBackupNeeded(false);
if (next.enabled) void run({ directoryHandle });
return true; return true;
} catch (chooseError) { } catch (chooseError) {
if (chooseError?.name !== "AbortError") setError(chooseError?.message || "Impossible de choisir ce dossier."); if (chooseError?.name !== "AbortError") setError(chooseError?.message || "Impossible de choisir ce dossier.");
return false; return false;
} }
}, [config, persist, run, supported]); }, [persist, run, supported]);
const requestPermission = useCallback(async () => { const requestPermission = useCallback(async () => {
if (!config.directoryHandle?.requestPermission) return false; if (!config.directoryHandle?.requestPermission) return false;
try { try {
const result = await config.directoryHandle.requestPermission({ mode: "readwrite" }); const result = await config.directoryHandle.requestPermission({ mode: "readwrite" });
setPermission(result); setPermission(result);
if (result === "granted") setBackupNeeded(false);
if (result === "granted" && config.enabled) void run(); if (result === "granted" && config.enabled) void run();
return result === "granted"; return result === "granted";
} catch (permissionError) { } catch (permissionError) {
@ -123,16 +193,18 @@ export function useAutoBackup(store) {
}, [config.directoryHandle, config.enabled, run]); }, [config.directoryHandle, config.enabled, run]);
const setEnabled = useCallback(async (enabled) => { const setEnabled = useCallback(async (enabled) => {
const next = await persist({ ...config, enabled }); const next = await persist({ enabled });
if (!enabled) setBackupNeeded(false);
if (enabled && next.directoryHandle) { if (enabled && next.directoryHandle) {
const currentPermission = await getDirectoryPermission(next.directoryHandle); const currentPermission = await getDirectoryPermission(next.directoryHandle);
setPermission(currentPermission); setPermission(currentPermission);
if (currentPermission === "granted") void run({ configOverride: next }); if (currentPermission === "granted") void run({ directoryHandle: next.directoryHandle });
} }
}, [config, persist, run]); }, [persist, run]);
return { return {
supported, supported,
backupNeeded,
config, config,
busy, busy,
permission, permission,

View file

@ -25,6 +25,7 @@ import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleDa
import { createPersistenceQueue } from "./persistenceQueue.js"; import { createPersistenceQueue } from "./persistenceQueue.js";
import { createQuotaRefresh } from "./quotaRefresh.js"; import { createQuotaRefresh } from "./quotaRefresh.js";
import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js"; import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js";
import { AUTO_BACKUP_DATA_CHANGED_EVENT } from "./autoBackup.js";
function normalizeToolboxes(toolboxes) { function normalizeToolboxes(toolboxes) {
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean); return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
@ -102,10 +103,16 @@ export function useIndexedToolboxes(onError) {
setLoadAttempt((attempt) => attempt + 1); setLoadAttempt((attempt) => attempt + 1);
} }
function enqueue(apply, persist) { function enqueue(apply, persist, { marksBackupDirty = true } = {}) {
if (!writableRef.current) return Promise.resolve(false); if (!writableRef.current) return Promise.resolve(false);
const result = queue.enqueue(apply, persist, reportStorageError); 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; return result;
} }
@ -209,7 +216,8 @@ export function useIndexedToolboxes(onError) {
const toastPosition = position === "left" ? "left" : "right"; const toastPosition = position === "left" ? "left" : "right";
enqueue( enqueue(
(current) => ({ ...current, toastPosition }), (current) => ({ ...current, toastPosition }),
() => dbSetSetting("toastPosition", toastPosition) () => dbSetSetting("toastPosition", toastPosition),
{ marksBackupDirty: false }
); );
} }

View file

@ -1,6 +1,6 @@
// Rôle : point d'entrée React, assemble données, routes, shell et overlays. // 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. */ /* 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 { createRoot } from "react-dom/client";
import "./styles/main.scss"; import "./styles/main.scss";
import { AppOverlays } from "./components/AppOverlays.jsx"; import { AppOverlays } from "./components/AppOverlays.jsx";
@ -24,6 +24,7 @@ const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
const SHORT_NOTIFICATION_DURATION_MS = 5000; const SHORT_NOTIFICATION_DURATION_MS = 5000;
function getNotificationDuration(message, options = {}) { function getNotificationDuration(message, options = {}) {
if (options.persistent) return 0;
if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs)); if (Number.isFinite(options.durationMs)) return Math.max(1000, Number(options.durationMs));
const normalized = String(message || "").toLowerCase(); const normalized = String(message || "").toLowerCase();
return /\bcopi[ée]|\bcoll[ée]|presse-papiers/.test(normalized) ? SHORT_NOTIFICATION_DURATION_MS : DEFAULT_NOTIFICATION_DURATION_MS; 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 [pendingTutorialToolboxId, setPendingTutorialToolboxId] = useState("");
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [storageError, setStorageError] = useState(""); const [storageError, setStorageError] = useState("");
const backupReminderIdRef = useRef("");
const handleStorageError = useCallback((message) => setStorageError(message), []); const handleStorageError = useCallback((message) => setStorageError(message), []);
const store = useIndexedToolboxes(handleStorageError); const store = useIndexedToolboxes(handleStorageError);
const autoBackup = useAutoBackup(store); const autoBackup = useAutoBackup(store);
const { backupNeeded, requestPermission: requestBackupPermission } = autoBackup;
const guideChecklistSession = useGuideChecklistSession(store); const guideChecklistSession = useGuideChecklistSession(store);
const notify = useCallback((message, options = {}) => { const notify = useCallback((message, options = {}) => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
setNotifications((current) => [ 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 ...current
].slice(0, 5)); ].slice(0, 5));
return id;
}, []); }, []);
const dismissNotification = useCallback((notificationId) => { const dismissNotification = useCallback((notificationId) => {
@ -67,6 +78,21 @@ function App() {
return () => window.removeEventListener("sokkog:notify", handleNotification); return () => window.removeEventListener("sokkog:notify", handleNotification);
}, [notify]); }, [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 t = (key, { capitalize = false } = {}) => {
const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " "); const value = mhwilds.translations[key] || String(key || "").replace(/_/g, " ");
return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value; return capitalize ? value.charAt(0).toUpperCase() + value.slice(1) : value;

View file

@ -98,6 +98,35 @@
font-size: var(--font-size-sm); 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 { .auto-backup-folder {
display: flex; display: flex;
align-items: stretch; align-items: stretch;

View file

@ -17,6 +17,14 @@
min-width: 0; 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 { .notification-toast button .ui-icon {
width: 16px; width: 16px;
height: 16px; height: 16px;