Add local automatic backups and Aniimo regions lists
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-24 09:39:33 +02:00
parent 2255de94a7
commit 0a3bb4dffd
24 changed files with 908 additions and 16 deletions

View file

@ -73,7 +73,7 @@ async function copyText(value) {
}
}
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage, toastPosition, setToastPosition }) {
export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions, storageUsage, toastPosition, setToastPosition, onOpenAutoBackup }) {
const content = siteContent.toolboxes;
const {
itemReorder,
@ -114,6 +114,7 @@ export function ToolboxesPage({ siteContent, toolboxes, getToolboxGame, actions,
<ImportButton className="nds-button" data-size="sm" label={content.importOne} onFile={actions.importToolbox} />
</div>
<div>
<button className="nds-button" data-size="sm" type="button" onClick={onOpenAutoBackup}>{content.autoBackup.title}</button>
<button className="nds-button" data-size="sm" data-variant="primary" onClick={actions.exportAllToolboxes}>{content.exportAll}</button>
<ImportButton className="nds-button" data-size="sm" label={content.importAll} onFile={actions.importAllToolboxes} />
</div>

View file

@ -0,0 +1,140 @@
// Rôle : gère les sauvegardes JSON locales via la File System Access API.
export const AUTO_BACKUP_SETTING = "autoBackup";
export const AUTO_BACKUP_FILE_PATTERN = /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/;
const AUTO_BACKUP_LOCK = "sokkog-auto-backup";
export function isFileSystemAccessSupported(scope = globalThis) {
return Boolean(scope && "showDirectoryPicker" in scope);
}
export function getLocalDateKey(date = new Date()) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
export function getBackupFileName(date = new Date()) {
return `sokkog-global-${getLocalDateKey(date)}.json`;
}
export function normalizeAutoBackupConfig(value) {
return {
enabled: value?.enabled === true,
directoryHandle: value?.directoryHandle || null,
lastBackup: typeof value?.lastBackup === "string" ? value.lastBackup : ""
};
}
async function withBackupLock(task) {
if (globalThis.navigator?.locks?.request) {
return globalThis.navigator.locks.request(AUTO_BACKUP_LOCK, { mode: "exclusive" }, task);
}
return task();
}
async function queryReadWritePermission(directoryHandle) {
if (!directoryHandle?.queryPermission) return "denied";
return directoryHandle.queryPermission({ mode: "readwrite" });
}
export async function getDirectoryPermission(directoryHandle) {
try {
return await queryReadWritePermission(directoryHandle);
} catch {
return "denied";
}
}
async function listBackupFiles(directoryHandle) {
const files = [];
for await (const entry of directoryHandle.values()) {
if (entry.kind === "file" && AUTO_BACKUP_FILE_PATTERN.test(entry.name)) files.push(entry.name);
}
return files;
}
function sortBackupFiles(files) {
return [...files].sort((left, right) => right.slice(14, 24).localeCompare(left.slice(14, 24)));
}
async function cleanupBackups(directoryHandle) {
const files = sortBackupFiles(await listBackupFiles(directoryHandle));
for (const filename of files.slice(10)) await directoryHandle.removeEntry(filename);
}
async function readBackupFile(directoryHandle, filename) {
const fileHandle = await directoryHandle.getFileHandle(filename);
const file = await fileHandle.getFile();
return file.text();
}
function comparableBackupContents(contents) {
try {
const payload = JSON.parse(contents);
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
const withoutTimestamp = { ...payload };
delete withoutTimestamp.exportedAt;
return JSON.stringify(withoutTimestamp);
} catch {
return null;
}
}
export function backupContentsEqual(left, right) {
const comparableLeft = comparableBackupContents(left);
const comparableRight = comparableBackupContents(right);
return comparableLeft !== null && comparableLeft === comparableRight;
}
async function writeBackupFile(directoryHandle, filename, contents, preserveExisting) {
const fileHandle = await directoryHandle.getFileHandle(filename, { create: true });
let writable;
try {
writable = await fileHandle.createWritable();
await writable.write(contents);
await writable.close();
} catch (error) {
try { await writable?.abort(); } catch { /* Le fichier peut déjà être fermé. */ }
if (!preserveExisting) {
try { await directoryHandle.removeEntry(filename); } catch { /* Le fichier peut ne pas avoir été créé. */ }
}
throw error;
}
}
export async function saveBackup({ directoryHandle, generateJson, date = new Date(), force = false }) {
if (!directoryHandle) return { ok: false, reason: "missing-directory" };
const permission = await getDirectoryPermission(directoryHandle);
if (permission !== "granted") return { ok: false, reason: "permission", permission };
const filename = getBackupFileName(date);
return withBackupLock(async () => {
try {
const existing = await listBackupFiles(directoryHandle);
if (!force && existing.includes(filename)) return { ok: true, created: false, filename };
const contents = await generateJson();
if (!force && existing.length) {
const latestFilename = sortBackupFiles(existing)[0];
try {
if (backupContentsEqual(contents, await readBackupFile(directoryHandle, latestFilename))) {
return { ok: true, created: false, unchanged: true, filename, comparedTo: latestFilename };
}
} catch {
// Une lecture impossible ne doit pas empêcher la création d'une nouvelle version.
}
}
await writeBackupFile(directoryHandle, filename, contents, existing.includes(filename));
let cleanupError = "";
try {
await cleanupBackups(directoryHandle);
} catch (error) {
cleanupError = error?.message || "Erreur de nettoyage des anciennes sauvegardes.";
}
return { ok: true, created: true, filename, cleanupError };
} catch (error) {
return { ok: false, reason: "backup", error };
}
});
}

View file

@ -0,0 +1,148 @@
// Rôle : expose la configuration et les actions de sauvegarde automatique à l'interface.
import { useCallback, useEffect, useRef, useState } from "react";
import { createGlobalExportPayload } from "./toolboxStorage.js";
import { getSetting, setSetting } from "../../../utils/indexedDbStorage.js";
import {
AUTO_BACKUP_SETTING,
getBackupFileName,
getDirectoryPermission,
isFileSystemAccessSupported,
normalizeAutoBackupConfig,
saveBackup
} from "./autoBackup.js";
function serializeGlobalExport(snapshot) {
return JSON.stringify(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), null, 2);
}
export function useAutoBackup(store) {
const supported = isFileSystemAccessSupported(typeof window === "undefined" ? undefined : window);
const [config, setConfig] = useState(() => normalizeAutoBackupConfig());
const [loaded, setLoaded] = useState(false);
const [busy, setBusy] = useState(false);
const [permission, setPermission] = useState("unknown");
const [error, setError] = useState("");
const runRef = useRef(false);
const startedRef = useRef(false);
const persist = useCallback(async (next) => {
const normalized = normalizeAutoBackupConfig(next);
setConfig(normalized);
try {
await setSetting(AUTO_BACKUP_SETTING, normalized);
} catch (persistError) {
setError(persistError?.message || "Impossible d'enregistrer les préférences de sauvegarde.");
}
return normalized;
}, []);
useEffect(() => {
let cancelled = false;
getSetting(AUTO_BACKUP_SETTING, normalizeAutoBackupConfig()).then((value) => {
if (!cancelled) {
setConfig(normalizeAutoBackupConfig(value));
setLoaded(true);
}
}).catch((loadError) => {
if (!cancelled) {
setLoaded(true);
setError(loadError?.message || "Impossible de lire les préférences de sauvegarde.");
}
});
return () => { cancelled = true; };
}, []);
const run = useCallback(async ({ force = false, directoryHandle = config.directoryHandle, configOverride = config } = {}) => {
if (!supported || !directoryHandle || runRef.current) return { ok: false, reason: "unavailable" };
runRef.current = true;
setBusy(true);
try {
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);
}
});
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) setError(result.error?.message || "La sauvegarde locale a échoué.");
if (result.ok && result.cleanupError) setError(result.cleanupError);
return result;
} catch (runError) {
setError(runError?.message || "La sauvegarde locale a échoué.");
return { ok: false, reason: "backup", error: runError };
} finally {
runRef.current = false;
setBusy(false);
}
}, [config, persist, store, supported]);
useEffect(() => {
if (!loaded || !supported || !store.ready || !config.enabled || !config.directoryHandle || startedRef.current) return;
startedRef.current = true;
void run();
}, [config.directoryHandle, config.enabled, loaded, run, store.ready, supported]);
useEffect(() => {
if (!loaded || !config.directoryHandle) return;
void getDirectoryPermission(config.directoryHandle).then(setPermission);
}, [config.directoryHandle, loaded]);
const chooseDirectory = useCallback(async () => {
if (!supported) return false;
try {
const directoryHandle = await window.showDirectoryPicker({ mode: "readwrite" });
const next = await persist({ ...config, directoryHandle });
setPermission("granted");
if (next.enabled) void run({ directoryHandle, configOverride: next });
return true;
} catch (chooseError) {
if (chooseError?.name !== "AbortError") setError(chooseError?.message || "Impossible de choisir ce dossier.");
return false;
}
}, [config, 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" && config.enabled) void run();
return result === "granted";
} catch (permissionError) {
setError(permissionError?.message || "Impossible de réautoriser ce dossier.");
return false;
}
}, [config.directoryHandle, config.enabled, run]);
const setEnabled = useCallback(async (enabled) => {
const next = await persist({ ...config, enabled });
if (enabled && next.directoryHandle) {
const currentPermission = await getDirectoryPermission(next.directoryHandle);
setPermission(currentPermission);
if (currentPermission === "granted") void run({ configOverride: next });
}
}, [config, persist, run]);
return {
supported,
config,
busy,
permission,
error,
setError,
setEnabled,
chooseDirectory,
requestPermission,
saveNow: () => run({ force: true }),
filename: getBackupFileName(),
retention: 10
};
}

