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