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

@ -40,6 +40,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier que l'ajout, la suppression et la réorganisation respectent `moduleOrder.one` et `moduleOrder.two`.
- Pour les déplacements par pointeur, vérifier les dépôts avant/après, le rejet des cibles interdites et l'annulation navigateur (`pointercancel`) sans modification de l'ordre ; le démontage doit retirer les listeners actifs.
- Vérifier le quota de stockage si l'outil manipule des données lourdes.
- Pour la sauvegarde locale, vérifier la feature detection, les permissions sans popup au démarrage, le nommage en date locale, la déduplication quotidienne, le remplacement manuel, la rétention de dix fichiers et la conservation des fichiers étrangers.
## Page Jeu

View file

@ -20,10 +20,24 @@ Clés principales dans `kv` :
"links": {},
"setting:toolboxOrder": [],
"setting:storageBudgetBytes": 536870912,
"setting:toastPosition": "right"
"setting:toastPosition": "right",
"setting:autoBackup": {
"enabled": false,
"directoryHandle": null,
"lastBackup": ""
}
}
```
`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.
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.
Entrée type dans `modules` :
```json

View file

@ -105,7 +105,15 @@ test("catalogue, overview and deferred Aniimo loading preserve filters across na
await page.locator('a[href="#/games/aniimo/aniimos"]').click();
await expect(cards(page)).toHaveCount(3);
await expect(page.getByRole("searchbox")).toHaveValue("EMBERPUP");
expect(requests).toHaveLength(3);
expect(requests).toHaveLength(5);
});
test("Aniimo exposes its regions list", async ({ page }) => {
await page.goto("/#/games/aniimo/lists");
await expect(page.getByRole("heading", { name: "Listes", exact: true })).toBeVisible();
await expect(page.locator("h2", { hasText: "Regions" })).toBeVisible();
await expect(page.getByText("14 éléments", { exact: true })).toBeVisible();
await expect(page.getByText("Champs de Cumulaine", { exact: true })).toBeVisible();
});
test("tabs, flip, matching variants and reset remain independent", async ({ page }) => {

View file

@ -240,6 +240,24 @@ test("imports and exports a toolbox payload", async ({ page }) => {
expect(download.suggestedFilename()).toBe("fixture-toolbox.sokkog.json");
});
test("opens the local automatic backup settings modal", async ({ page }) => {
await page.goto("/#/toolboxes");
await expect(page.getByRole("button", { name: "Sauvegarde automatique", exact: true })).toBeVisible();
await page.getByRole("button", { name: "Sauvegarde automatique", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Sauvegarde automatique" });
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("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();
await expect(dialog.getByRole("checkbox")).toBeVisible();
await dialog.getByRole("button", { name: "Fermer", exact: true }).last().click();
await expect(dialog).toHaveCount(0);
});
test("edits a table with formulas and persists values", async ({ page }) => {
await createToolbox(page, "Table E2E Toolbox");
await addTool(page, "Tableau");

View file

@ -112,7 +112,9 @@ test("grouping preserves order and contains only variants that pass all filters"
test("loader passes cancellation to all requests and reports failed responses", async (context) => {
const calls = [];
const controller = new AbortController();
const bodies = [aniimos, fr, en];
const listIndex = await readJson("aniimo/lists/index.json");
const regions = await readJson("aniimo/lists/regions.json");
const bodies = [aniimos, fr, en, listIndex, regions];
context.mock.method(globalThis, "fetch", async (url, options) => {
calls.push({ url, options });
const body = bodies[calls.length - 1];
@ -120,7 +122,10 @@ test("loader passes cancellation to all requests and reports failed responses",
});
const result = await loadAniimoData(controller.signal);
assert.equal(result.variants.length, data.variants.length);
assert.equal(calls.length, 3);
assert.equal(result.lists.length, 1);
assert.equal(result.lists[0].id, "regions");
assert.equal(result.lists[0].itemCount, 14);
assert.equal(calls.length, 5);
assert.ok(calls.every((call) => call.options.signal === controller.signal));
globalThis.fetch.mock.mockImplementation(async () => ({ ok: false }));
await assert.rejects(loadAniimoData(controller.signal), /indisponibles/);

128
tests/auto-backup.test.mjs Normal file
View file

@ -0,0 +1,128 @@
// 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";
class FakeDirectory {
constructor(names = []) {
this.files = new Map(names.map((name) => [name, "old"]));
this.directories = new Set();
this.permission = "granted";
this.failCleanup = false;
this.failWrite = false;
}
async queryPermission() { return this.permission; }
async *values() {
for (const name of this.files.keys()) yield { kind: "file", name };
for (const name of this.directories) yield { kind: "directory", name };
}
async getFileHandle(name, { create = false } = {}) {
if (create && !this.files.has(name)) this.files.set(name, "");
const directory = this;
return {
async getFile() {
return { text: async () => directory.files.get(name) || "" };
},
async createWritable() {
if (directory.failWrite) throw new Error("write failed");
return {
async write(contents) { directory.files.set(name, contents); },
async close() {},
async abort() {}
};
}
};
}
async removeEntry(name) {
if (this.failCleanup) throw new Error("cleanup failed");
this.files.delete(name);
}
}
test("feature detection does not rely on the user agent", () => {
assert.equal(isFileSystemAccessSupported({ showDirectoryPicker() {} }), true);
assert.equal(isFileSystemAccessSupported({}), 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");
assert.equal(getBackupFileName(date), "sokkog-global-2026-09-24.json");
});
test("automatic backup skips an existing day and creates the next day", async () => {
const directory = new FakeDirectory(["sokkog-global-2026-09-23.json"]);
let generated = 0;
const generateJson = async () => { generated += 1; return "{}"; };
const first = await saveBackup({ directoryHandle: directory, generateJson, date: new Date(2026, 8, 23) });
const second = await saveBackup({ directoryHandle: directory, generateJson, date: new Date(2026, 8, 24) });
assert.equal(first.created, false);
assert.equal(second.created, true);
assert.equal(generated, 1);
assert.ok(directory.files.has("sokkog-global-2026-09-24.json"));
});
test("automatic backup skips a new day when the export is unchanged", async () => {
const previous = JSON.stringify({ version: 1, exportedAt: "2026-09-23T08:00:00.000Z", toolboxes: [], modules: {}, links: {} });
const directory = new FakeDirectory(["sokkog-global-2026-09-23.json"]);
directory.files.set("sokkog-global-2026-09-23.json", previous);
const current = JSON.stringify({ version: 1, exportedAt: "2026-09-24T08:00:00.000Z", toolboxes: [], modules: {}, links: {} });
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => current, date: new Date(2026, 8, 24) });
assert.equal(backupContentsEqual(previous, current), true);
assert.equal(result.ok, true);
assert.equal(result.created, false);
assert.equal(result.unchanged, true);
assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false);
});
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",
"sokkog-global-2025-03-01.json", "sokkog-global-2025-09-01.json", "sokkog-global-2026-01-01.json",
"sokkog-global-2026-03-01.json", "sokkog-global-2026-06-01.json", "sokkog-global-2026-07-01.json",
"sokkog-global-2026-08-01.json", "sokkog-global-2026-09-24.json", "notes.json"
];
const directory = new FakeDirectory(names);
const result = await saveBackup({ directoryHandle: directory, force: true, date: new Date(2026, 8, 24), generateJson: async () => "new" });
assert.equal(result.ok, true);
assert.equal(directory.files.get("sokkog-global-2026-09-24.json"), "new");
assert.equal([...directory.files.keys()].filter((name) => /^sokkog-global-\d{4}-\d{2}-\d{2}\.json$/.test(name)).length, 10);
assert.equal(directory.files.get("notes.json"), "old");
});
test("permission failure prevents export and cleanup", async () => {
const directory = new FakeDirectory();
directory.permission = "prompt";
let generated = false;
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => { generated = true; return "{}"; } });
assert.equal(result.reason, "permission");
assert.equal(generated, false);
});
test("cleanup failure does not invalidate a successfully written backup", async () => {
const directory = new FakeDirectory(Array.from({ length: 10 }, (_, index) => `sokkog-global-2020-01-${String(index + 1).padStart(2, "0")}.json`));
directory.failCleanup = true;
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => "{}", date: new Date(2026, 8, 24) });
assert.equal(result.ok, true);
assert.match(result.cleanupError, /cleanup failed/);
assert.equal(directory.files.get("sokkog-global-2026-09-24.json"), "{}");
});
test("failed write does not leave a newly created backup", async () => {
const directory = new FakeDirectory();
directory.failWrite = true;
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => "{}", date: new Date(2026, 8, 24) });
assert.equal(result.ok, false);
assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false);
});
test("export failure does not create a partial backup", async () => {
const directory = new FakeDirectory();
const result = await saveBackup({ directoryHandle: directory, generateJson: async () => { throw new Error("export failed"); }, date: new Date(2026, 8, 24) });
assert.equal(result.ok, false);
assert.equal(directory.files.has("sokkog-global-2026-09-24.json"), false);
});

View file

@ -220,6 +220,23 @@ export function validateSiteContent(site) {
"toolboxes.toastPositionLabel",
"toolboxes.toastPositionLeft",
"toolboxes.toastPositionRight",
"toolboxes.autoBackup.eyebrow",
"toolboxes.autoBackup.title",
"toolboxes.autoBackup.description",
"toolboxes.autoBackup.compatibleLabel",
"toolboxes.autoBackup.folderLabel",
"toolboxes.autoBackup.enabled",
"toolboxes.autoBackup.toggleHelp",
"toolboxes.autoBackup.chooseFolder",
"toolboxes.autoBackup.changeFolder",
"toolboxes.autoBackup.reauthorize",
"toolboxes.autoBackup.saveNow",
"toolboxes.autoBackup.noFolder",
"toolboxes.autoBackup.never",
"toolboxes.autoBackup.lastBackup",
"toolboxes.autoBackup.retention",
"toolboxes.autoBackup.unsupported",
"toolboxes.autoBackup.close",
"toolboxes.summary",
"toolboxes.storageHelp.title",
"toolboxes.storageHelp.text",
@ -883,6 +900,7 @@ export function validateSiteContent(site) {
].forEach((path) => assertNonEmptyString(site, path));
assertStringArray(site, "toolboxes.storageHelp.items", 3);
assertStringArray(site, "toolboxes.autoBackup.browsers", 2);
const librarySummaryItems = valueAt(site, "library.summaryItems");
assert.ok(Array.isArray(librarySummaryItems), "library.summaryItems must be an array");

View file

@ -53,6 +53,8 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
const mhwildsOverview = await readFile("website/src/features/games/mhwilds/MhwildsOverview.jsx", "utf8");
const mhwildsListing = await readFile("website/src/features/games/mhwilds/MhwildsListing.jsx", "utf8");
const mhwildsLists = await readFile("website/src/features/games/mhwilds/MhwildsLists.jsx", "utf8");
const aniimoPage = await readFile("website/src/features/games/aniimo/AniimoPage.jsx", "utf8");
const aniimoLists = await readFile("website/src/features/games/aniimo/AniimoLists.jsx", "utf8");
const mhwildsFilters = await readFile("website/src/features/games/mhwilds/MhwildsFilters.jsx", "utf8");
const monsterCard = await readFile("website/src/features/games/mhwilds/cards/MonsterCard.jsx", "utf8");
const endemicCard = await readFile("website/src/features/games/mhwilds/cards/EndemicCard.jsx", "utf8");
@ -79,6 +81,9 @@ test("mhwilds and diablo pages expose expected route pieces", async () => {
assert.match(mhwildsLists, /export function MhwildsLists/);
assert.match(mhwildsLists, /GameListsPage/);
assert.match(mhwildsLists, /selectedListId/);
assert.match(aniimoPage, /AniimoLists/);
assert.match(aniimoPage, /games\/aniimo\/lists/);
assert.match(aniimoLists, /GameListsPage/);
assert.match(mhwildsFilters, /export function MhwildsFilters/);
assert.match(mhwildsFilters, /GameFiltersPanel/);
assert.match(monsterCard, /export function MonsterCard/);

View file

@ -0,0 +1,9 @@
{
"lists": [
{
"id": "regions",
"title": "Regions",
"file": "regions.json"
}
]
}

View file

@ -0,0 +1,61 @@
{
"titre": "Regions",
"regions": [
{
"nom": "Champs de Cumulaine",
"quantite": 1
},
{
"nom": "Mer florale",
"quantite": 1
},
{
"nom": "Forêt des météores",
"quantite": 1
},
{
"nom": "Le détroit argenté",
"quantite": 1
},
{
"nom": "Crête des crocs",
"quantite": 1
},
{
"nom": "Grève d'echoflux",
"quantite": 1
},
{
"nom": "Bois Brumeux",
"quantite": 1
},
{
"nom": "Forêt des foudres",
"quantite": 1
},
{
"nom": "Vallée de béryl",
"quantite": 1
},
{
"nom": "Côte de maréeflore",
"quantite": 1
},
{
"nom": "Hautes-plaines ocres",
"quantite": 1
},
{
"nom": "Boix de la tour-aux-roses",
"quantite": 1
},
{
"nom": "Pré-aux-frimas",
"quantite": 1
},
{
"nom": "Isthme du zéphyr",
"quantite": 1
}
]
}

View file

@ -1813,6 +1813,30 @@
"toastPositionLabel": "Positionnement des alertes",
"toastPositionLeft": "Positionner les alertes en bas à gauche",
"toastPositionRight": "Positionner les alertes en bas à droite",
"autoBackup": {
"eyebrow": "Sauvegarde locale",
"title": "Sauvegarde automatique",
"description": "Cette fonctionnalité n’est pas disponible dans tous les navigateurs. Les sauvegardes restent locales à ce navigateur et ne sont envoyées à aucun serveur.",
"compatibleLabel": "Navigateurs compatibles",
"browsers": [
"Opera GX",
"Google Chrome",
"Microsoft Edge"
],
"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",
"saveNow": "Sauvegarder maintenant",
"noFolder": "Aucun dossier",
"never": "Jamais",
"lastBackup": "Dernière sauvegarde",
"retention": "10 versions sont conservées.",
"unsupported": "Cette sauvegarde locale n’est pas disponible dans ce navigateur.",
"close": "Fermer"
},
"summary": "Créez des espaces modulaires pour regrouper vos outils de session : notes, suivis, références, captures et futurs outils restent accessibles rapidement, en local, par jeu ou par usage.",
"creationTemplate": {
"memos": {

View file

@ -1,7 +1,7 @@
// Rôle : regroupe les modales, notifications et viewers rendus au-dessus de l'application.
import { Icon } from "./Icon.jsx";
import { ImageViewer } from "./ImageViewer.jsx";
import { ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } from "./ToolboxModals.jsx";
import { AutoBackupModal, ConfirmModal, CreateToolboxModal, LinkToolboxModal, NotificationToast } from "./ToolboxModals.jsx";
import { navigate } from "../router/hashRouter.js";
import { uid } from "../features/toolboxes/storage/toolboxStorage.js";
@ -42,7 +42,11 @@ export function AppOverlays({
getGame,
toolboxes,
links,
setPendingTutorialToolboxId
setPendingTutorialToolboxId,
autoBackupModalOpen,
setAutoBackupModalOpen,
autoBackup,
autoBackupContent
}) {
return (
<>
@ -70,6 +74,7 @@ export function AppOverlays({
actions.linkToolboxToGame(gameId, toolboxId);
setDrawerGameId(gameId);
}} />}
{autoBackupModalOpen && <AutoBackupModal backup={autoBackup} content={autoBackupContent} onClose={() => setAutoBackupModalOpen(false)} />}
{storageError && <ConfirmModal title={storageErrorTitle} message={storageError} confirmLabel="Compris" cancelLabel="Fermer" danger onClose={() => setStorageError("")} />}
{image && <ImageViewer image={image} onClose={() => setImage(null)} createMarkerId={() => uid("marker")} />}
{notifications.length > 0 && (

View file

@ -79,3 +79,56 @@ export function LinkToolboxModal({ toolboxes, selectedId, onClose }) {
</div>
);
}
export function AutoBackupModal({ backup, content, onClose }) {
useModalScrollLock();
const folderName = backup.config.directoryHandle?.name || content.noFolder;
const lastBackup = backup.config.lastBackup
? new Date(backup.config.lastBackup).toLocaleString()
: content.never;
return (
<div className="confirm-modal-root auto-backup-modal-root">
<div className="confirm-backdrop nds-modal-backdrop" onClick={onClose} />
<section className="confirm-modal nds-modal auto-backup-modal" role="dialog" aria-modal="true" aria-labelledby="auto-backup-title">
<header className="auto-backup-modal-header">
<div>
<p className="eyebrow">{content.eyebrow}</p>
<h2 id="auto-backup-title">{content.title}</h2>
</div>
<button className="nds-icon-button" data-size="sm" type="button" onClick={onClose} aria-label={content.close} title={content.close}><Icon name="close" /></button>
</header>
<p className="auto-backup-description">{content.description}</p>
<div className="auto-backup-compatibility">
<strong>{content.compatibleLabel}</strong>
<ul>{content.browsers.map((browser) => <li key={browser}>{browser}</li>)}</ul>
</div>
{!backup.supported ? <p className="nds-inline-notice" data-variant="warning" role="status">{content.unsupported}</p> : (
<>
<div className="auto-backup-folder">
<div><span className="eyebrow">{content.folderLabel}</span><strong>{folderName}</strong></div>
<button className="nds-button" data-size="sm" type="button" disabled={backup.busy} onClick={() => void backup.chooseDirectory()}>{backup.config.directoryHandle ? content.changeFolder : content.chooseFolder}</button>
</div>
<div className="auto-backup-last">
<span className="eyebrow">{content.lastBackup}</span>
<strong>{lastBackup}</strong>
</div>
<label className="auto-backup-toggle">
<input className="native-switch-input" type="checkbox" checked={backup.config.enabled} disabled={backup.busy} onChange={(event) => void backup.setEnabled(event.target.checked)} />
<span className="switch-track" aria-hidden="true"><span className="switch-thumb" /></span>
<span className="auto-backup-toggle-copy"><strong>{content.enabled}</strong><small>{content.toggleHelp}</small><small>{content.retention}</small></span>
</label>
{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 !== "granted" && <button className="nds-button" data-size="sm" 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} onClick={() => void backup.saveNow()}>{content.saveNow}</button>
</footer>
</>
)}
<footer className="nds-modal-footer auto-backup-close-footer">
<button className="nds-button" data-size="sm" type="button" onClick={onClose}>{content.close}</button>
</footer>
</section>
</div>
);
}

View file

@ -0,0 +1,18 @@
// Rôle : affiche les listes Aniimo exportables en checklist.
import { useState } from "react";
import { GameListsPage } from "../GameListsPage.jsx";
export function AniimoLists({ game, aniimo, linkedToolboxId, actions }) {
const lists = aniimo.lists || [];
const [selectedListId, setSelectedListId] = useState("");
return (
<GameListsPage
game={game}
lists={lists}
selectedListId={selectedListId}
onSelectList={setSelectedListId}
linkedToolboxId={linkedToolboxId}
onCreateChecklist={(list) => actions.createChecklistFromList(game.id, list)}
/>
);
}

View file

@ -6,6 +6,7 @@ import { Icon } from "../../../components/Icon.jsx";
import { GameBreadcrumb } from "../GameBreadcrumb.jsx";
import { AniimoCard } from "./AniimoCard.jsx";
import { AniimoFilters } from "./AniimoFilters.jsx";
import { AniimoLists } from "./AniimoLists.jsx";
import { getAniimoStatsMax, aniimoLabel, filterAniimoVariants, groupAniimoVariants } from "./utils.js";
function AniimoListing({ game, aniimo, filters, setFilters, content }) {
@ -41,11 +42,19 @@ function AniimoListing({ game, aniimo, filters, setFilters, content }) {
</>;
}
export function AniimoPage({ category, game, aniimo, filters, setFilters, siteContent }) {
export function AniimoPage({ category, game, aniimo, filters, setFilters, siteContent, links, actions }) {
const content = siteContent.aniimo;
if (category === "lists") {
if (!aniimo.loaded) return <section className="empty nds-empty-state" aria-busy="true"><h1>{content.loading}</h1></section>;
if (aniimo.error) return <section className="empty nds-empty-state" role="alert"><h1>{content.error}</h1><p>{aniimo.error}</p></section>;
return <AniimoLists game={game} aniimo={aniimo} linkedToolboxId={links?.[game.id] || ""} actions={actions} />;
}
if (category !== "aniimos") return <>
<section className="game-hero" style={{ "--game-hero-image": `url("${game.images.heroBg}")` }}><div><p className="eyebrow">{game.eyebrow}</p><h1>{game.title}</h1><p>{game.summary}</p></div></section>
<section className="game-home-grid"><a className="feature game-home-card aniimo-home-card" href="#/games/aniimo/aniimos"><img src={game.images.aniimosCover} alt="" /><strong>{content.title}</strong><span>{content.homeDescription}</span></a></section>
<section className="game-home-grid">
<a className="feature game-home-card aniimo-home-card" href="#/games/aniimo/aniimos"><img src={game.images.aniimosCover} alt="" /><strong>{content.title}</strong><span>{content.homeDescription}</span></a>
<a className="feature game-home-card" href="#/games/aniimo/lists"><span className="game-home-card-icon" aria-hidden="true"><span className="module-icon-svg module-icon-checklist" /></span><strong>Listes</strong><span>Données copiables au format checklist pour préparer vos suivis.</span></a>
</section>
</>;
if (!aniimo.loaded) return <section className="empty nds-empty-state" aria-busy="true"><h1>{content.loading}</h1></section>;
if (aniimo.error) return <section className="empty nds-empty-state" role="alert"><h1>{content.error}</h1><p>{aniimo.error}</p></section>;

View file

@ -134,16 +134,19 @@ export const INITIAL_ANIIMO_STATE = {
error: "",
translations: {},
variants: [],
filterOptions: { elements: [], roles: [], abilities: [] }
filterOptions: { elements: [], roles: [], abilities: [] },
lists: []
};
export async function loadAniimoData(signal) {
const responses = await Promise.all([
fetch("/data/aniimo/aniimos.json", { signal }),
fetch("/data/aniimo/i18n/fr.json", { signal }),
fetch("/data/aniimo/i18n/en.json", { signal })
fetch("/data/aniimo/i18n/en.json", { signal }),
fetch("/data/aniimo/lists/index.json", { signal })
]);
if (responses.some((response) => !response.ok)) throw new Error("Les données Aniimo sont indisponibles.");
const [aniimos, translations, englishTranslations] = await Promise.all(responses.map((response) => response.json()));
return { loaded: true, loading: false, error: "", translations, englishTranslations, ...prepareAniimoData(aniimos, translations, englishTranslations) };
const [aniimos, translations, englishTranslations, listIndex] = await Promise.all(responses.map((response) => response.json()));
const lists = await loadGameLists("aniimo", listIndex, signal);
return { loaded: true, loading: false, error: "", translations, englishTranslations, lists, ...prepareAniimoData(aniimos, translations, englishTranslations) };
}

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é.");
}
};

View file

@ -17,6 +17,7 @@ import { useToolboxAlerts } from "./hooks/useToolboxAlerts.js";
import { RouteContent } from "./router/RouteContent.jsx";
import { useHashRoute } from "./router/hashRouter.js";
import { normalizeCalendarData, normalizeTimerData } from "./features/toolboxes/storage/toolboxStorage.js";
import { useAutoBackup } from "./features/toolboxes/storage/useAutoBackup.js";
const ToolboxDrawer = lazy(() => import("./features/toolboxes/ToolboxPages.jsx").then((module) => ({ default: module.ToolboxDrawer })));
const DEFAULT_NOTIFICATION_DURATION_MS = 120000;
@ -34,6 +35,7 @@ function App() {
const [filters, setFilters] = useGameFilters();
const [confirmModal, setConfirmModal] = useState(null);
const [createModal, setCreateModal] = useState(null);
const [autoBackupModalOpen, setAutoBackupModalOpen] = useState(false);
const [linkModalGameId, setLinkModalGameId] = useState("");
const [drawerGameId, setDrawerGameId] = useState("");
const [image, setImage] = useState(null);
@ -42,6 +44,7 @@ function App() {
const [storageError, setStorageError] = useState("");
const handleStorageError = useCallback((message) => setStorageError(message), []);
const store = useIndexedToolboxes(handleStorageError);
const autoBackup = useAutoBackup(store);
const guideChecklistSession = useGuideChecklistSession(store);
const notify = useCallback((message, options = {}) => {
@ -148,6 +151,7 @@ function App() {
storageReady={store.ready}
toastPosition={store.toastPosition}
setToastPosition={store.setToastPosition}
onOpenAutoBackup={() => setAutoBackupModalOpen(true)}
getModuleData={store.getModuleData}
updateToolbox={actions.updateToolbox}
updateModuleData={store.updateModuleData}
@ -195,6 +199,10 @@ function App() {
toolboxes={store.toolboxes}
links={store.links}
setPendingTutorialToolboxId={setPendingTutorialToolboxId}
autoBackupModalOpen={autoBackupModalOpen}
setAutoBackupModalOpen={setAutoBackupModalOpen}
autoBackup={autoBackup}
autoBackupContent={siteContent.toolboxes.autoBackup}
/>
</Shell>
</GuideChecklistContext.Provider>

View file

@ -9,6 +9,9 @@ function currentRoute() {
if (path === "/mhwilds/monsters") return "/games/mhwilds/monsters";
if (path === "/mhwilds/endemic") return "/games/mhwilds/endemic";
if (path === "/mhwilds/lists") return "/games/mhwilds/lists";
if (path === "/aniimo") return "/games/aniimo";
if (path === "/aniimo/aniimos") return "/games/aniimo/aniimos";
if (path === "/aniimo/lists") return "/games/aniimo/lists";
return path;
}

View file

@ -31,6 +31,218 @@
margin-bottom: var(--space-6);
}
.auto-backup-modal {
width: min(620px, calc(100vw - 32px));
max-height: min(760px, calc(100vh - 32px));
overflow-y: auto;
}
.auto-backup-modal-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
}
.auto-backup-modal-header h2 {
margin-bottom: 0;
}
.auto-backup-modal-header .eyebrow,
.auto-backup-folder .eyebrow,
.auto-backup-last .eyebrow {
margin-bottom: 4px;
}
.auto-backup-description {
color: var(--color-text-secondary);
line-height: 1.6;
}
.auto-backup-compatibility {
margin-bottom: var(--space-5);
padding: var(--space-4);
border: 1px solid rgba(165, 180, 252, 0.14);
border-radius: var(--radius-md);
background: rgba(5, 7, 17, 0.32);
}
.auto-backup-compatibility strong {
display: block;
color: var(--color-text-primary);
font-size: var(--font-size-sm);
}
.auto-backup-compatibility ul {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: var(--space-3) 0;
padding: 0;
list-style: none;
}
.auto-backup-compatibility li {
padding: 5px 9px;
border: 1px solid rgba(246, 196, 83, 0.2);
border-radius: var(--radius-pill);
background: rgba(246, 196, 83, 0.08);
color: var(--color-text-secondary);
font-size: var(--font-size-xs);
font-weight: 800;
}
.auto-backup-compatibility p {
margin: 0;
color: var(--color-text-muted);
font-size: var(--font-size-sm);
}
.auto-backup-folder {
display: flex;
align-items: stretch;
flex-direction: column;
gap: var(--space-4);
margin-top: var(--space-5);
}
.auto-backup-folder,
.auto-backup-last {
padding: var(--space-4);
border: 1px solid rgba(165, 180, 252, 0.14);
border-radius: var(--radius-md);
background: rgba(16, 20, 38, 0.42);
}
.auto-backup-folder > div {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.auto-backup-folder strong {
overflow-wrap: anywhere;
color: var(--color-text-primary);
}
.auto-backup-last {
margin-top: var(--space-6);
}
.auto-backup-last strong {
display: block;
color: var(--color-text-primary);
font-size: var(--font-size-lg);
}
.auto-backup-toggle {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
gap: var(--space-3);
margin-top: var(--space-5);
padding: var(--space-4);
border: 1px solid rgba(165, 180, 252, 0.16);
border-radius: var(--radius-md);
background: rgba(16, 20, 38, 0.52);
cursor: pointer;
}
.auto-backup-toggle:has(input:disabled) {
cursor: not-allowed;
opacity: 0.65;
}
.auto-backup-toggle .native-switch-input {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
clip-path: inset(50%);
white-space: nowrap;
}
.auto-backup-toggle .switch-track {
position: relative;
display: block;
width: 52px;
height: 30px;
flex: 0 0 auto;
border: 1px solid rgba(165, 180, 252, 0.24);
border-radius: var(--radius-pill);
background: rgba(7, 10, 24, 0.72);
transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}
.auto-backup-toggle .switch-thumb {
position: absolute;
top: 50%;
left: 4px;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--color-text-muted);
transform: translateY(-50%);
transition: left var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
}
.auto-backup-toggle input:checked + .switch-track {
border-color: rgba(246, 196, 83, 0.5);
background: rgba(246, 196, 83, 0.16);
}
.auto-backup-toggle input:checked + .switch-track .switch-thumb {
left: 26px;
background: var(--color-accent-gold);
}
.auto-backup-toggle:focus-within {
border-color: rgba(246, 196, 83, 0.45);
}
.auto-backup-toggle-copy {
display: flex;
min-width: 0;
flex-direction: column;
gap: 4px;
}
.auto-backup-toggle-copy strong {
color: var(--color-text-primary);
font-size: var(--font-size-base);
}
.auto-backup-toggle-copy small {
color: var(--color-text-muted);
line-height: 1.45;
}
.auto-backup-modal-footer {
margin-top: var(--space-5);
}
.auto-backup-close-footer {
margin-top: var(--space-3);
padding-top: var(--space-4);
border-top: 1px solid rgba(165, 180, 252, 0.12);
}
@media (max-width: 600px) {
.auto-backup-folder {
gap: var(--space-3);
}
.auto-backup-modal-footer {
flex-wrap: wrap;
}
.auto-backup-modal-footer > button {
flex: 1 1 180px;
}
}
@property --notification-toast-angle {
syntax: "<angle>";
inherits: false;