View file

@ -192,7 +192,7 @@ export function useIndexedToolboxes(onError) {
return request;
}, [queue]);
async function getExportSnapshot(toolboxId) {
async function getExportSnapshot(toolboxId, options = {}) {
if (!writableRef.current) return null;
let snapshot;
const success = await queue.enqueue((current) => current, async (current) => {
@ -200,7 +200,7 @@ export function useIndexedToolboxes(onError) {
const keys = deferred.filter((key) => !toolboxId || key.startsWith(`${toolboxId}:`));
const entries = await dbGetModuleDataEntries(keys);
snapshot = { ...current, moduleData: mergeLoadedModuleData(current.moduleData, keys, entries) };
}, reportStorageError);
}, options.onError || reportStorageError);
return success ? snapshot : null;
}

View file

@ -10,6 +10,7 @@ import {
uid
} from "./storage/toolboxStorage.js";
import { compressImage } from "../../utils/imageCompression.js";
import { getBackupFileName } from "./storage/autoBackup.js";
function downloadJson(payload, filename) {
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
@ -290,7 +291,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
if (!store.ready) return false;
const snapshot = await store.getExportSnapshot();
if (!snapshot) return false;
downloadJson(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`);
downloadJson(createGlobalExportPayload(snapshot.toolboxes, snapshot.links, snapshot.moduleData), getBackupFileName());
notify("Export global lancé.");
}
};