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": {
"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` :

View file

@ -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");

View file

@ -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",

View file

@ -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");

View file

@ -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",

View file

@ -85,6 +85,8 @@ export function AppOverlays({
id={notification.id}
message={notification.message}
durationMs={notification.durationMs}
actionLabel={notification.actionLabel}
onAction={notification.onAction}
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(() => {
if (durationMs <= 0) return undefined;
const timeoutId = window.setTimeout(() => onClose(id), durationMs);
return () => window.clearTimeout(timeoutId);
}, [durationMs, id, onClose]);
return (
<div className="notification-toast nds-toast" role="status">
<div className={`notification-toast nds-toast${actionLabel && onAction ? " has-action" : ""}`} role="status">
<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">
<Icon name="close" />
</button>
@ -103,6 +108,11 @@ export function AutoBackupModal({ backup, content, onClose }) {
<strong>{content.compatibleLabel}</strong>
<ul>{content.browsers.map((browser) => <li key={browser}>{browser}</li>)}</ul>
</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> : (
<>
<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.error && <p className="nds-inline-notice" data-variant="danger" role="alert">{backup.error}</p>}
<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>
</footer>
</>

View file

@ -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];

View file

@ -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,

View file

@ -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 }
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;