Defer media loading and strengthen persistence tests
All checks were successful
Deploy Sokko G / deploy (push) Successful in 18s

This commit is contained in:
Shinuwa 2026-09-12 23:28:05 +02:00
parent 09a2d4b71a
commit d0e1fd73b1
19 changed files with 459 additions and 71 deletions

View file

@ -40,3 +40,5 @@
{"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}} {"id":"int-4d9a5305507377a0f0d42d8c0e073c0e","kind":"field_change","created_at":"2026-09-12T11:13:23.757763936Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}
{"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}} {"id":"int-e86990d8d18afb8dec65036e0ab7712f","kind":"field_change","created_at":"2026-09-12T11:14:17.70653268Z","actor":"Shinuwa","issue_id":"sokko-g-usb","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Reproducible production profiling completed and documented: npm run profile:toolboxes, four disk-backed scenarios and five genuine reloads each, 117.9 MiB identical media dataset. Raw and analysis reports in ignored .local/performance. Closed media retains about118MiB extra JS heap. check:full passes lint,101 Node tests,build,40 E2E. Initial read failure handling tracked separately as P1 sokko-g-d5u; no storage/schema changes."}}
{"id":"int-5a1d6dccbbcebf69807b6134785175ef","kind":"field_change","created_at":"2026-09-12T18:30:49.351851361Z","actor":"Shinuwa","issue_id":"sokko-g-d5u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Protected IndexedDB bootstrap: readiness granted only after complete successful reads; all store writes rejected before initialization; create/import/export guards; startup error/loading gate with non-destructive retry; rejected connection promise evicted for reopening; generic accurate write-error title. Editorial strings validated and storage/workflow docs updated. Four new E2E cover repeated open/read failures, preserved data and exports, creation after retry, direct command guards and later write errors. Desktop/mobile screenshots reviewed. npm run check:full passed lint,101 Node tests,build,44 E2E. No schema or format changes."}} {"id":"int-5a1d6dccbbcebf69807b6134785175ef","kind":"field_change","created_at":"2026-09-12T18:30:49.351851361Z","actor":"Shinuwa","issue_id":"sokko-g-d5u","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Protected IndexedDB bootstrap: readiness granted only after complete successful reads; all store writes rejected before initialization; create/import/export guards; startup error/loading gate with non-destructive retry; rejected connection promise evicted for reopening; generic accurate write-error title. Editorial strings validated and storage/workflow docs updated. Four new E2E cover repeated open/read failures, preserved data and exports, creation after retry, direct command guards and later write errors. Desktop/mobile screenshots reviewed. npm run check:full passed lint,101 Node tests,build,44 E2E. No schema or format changes."}}
{"id":"int-224b7820169f57116ea4fd87a50560f1","kind":"field_change","created_at":"2026-09-12T21:26:21.88315065Z","actor":"Shinuwa","issue_id":"sokko-g-bhy","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Implemented selective images/imageAnnotation hydration with local retry, preserved unread values in atomic snapshots, and serialized complete exports without populating the UI cache. No schema change; timer/calendar remain eager. check:full passed including 52 E2E; four production scenarios profiled alone, five reloads each. 40-media baseline 120.68 to 2.78 MiB JS heap, 610.1 to 445.4 ms median ready. Reports in .local/performance; docs updated. Opened media remain cached until reload. No git commit or push performed."}}
{"id":"int-4036248c53312cfaafae4ee37bb145bd","kind":"field_change","created_at":"2026-09-12T21:26:40.724321767Z","actor":"Shinuwa","issue_id":"sokko-g-zsd","extra":{"field":"status","new_value":"open","old_value":"in_progress"}}

View file

@ -90,6 +90,8 @@ JavaScript après GC. Le rapport brut est écrit dans
build ou tests concurrents. Ce n'est ni un budget CI, ni une mesure du démarrage build ou tests concurrents. Ce n'est ni un budget CI, ni une mesure du démarrage
à cache froid, ni de la mémoire totale du navigateur ; les résultats dépendent à cache froid, ni de la mémoire totale du navigateur ; les résultats dépendent
de la machine et ne couvrent pas le décodage des images dans une toolbox ouverte. de la machine et ne couvrent pas le décodage des images dans une toolbox ouverte.
Le profil suit les lectures groupées et ciblées et vérifie que les valeurs des
outils Images restent sur disque tant qu'aucune toolbox n'est ouverte.
## Contenu éditable ## Contenu éditable

View file

@ -15,6 +15,8 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
- Vérifier l'import et l'export si l'outil stocke des données. - Vérifier l'import et l'export si l'outil stocke des données.
- Pour une modification de persistance, vérifier les écritures successives en succès/échec, les suppressions, ainsi qu'un import concurrent avec une édition ; un échec ne doit pas restaurer une valeur jamais confirmée. - Pour une modification de persistance, vérifier les écritures successives en succès/échec, les suppressions, ainsi qu'un import concurrent avec une édition ; un échec ne doit pas restaurer une valeur jamais confirmée.
- Vérifier qu'une erreur d'ouverture ou de lecture au démarrage bloque les écritures, créations, imports et exports sans afficher une fausse liste vide ; plusieurs tentatives puis une reprise réussie doivent retrouver les données initiales sans recharger la page ni les effacer. - Vérifier qu'une erreur d'ouverture ou de lecture au démarrage bloque les écritures, créations, imports et exports sans afficher une fausse liste vide ; plusieurs tentatives puis une reprise réussie doivent retrouver les données initiales sans recharger la page ni les effacer.
- Pour Images et Annotation d'images, vérifier la lecture différée en page complète et panneau latéral, la reprise après échec local et l'absence d'écrasement pendant le chargement. Les alertes des autres outils doivent rester actives sans ouvrir les médias.
- Vérifier qu'une création, un import ou une suppression préserve les médias non lus des autres outils ; les exports doivent les inclure et refuser un téléchargement partiel en cas d'échec. Une lecture en cours ne doit pas réintroduire des données supprimées.
- Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis le toggle import/export du header. - Si l'outil a un contenu représentable en texte, ajouter ou vérifier l'import/export texte round-trip depuis le toggle import/export du header.
- Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu. - Si l'outil porte un état de progression, vérifier que l'import texte repart de l'état initial attendu.
- Vérifier l'affichage dans la page toolbox complète. - Vérifier l'affichage dans la page toolbox complète.

View file

@ -37,7 +37,7 @@ Entrée type dans `modules` :
### Ordre et confirmation des écritures ### Ordre et confirmation des écritures
Le démarrage attend la lecture complète du registre, des liens, des modules et Le démarrage attend la lecture du registre, des liens, des modules hors médias et
de la position des notifications avant d'ouvrir l'interface. Un échec laisse de la position des notifications avant d'ouvrir l'interface. Un échec laisse
le stockage non prêt : les méthodes d'écriture refusent les opérations, et la le stockage non prêt : les méthodes d'écriture refusent les opérations, et la
création, l'import et l'export ne peuvent pas utiliser un état vide incomplet. création, l'import et l'export ne peuvent pas utiliser un état vide incomplet.
@ -58,6 +58,25 @@ la fin de transaction. Une erreur synchrone pendant sa préparation déclenche
l'abandon de la transaction. Les écritures ordinaires d'un module restent ciblées l'abandon de la transaction. Les écritures ordinaires d'un module restent ciblées
sur sa clé et ne réécrivent pas les images des autres outils. sur sa clé et ne réécrivent pas les images des autres outils.
Les données des outils `images` et `imageAnnotation` sont lues à leur première
ouverture, en page complète ou dans le panneau latéral. Les minuteurs et calendriers
restent chargés au démarrage pour assurer les alertes globales. Les couvertures,
incluses dans le registre des toolboxes, restent également chargées au démarrage.
Un marqueur uniquement en mémoire distingue un média non lu d'un outil vide ; il
n'est jamais persisté ni exporté. Les transactions de snapshot conservent les
entrées non lues, sauf celles explicitement supprimées de l'état courant.
Une lecture tardive ne réintroduit pas un outil supprimé entre-temps.
Pendant la lecture, le contenu de l'outil est bloqué. Un échec affiche une reprise
locale sans empêcher les autres outils de fonctionner ni autoriser l'écrasement
du média non lu. Après ouverture, les médias restent en mémoire jusqu'au
rechargement de l'application ; il n'y a pas d'éviction à la fermeture.
Les exports attendent les écritures précédentes dans la même file et lisent les
médias manquants depuis l'état confirmé, sans les ajouter au cache de l'interface.
Si cette lecture échoue, aucun fichier partiel n'est téléchargé. Les formats
IndexedDB et import/export sont inchangés.
Cette coordination est locale à l'instance React ; elle ne synchronise pas Cette coordination est locale à l'instance React ; elle ne synchronise pas
l'édition entre plusieurs onglets. l'édition entre plusieurs onglets.

179
e2e/deferred-media.spec.js Normal file
View file

@ -0,0 +1,179 @@
// Rôle : vérifie les lectures à la demande, les exports complets et la conservation des médias non chargés.
/* global window, document, IDBObjectStore, DOMException */
import { test, expect } from "@playwright/test";
import { Buffer } from "node:buffer";
async function setup(page) {
await page.route("**/__media-seed", (route) => route.fulfill({ contentType: "text/html", body: "<!doctype html><title>Seed</title>" }));
await page.goto("/__media-seed");
const seeded = await page.evaluate(async () => {
const db = await import("/src/utils/indexedDbStorage.js");
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 32;
const context = canvas.getContext("2d");
context.fillStyle = "red";
context.fillRect(0, 0, 32, 32);
const red = canvas.toDataURL();
context.fillStyle = "green";
context.fillRect(0, 0, 32, 32);
const green = canvas.toDataURL();
const targetAt = Date.now() + 60000;
const toolboxes = [
{ id: "a", name: "Alpha", updatedAt: "2026-09-12T12:00:00Z", modules: [
{ id: "images", type: "images", title: "Images" },
{ id: "map", type: "imageAnnotation", title: "Carte" },
{ id: "timer", type: "timer", title: "Timer" }
] },
{ id: "b", name: "Beta", updatedAt: "2026-09-12T12:00:00Z", modules: [{ id: "images", type: "images", title: "Images" }] }
];
const modules = {
"a:images": { images: [{ id: "red", label: "Rouge", dataUrl: red }] },
"a:map": { image: green, markers: [{ id: "marker", x: 25, y: 50, label: "Entrée" }], drawings: { strokes: [{ id: "stroke", color: "#f6c453", width: 4, points: [{ x: 10, y: 10 }, { x: 80, y: 80 }] }] } },
"a:timer": { countdowns: [{ id: "alert", type: "duration", label: "Alerte fermée", targetAt, durationMs: 60000, alertMode: "site" }] },
"b:images": { images: [{ id: "green", label: "Vert", dataUrl: green }] }
};
await db.setToolboxSnapshot(toolboxes, { mhwilds: "a" }, modules);
return { toolboxes, modules, targetAt };
});
await page.addInitScript(() => {
window.moduleReads = [];
window.failedMediaKey = "";
const get = IDBObjectStore.prototype.get;
IDBObjectStore.prototype.get = function (key) {
if (this.name === "modules") {
window.moduleReads.push(key);
if (key === window.failedMediaKey) throw new DOMException("Média indisponible", "UnknownError");
}
return get.call(this, key);
};
});
await page.goto("/#/toolboxes");
await expect(page.locator(".toolbox-card")).toHaveCount(2);
return seeded;
}
async function disk(page) {
return page.evaluate(async () => {
const db = await import("/src/utils/indexedDbStorage.js");
return { toolboxes: await db.getToolboxes(), modules: Object.fromEntries((await db.getAllModuleData()).map((entry) => [entry.key, entry.data])) };
});
}
async function exportJson(page, buttonName = "Exporter tout") {
const download = page.waitForEvent("download");
await page.getByRole("button", { name: buttonName, exact: true }).click();
const stream = await (await download).createReadStream();
const chunks = [];
for await (const chunk of stream) chunks.push(chunk);
return JSON.parse(Buffer.concat(chunks).toString());
}
test("closed media stays unread while site-wide timer alerts still fire", async ({ page }) => {
const seeded = await setup(page);
expect(await page.evaluate(() => window.moduleReads)).not.toEqual([]);
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
await page.clock.install({ time: new Date(seeded.targetAt - 3000) });
await page.clock.pauseAt(new Date(seeded.targetAt - 2000));
await page.clock.runFor(3000);
await expect(page.getByRole("status").filter({ hasText: "Alerte fermée" })).toBeVisible();
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
});
test("creation, import and deletion preserve unread media and global exports include it", async ({ page }) => {
const seeded = await setup(page);
await page.getByRole("button", { name: "Nouvelle toolbox", exact: true }).click();
await page.getByRole("textbox", { name: "Nom", exact: true }).fill("New");
await page.getByRole("button", { name: "Créer", exact: true }).click();
await expect.poll(async () => (await disk(page)).toolboxes.length).toBe(3);
expect((await disk(page)).modules).toMatchObject(seeded.modules);
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
await page.goto("/#/toolboxes");
await page.getByText("Importer une toolbox", { exact: true }).locator("input").setInputFiles({
name: "import.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify({ toolbox: { id: "source", name: "Imported", modules: [] }, modules: {} }))
});
await expect(page.locator(".toolbox-card")).toHaveCount(4);
expect((await disk(page)).modules).toMatchObject(seeded.modules);
expect(await page.evaluate(() => window.moduleReads.every((key) => key === "a:timer"))).toBe(true);
const exported = await exportJson(page);
for (const key of ["a:images", "a:map", "b:images"]) expect(exported.modules[key]).toEqual(seeded.modules[key]);
await page.getByRole("button", { name: "Supprimer Beta", exact: true }).click();
await page.getByRole("dialog").getByRole("button", { name: "Supprimer", exact: true }).click();
await expect.poll(async () => (await disk(page)).modules["b:images"]).toBeUndefined();
expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]);
expect((await disk(page)).modules["a:map"]).toEqual(seeded.modules["a:map"]);
});
test("single exports include deferred media without reading other toolboxes or retaining it", async ({ page }) => {
const seeded = await setup(page);
const exported = await exportJson(page, "Exporter Alpha");
const imageModule = exported.toolbox.modules.find((module) => module.type === "images");
const mapModule = exported.toolbox.modules.find((module) => module.type === "imageAnnotation");
expect(exported.modules[imageModule.id].images[0].dataUrl).toBe(seeded.modules["a:images"].images[0].dataUrl);
expect(exported.modules[mapModule.id].image).toBe(seeded.modules["a:map"].image);
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
const reads = await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length);
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
await expect(page.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
expect(await page.evaluate(() => window.moduleReads.filter((key) => key === "a:images").length)).toBe(reads + 1);
});
test("opening and editing one toolbox only reads its media and reuses it on return", async ({ page }) => {
const seeded = await setup(page);
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
const image = page.locator('[data-module-type="images"] .image-preview img');
await expect(image).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
await expect.poll(() => image.evaluate((element) => element.naturalWidth)).toBe(32);
await expect(page.locator(".annotation-marker")).toHaveCount(1);
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
const initialReads = await page.evaluate(() => window.moduleReads.length);
await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click();
await page.getByRole("textbox", { name: "Libellé de l'image", exact: true }).fill("Edited");
await expect.poll(async () => (await disk(page)).modules["a:images"].images[0].label).toBe("Edited");
await page.goto("/#/toolboxes");
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
await expect(page.getByText("Edited", { exact: true })).toBeVisible();
expect(await page.evaluate(() => window.moduleReads.length)).toBe(initialReads);
});
test("the game drawer loads only the linked toolbox media", async ({ page }) => {
const seeded = await setup(page);
await page.goto("/#/games/mhwilds");
await page.getByRole("button", { name: "Ouvrir la toolbox Alpha", exact: true }).click();
const drawer = page.locator("#toolbox-drawer .drawer-panel");
await expect(drawer.locator('.image-preview img')).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
await expect(drawer.locator(".annotation-marker")).toHaveCount(1);
expect(await page.evaluate(() => window.moduleReads.includes("b:images"))).toBe(false);
});
test("media read errors block only the affected tool and retry without overwriting it", async ({ page }) => {
const seeded = await setup(page);
await page.evaluate(() => { window.failedMediaKey = "a:images"; });
await page.getByRole("link", { name: "Ouvrir Alpha", exact: true }).first().click();
const module = page.locator('[data-module-type="images"]');
await expect(module.getByRole("alert")).toHaveText("Média indisponible");
await page.getByRole("button", { name: "Afficher l'ajout de Images", exact: true }).click();
await expect(module.locator('input[type="file"], .image-preview')).toHaveCount(0);
await expect(page.locator(".annotation-marker")).toHaveCount(1);
expect((await disk(page)).modules["a:images"]).toEqual(seeded.modules["a:images"]);
await module.getByRole("button", { name: "Réessayer" }).click();
await expect(module.getByRole("alert")).toHaveText("Média indisponible");
await page.evaluate(() => { window.failedMediaKey = ""; });
await module.getByRole("button", { name: "Réessayer" }).click();
await expect(module.locator(".image-preview img")).toHaveAttribute("src", seeded.modules["a:images"].images[0].dataUrl);
});
test("failed media exports produce no partial download and can be retried", async ({ page }) => {
const seeded = await setup(page);
const downloads = [];
page.on("download", (download) => downloads.push(download));
await page.evaluate(() => { window.failedMediaKey = "b:images"; });
await page.getByRole("button", { name: "Exporter tout", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "Erreur de stockage local" });
await expect(dialog).toBeVisible();
expect(downloads).toHaveLength(0);
await dialog.getByRole("button", { name: "Compris" }).click();
await page.evaluate(() => { window.failedMediaKey = ""; });
const exported = await exportJson(page);
expect(exported.modules["b:images"]).toEqual(seeded.modules["b:images"]);
expect(downloads).toHaveLength(1);
});

View file

@ -27,3 +27,22 @@ test("a synchronous snapshot error aborts all its IndexedDB writes", async ({ pa
modules: [{ key: "saved:note", data: { text: "Keep" } }] modules: [{ key: "saved:note", data: { text: "Keep" } }]
}); });
}); });
test("partial snapshots preserve deferred values and roll back deletes on failure", async ({ page }) => {
await resetSokkoStorage(page);
const result = await page.evaluate(async () => {
const db = await import("/src/utils/indexedDbStorage.js");
await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { media: { image: "Keep" }, removed: { text: "Delete" } });
let failed = false;
try {
await db.setToolboxSnapshot([{ id: "broken" }], {}, { broken: { invalid: () => {} } }, ["media"]);
} catch { failed = true; }
const afterFailure = await db.getAllModuleData();
await db.setToolboxSnapshot([{ id: "saved", modules: [] }], {}, { note: { text: "New" } }, ["media"]);
return { failed, afterFailure, afterSuccess: await db.getAllModuleData() };
});
expect(result).toEqual({ failed: true,
afterFailure: [{ key: "media", data: { image: "Keep" } }, { key: "removed", data: { text: "Delete" } }],
afterSuccess: [{ key: "media", data: { image: "Keep" } }, { key: "note", data: { text: "New" } }]
});
});

View file

@ -120,7 +120,7 @@ test("commands reject writes, creation, imports and exports while the store is n
created: actions.createToolbox("New"), created: actions.createToolbox("New"),
imported: await actions.importToolbox({ text: forbidden }), imported: await actions.importToolbox({ text: forbidden }),
importedAll: await actions.importAllToolboxes({ text: forbidden }), importedAll: await actions.importAllToolboxes({ text: forbidden }),
exported: actions.exportToolbox("saved"), exportedAll: actions.exportAllToolboxes(), exported: await actions.exportToolbox("saved"), exportedAll: await actions.exportAllToolboxes(),
notices: window.storageProbe.notices notices: window.storageProbe.notices
}; };
}); });

View file

@ -88,16 +88,17 @@ async function instrument(page, expectedCards) {
observer.disconnect(); observer.disconnect();
requestAnimationFrame(() => requestAnimationFrame(() => { metrics.readyMs = performance.now(); })); requestAnimationFrame(() => requestAnimationFrame(() => { metrics.readyMs = performance.now(); }));
} }
const getAll = IDBObjectStore.prototype.getAll; for (const method of ["getAll", "getAllKeys", "get"]) {
IDBObjectStore.prototype.getAll = function (...args) { const original = IDBObjectStore.prototype[method];
IDBObjectStore.prototype[method] = function (...args) {
const start = performance.now(); const start = performance.now();
const request = getAll.apply(this, args); const request = original.apply(this, args);
if (this.name === "modules") { if (this.name === "modules") {
const read = { startMs: start }; const read = { method, key: args[0], startMs: start };
metrics.reads.push(read); metrics.reads.push(read);
request.addEventListener("success", () => { request.addEventListener("success", () => {
read.requestMs = performance.now() - start; read.requestMs = performance.now() - start;
read.entries = request.result.length; read.entries = method === "getAllKeys" ? 0 : method === "get" ? Number(request.result != null) : request.result.length;
}); });
request.addEventListener("error", () => { read.error = request.error?.message; }); request.addEventListener("error", () => { read.error = request.error?.message; });
this.transaction.addEventListener("abort", () => { read.error = this.transaction.error?.message; }); this.transaction.addEventListener("abort", () => { read.error = this.transaction.error?.message; });
@ -108,6 +109,7 @@ async function instrument(page, expectedCards) {
} }
return request; return request;
}; };
}
new PerformanceObserver((list) => { new PerformanceObserver((list) => {
for (const entry of list.getEntries()) metrics.longTasks.push({ startMs: entry.startTime, durationMs: entry.duration }); for (const entry of list.getEntries()) metrics.longTasks.push({ startMs: entry.startTime, durationMs: entry.duration });
}).observe({ type: "longtask", buffered: true }); }).observe({ type: "longtask", buffered: true });
@ -152,8 +154,8 @@ try {
const sample = await page.evaluate(() => ({ ...window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length })); const sample = await page.evaluate(() => ({ ...window.toolboxProfile, cards: document.querySelectorAll(".toolbox-card").length }));
assert.notEqual(sample.timeOrigin, previousTimeOrigin, "Every sample must load a new document"); assert.notEqual(sample.timeOrigin, previousTimeOrigin, "Every sample must load a new document");
previousTimeOrigin = sample.timeOrigin; previousTimeOrigin = sample.timeOrigin;
assert.equal(sample.reads.length, 1, "Production must perform one initial modules read"); assert.equal(sample.reads.reduce((sum, read) => sum + read.entries, 0), scenario.toolboxes, "Only memo values should load before opening a toolbox");
assert.equal(sample.reads[0].entries, dataset.moduleCount); assert.ok(sample.reads.every((read) => read.key == null || !String(read.key).endsWith(":images")), "Closed image modules must stay on disk");
assert.equal(sample.cards, scenario.toolboxes); assert.equal(sample.cards, scenario.toolboxes);
assert.deepEqual(errors, []); assert.deepEqual(errors, []);
await cdp.send("HeapProfiler.collectGarbage"); await cdp.send("HeapProfiler.collectGarbage");
@ -166,7 +168,7 @@ try {
const summary = { const summary = {
readyMedianMs: median(samples.map((sample) => sample.readyMs)), readyMedianMs: median(samples.map((sample) => sample.readyMs)),
readyMaxMs: Math.max(...samples.map((sample) => sample.readyMs)), readyMaxMs: Math.max(...samples.map((sample) => sample.readyMs)),
readMedianMs: median(samples.map((sample) => sample.reads[0].transactionMs)), readMedianMs: median(samples.map((sample) => Math.max(...sample.reads.map((read) => read.startMs + read.transactionMs)) - Math.min(...sample.reads.map((read) => read.startMs)))),
heapMedianMiB: median(samples.map((sample) => sample.jsHeapBytes / 1024 ** 2)), heapMedianMiB: median(samples.map((sample) => sample.jsHeapBytes / 1024 ** 2)),
longestTaskMs: Math.max(0, ...samples.flatMap((sample) => sample.longTasks.map((task) => task.durationMs))) longestTaskMs: Math.max(0, ...samples.flatMap((sample) => sample.longTasks.map((task) => task.durationMs)))
}; };

View file

@ -0,0 +1,43 @@
// Rôle : vérifie la frontière entre médias différés, données vides et écritures concurrentes.
import { test } from "node:test";
import assert from "node:assert/strict";
import { DEFERRED_MODULE_DATA as deferred, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "../website/src/features/toolboxes/storage/deferredModuleData.js";
import { createPersistenceQueue } from "../website/src/features/toolboxes/storage/persistenceQueue.js";
test("only images and annotations are deferred; alert and text tools remain available", () => {
assert.deepEqual(getDeferredModuleKeys([{ id: "box", modules: [
{ id: "image", type: "images" }, { id: "map", type: "imageAnnotation" },
{ id: "timer", type: "timer" }, { id: "calendar", type: "calendar" },
{ id: "memos", type: "memos" }, { id: "notepad", type: "notepad" }
] }]), ["box:image", "box:map"]);
assert.deepEqual(splitModuleData({ "box:image": deferred, "box:timer": { countdowns: [] } }), {
stored: { "box:timer": { countdowns: [] } }, deferred: ["box:image"]
});
});
test("loading never replaces edits or resurrects deleted module data", () => {
const data = { pending: deferred, missing: deferred, edited: { images: ["new"] } };
const loaded = mergeLoadedModuleData(data, ["pending", "missing", "edited", "deleted"], [
{ key: "pending", data: { image: "loaded" } }, { key: "edited", data: "old" }, { key: "deleted", data: "old" }
]);
assert.deepEqual(loaded, { pending: { image: "loaded" }, edited: { images: ["new"] } });
assert.equal(data.pending, deferred);
assert.equal(data.missing, deferred);
});
for (const failDelete of [false, true]) {
test(`a load queued during deletion follows its confirmed outcome (failure=${failDelete})`, async () => {
let finish;
const transaction = new Promise((resolve) => { finish = resolve; });
const queue = createPersistenceQueue({ media: deferred }, () => {});
const removal = queue.enqueue(() => ({}), async () => {
await transaction;
if (failDelete) throw new Error("abort");
});
const load = queue.enqueue((current) => mergeLoadedModuleData(current, ["media"], [{ key: "media", data: "kept" }]), () => {});
finish();
assert.equal(await removal, !failDelete);
assert.equal(await load, true);
assert.deepEqual(queue.getCurrent(), failDelete ? { media: "kept" } : {});
});
}

View file

@ -117,6 +117,7 @@ export function validateSiteContent(site) {
"about.contribute.promptText", "about.contribute.promptText",
"toolboxes.eyebrow", "toolboxes.eyebrow",
"toolboxes.storageStatus.loadingTitle", "toolboxes.storageStatus.loadingTitle",
"toolboxes.storageStatus.mediaLoadingMessage",
"toolboxes.storageStatus.loadingMessage", "toolboxes.storageStatus.loadingMessage",
"toolboxes.storageStatus.errorTitle", "toolboxes.storageStatus.errorTitle",
"toolboxes.storageStatus.errorMessage", "toolboxes.storageStatus.errorMessage",

View file

@ -15,20 +15,17 @@ function countLines(source) {
return source.trimEnd().split("\n").length; return source.trimEnd().split("\n").length;
} }
test("toolbox storage, cards and pages are wired", async () => { test("toolbox cards and pages retain their public UI conventions", async () => {
const source = await readFile("website/src/main.jsx", "utf8");
const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8"); const toolboxCard = await readFile("website/src/features/toolboxes/ToolboxCard.jsx", "utf8");
const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8"); const toolboxPages = await readFile("website/src/features/toolboxes/ToolboxPages.jsx", "utf8");
const toolboxCoverEditor = await readFile("website/src/features/toolboxes/ToolboxCoverEditor.jsx", "utf8"); const toolboxCoverEditor = await readFile("website/src/features/toolboxes/ToolboxCoverEditor.jsx", "utf8");
const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8"); const toolboxActions = await readFile("website/src/features/toolboxes/useToolboxActions.js", "utf8");
const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8"); const toolboxStorage = await readFile("website/src/features/toolboxes/storage/toolboxStorage.js", "utf8");
const storageCompactorRegistry = await readFile("website/src/features/toolboxes/storage/modules/index.js", "utf8");
const checklistStorage = await readFile("website/src/features/toolboxes/storage/modules/checklist.js", "utf8"); const checklistStorage = await readFile("website/src/features/toolboxes/storage/modules/checklist.js", "utf8");
const timerStorage = await readFile("website/src/features/toolboxes/storage/modules/timer.js", "utf8"); const timerStorage = await readFile("website/src/features/toolboxes/storage/modules/timer.js", "utf8");
const calendarStorage = await readFile("website/src/features/toolboxes/storage/modules/calendar.js", "utf8"); const calendarStorage = await readFile("website/src/features/toolboxes/storage/modules/calendar.js", "utf8");
const resetUtils = await readFile("website/src/features/toolboxes/modules/resetUtils.js", "utf8"); const resetUtils = await readFile("website/src/features/toolboxes/modules/resetUtils.js", "utf8");
const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8"); const equipmentStorage = await readFile("website/src/features/toolboxes/storage/modules/equipmentPlanner.js", "utf8");
const indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8");
const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8"); const storageQuota = await readFile("website/src/components/StorageQuota.jsx", "utf8");
const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8"); const importButton = await readFile("website/src/components/ImportButton.jsx", "utf8");
const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8"); const guidedTooltipTour = await readFile("website/src/components/GuidedTooltipTour.jsx", "utf8");
@ -36,25 +33,15 @@ test("toolbox storage, cards and pages are wired", async () => {
const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8"); const groupedReorderHook = await readFile("website/src/hooks/useGroupedReorder.js", "utf8");
const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8"); const draftFormHook = await readFile("website/src/hooks/useDraftForm.js", "utf8");
assert.match(`${indexedToolboxesHook}\n${toolboxPages}`, /indexedDbStorage\.js/);
assert.match(source, /features\/toolboxes\/storage\/useIndexedToolboxes\.js/);
assert.match(source, /features\/toolboxes\/useToolboxActions\.js/);
assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/);
assert.match(indexedToolboxesHook, /getStorageEstimate/);
assert.match(toolboxActions, /export function useToolboxActions/);
assert.match(toolboxActions, /importAllToolboxesPayload/);
assert.match(toolboxActions, /async function addImageFiles/);
assert.match(toolboxActions, /creationTemplate/); assert.match(toolboxActions, /creationTemplate/);
assert.match(toolboxActions, /type: "memos"/); assert.match(toolboxActions, /type: "memos"/);
assert.match(toolboxActions, /type: "links"/); assert.match(toolboxActions, /type: "links"/);
assert.match(toolboxActions, /#\/library/); assert.match(toolboxActions, /#\/library/);
assert.match(`${source}\n${toolboxActions}`, /features\/toolboxes\/storage\/toolboxStorage\.js/);
assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/); assert.match(toolboxStorage, /TOOLBOX_ICON_FILES/);
assert.match(toolboxStorage, /export function normalizeToolboxIcon/); assert.match(toolboxStorage, /export function normalizeToolboxIcon/);
assert.match(toolboxStorage, /export function normalizeToolboxCustomCover/); assert.match(toolboxStorage, /export function normalizeToolboxCustomCover/);
assert.match(toolboxStorage, /customCover: normalizeToolboxCustomCover/); assert.match(toolboxStorage, /customCover: normalizeToolboxCustomCover/);
assert.match(toolboxStorage, /compact\.customCover = normalized\.customCover/); assert.match(toolboxStorage, /compact\.customCover = normalized\.customCover/);
assert.match(toolboxStorage, /export function createGlobalExportPayload/);
assert.match(checklistStorage, /qtyTarget/); assert.match(checklistStorage, /qtyTarget/);
assert.match(checklistStorage, /qtyCurrent/); assert.match(checklistStorage, /qtyCurrent/);
assert.match(checklistStorage, /hideCompletedSections/); assert.match(checklistStorage, /hideCompletedSections/);
@ -62,23 +49,6 @@ test("toolbox storage, cards and pages are wired", async () => {
assert.match(checklistStorage, /hideWhenComplete/); assert.match(checklistStorage, /hideWhenComplete/);
assert.match(equipmentStorage, /collapsed/); assert.match(equipmentStorage, /collapsed/);
assert.match(toolboxStorage, /module\.scrollable/); assert.match(toolboxStorage, /module\.scrollable/);
assert.match(toolboxStorage, /normalizeLinksData/);
assert.match(toolboxStorage, /normalizeCountersData/);
assert.match(toolboxStorage, /normalizeCombosData/);
assert.match(toolboxStorage, /normalizeCalculatorData/);
assert.match(toolboxStorage, /normalizeTableData/);
assert.match(toolboxStorage, /normalizeTimerData/);
assert.match(toolboxStorage, /normalizeCalendarData/);
assert.match(toolboxStorage, /normalizeTaskPlannerData/);
assert.match(toolboxStorage, /normalizeGoalTreeData/);
assert.match(toolboxStorage, /normalizeEquipmentPlannerData/);
assert.match(toolboxStorage, /summarizeEquipmentPlannerData/);
assert.match(toolboxStorage, /normalizeImageAnnotationData/);
assert.match(toolboxStorage, /export function compactModuleDataForStorage\(type, value\)/);
assert.match(storageCompactorRegistry, /export const MODULE_DATA_COMPACTORS/);
assert.match(storageCompactorRegistry, /timer: compactTimerDataForStorage/);
assert.match(storageCompactorRegistry, /calendar: compactCalendarDataForStorage/);
assert.match(storageCompactorRegistry, /goalTree: compactGoalTreeDataForStorage/);
assert.match(timerStorage, /scrollResults/); assert.match(timerStorage, /scrollResults/);
assert.match(calendarStorage, /normalizeCalendarData/); assert.match(calendarStorage, /normalizeCalendarData/);
assert.match(resetUtils, /export function getLatestDailyReset/); assert.match(resetUtils, /export function getLatestDailyReset/);

View file

@ -1795,6 +1795,7 @@
"toolboxes": { "toolboxes": {
"storageStatus": { "storageStatus": {
"loadingTitle": "Chargement des données locales", "loadingTitle": "Chargement des données locales",
"mediaLoadingMessage": "Chargement des médias…",
"loadingMessage": "Lecture des toolboxes en cours…", "loadingMessage": "Lecture des toolboxes en cours…",
"errorTitle": "Données locales indisponibles", "errorTitle": "Données locales indisponibles",
"errorMessage": "Le chargement a échoué. Les modifications et les exports sont bloqués pour protéger les données existantes.", "errorMessage": "Le chargement a échoué. Les modifications et les exports sont bloqués pour protéger les données existantes.",

View file

@ -194,6 +194,9 @@ function ToolboxView({ siteContent, toolbox, toolboxGame, embedded, actions, sto
} }
const moduleContext = { const moduleContext = {
getModuleData, getModuleData,
ensureModuleData: actions.ensureModuleData,
isModuleDataReady: actions.isModuleDataReady,
storageStatus: siteContent.toolboxes.storageStatus,
storageReady, storageReady,
moduleText, moduleText,
normalizeChecklistData, normalizeChecklistData,

View file

@ -0,0 +1,28 @@
// Rôle : attend les médias persistés avant de monter un outil éditable et propose une reprise locale en cas d'échec.
import { useEffect, useState } from "react";
import { Icon } from "../../../components/Icon.jsx";
export function ModuleDataGate({ context, toolboxId, moduleId, children }) {
const ready = context.isModuleDataReady?.(toolboxId, moduleId) ?? true;
const load = context.ensureModuleData;
const [error, setError] = useState("");
const [attempt, setAttempt] = useState(0);
useEffect(() => {
if (ready || !load) return;
let cancelled = false;
void load(toolboxId, moduleId).catch((error) => {
if (!cancelled) setError(error?.message || context.storageStatus.errorTitle);
});
return () => { cancelled = true; };
}, [ready, load, toolboxId, moduleId, attempt, context.storageStatus?.errorTitle]);
if (ready) return children;
return (
<div className="empty nds-empty-state" aria-busy={!error}>
<p role={error ? "alert" : "status"}>{error || context.storageStatus.mediaLoadingMessage}</p>
{error && <button className="nds-button" data-size="sm" type="button" onClick={() => {
setError("");
setAttempt((value) => value + 1);
}}><Icon name="refresh" />{context.storageStatus.retryLabel}</button>}
</div>
);
}

View file

@ -5,6 +5,7 @@ import { useGroupedReorder } from "../../../hooks/useGroupedReorder.js";
import { useInlineEdit } from "../../../hooks/useInlineEdit.js"; import { useInlineEdit } from "../../../hooks/useInlineEdit.js";
import { supportsTextExchange } from "./textImport.js"; import { supportsTextExchange } from "./textImport.js";
import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx"; import { MODULE_COMPONENTS } from "./toolboxModuleComponents.jsx";
import { ModuleDataGate } from "./ModuleDataGate.jsx";
const MODULE_COLUMN_IDS = ["left", "right"]; const MODULE_COLUMN_IDS = ["left", "right"];
@ -264,7 +265,9 @@ function ModuleShell({ toolbox, module, context, reorder, itemGroup = "", onRena
</header> </header>
<div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}> <div className={`module-content ${scrollable ? "is-scrollable legacy-scrollbar" : ""}`}>
<Suspense fallback={<ModuleLoadingFallback label={label} />}> <Suspense fallback={<ModuleLoadingFallback label={label} />}>
<ModuleDataGate key={`${toolbox.id}:${module.id}`} context={context} toolboxId={toolbox.id} moduleId={module.id}>
<Component {...componentProps} /> <Component {...componentProps} />
</ModuleDataGate>
</Suspense> </Suspense>
</div> </div>
</article> </article>

View file

@ -0,0 +1,29 @@
// Rôle : distingue les médias non lus des outils vides et fusionne les lectures sans ressusciter les suppressions.
export const DEFERRED_MODULE_DATA = Symbol("deferred module data");
export function getDeferredModuleKeys(toolboxes) {
return toolboxes.flatMap((toolbox) => (toolbox.modules || [])
.filter((module) => module.type === "images" || module.type === "imageAnnotation")
.map((module) => `${toolbox.id}:${module.id}`));
}
export function splitModuleData(moduleData) {
const stored = {};
const deferred = [];
for (const [key, value] of Object.entries(moduleData)) {
if (value === DEFERRED_MODULE_DATA) deferred.push(key);
else stored[key] = value;
}
return { stored, deferred };
}
export function mergeLoadedModuleData(moduleData, keys, entries) {
const next = { ...moduleData };
const loaded = new Map(entries.map((entry) => [entry.key, entry.data]));
for (const key of keys) {
if (next[key] !== DEFERRED_MODULE_DATA) continue;
if (loaded.has(key)) next[key] = loaded.get(key);
else delete next[key];
}
return next;
}

View file

@ -2,6 +2,7 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
getAllModuleData as dbGetAllModuleData, getAllModuleData as dbGetAllModuleData,
getModuleDataEntries as dbGetModuleDataEntries,
getLinks as dbGetLinks, getLinks as dbGetLinks,
getSetting as dbGetSetting, getSetting as dbGetSetting,
getStorageEstimate, getStorageEstimate,
@ -23,6 +24,7 @@ import {
import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js"; import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
import { createPersistenceQueue } from "./persistenceQueue.js"; import { createPersistenceQueue } from "./persistenceQueue.js";
import { createQuotaRefresh } from "./quotaRefresh.js"; import { createQuotaRefresh } from "./quotaRefresh.js";
import { DEFERRED_MODULE_DATA, getDeferredModuleKeys, mergeLoadedModuleData, splitModuleData } from "./deferredModuleData.js";
function normalizeToolboxes(toolboxes) { function normalizeToolboxes(toolboxes) {
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean); return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
@ -33,6 +35,7 @@ export function useIndexedToolboxes(onError) {
const [loadError, setLoadError] = useState(""); const [loadError, setLoadError] = useState("");
const [loadAttempt, setLoadAttempt] = useState(0); const [loadAttempt, setLoadAttempt] = useState(0);
const writableRef = useRef(false); const writableRef = useRef(false);
const pendingReadsRef = useRef(new Map());
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" }); const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
const [queue] = useState(() => createPersistenceQueue(state, setState)); const [queue] = useState(() => createPersistenceQueue(state, setState));
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 }); const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
@ -64,14 +67,20 @@ export function useIndexedToolboxes(onError) {
let cancelled = false; let cancelled = false;
async function loadStore() { async function loadStore() {
try { try {
const [toolboxes, links, modules, toastPosition] = await Promise.all([ const [toolboxes, links, toastPosition] = await Promise.all([
dbGetToolboxes(), dbGetLinks(), dbGetAllModuleData(), dbGetSetting("toastPosition", "right") dbGetToolboxes(), dbGetLinks(), dbGetSetting("toastPosition", "right")
]); ]);
const normalizedToolboxes = (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean);
const deferredKeys = getDeferredModuleKeys(normalizedToolboxes);
const modules = await dbGetAllModuleData(deferredKeys);
if (cancelled) return; if (cancelled) return;
queue.initialize({ queue.initialize({
toolboxes: (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean), toolboxes: normalizedToolboxes,
links: links && typeof links === "object" ? links : {}, links: links && typeof links === "object" ? links : {},
moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])), moduleData: Object.fromEntries([
...deferredKeys.map((key) => [key, DEFERRED_MODULE_DATA]),
...(modules || []).map((entry) => [entry.key, entry.data])
]),
toastPosition: toastPosition === "left" ? "left" : "right" toastPosition: toastPosition === "left" ? "left" : "right"
}); });
writableRef.current = true; writableRef.current = true;
@ -125,6 +134,7 @@ export function useIndexedToolboxes(onError) {
function updateModuleData(toolboxId, moduleId, value, moduleType = "") { function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
if (!writableRef.current) return false; if (!writableRef.current) return false;
if (queue.getCurrent().moduleData[moduleStorageKey(toolboxId, moduleId)] === DEFERRED_MODULE_DATA) return false;
const compact = prepareModuleDataForStorage(queue.getCurrent().toolboxes, toolboxId, moduleId, value, moduleType); const compact = prepareModuleDataForStorage(queue.getCurrent().toolboxes, toolboxId, moduleId, value, moduleType);
const key = moduleStorageKey(toolboxId, moduleId); const key = moduleStorageKey(toolboxId, moduleId);
const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId)); const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId));
@ -160,7 +170,38 @@ export function useIndexedToolboxes(onError) {
return enqueue((current) => { return enqueue((current) => {
const next = updater(current); const next = updater(current);
return { ...current, ...next, toolboxes: normalizeToolboxes(next.toolboxes) }; return { ...current, ...next, toolboxes: normalizeToolboxes(next.toolboxes) };
}, (next) => dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, next.moduleData)); }, (next) => {
const { stored, deferred } = splitModuleData(next.moduleData);
return dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, stored, deferred);
});
}
const ensureModuleData = useCallback((toolboxId, moduleId) => {
const key = moduleStorageKey(toolboxId, moduleId);
if (queue.getCurrent().moduleData[key] !== DEFERRED_MODULE_DATA) return Promise.resolve();
if (pendingReadsRef.current.has(key)) return pendingReadsRef.current.get(key);
const request = (async () => {
const entries = await dbGetModuleDataEntries([key]);
if (!writableRef.current) return;
await queue.enqueue((current) => ({
...current, moduleData: mergeLoadedModuleData(current.moduleData, [key], entries)
}), () => {});
})();
pendingReadsRef.current.set(key, request);
void request.then(() => pendingReadsRef.current.delete(key), () => pendingReadsRef.current.delete(key));
return request;
}, [queue]);
async function getExportSnapshot(toolboxId) {
if (!writableRef.current) return null;
let snapshot;
const success = await queue.enqueue((current) => current, async (current) => {
const { deferred } = splitModuleData(current.moduleData);
const keys = deferred.filter((key) => !toolboxId || key.startsWith(`${toolboxId}:`));
const entries = await dbGetModuleDataEntries(keys);
snapshot = { ...current, moduleData: mergeLoadedModuleData(current.moduleData, keys, entries) };
}, reportStorageError);
return success ? snapshot : null;
} }
function setToastPosition(position) { function setToastPosition(position) {
@ -181,6 +222,9 @@ export function useIndexedToolboxes(onError) {
setToolboxes: persistToolboxes, setToolboxes: persistToolboxes,
setLinks: persistLinks, setLinks: persistLinks,
getModuleData, getModuleData,
ensureModuleData,
isModuleDataReady: (toolboxId, moduleId) => state.moduleData[moduleStorageKey(toolboxId, moduleId)] !== DEFERRED_MODULE_DATA,
getExportSnapshot,
updateModuleData, updateModuleData,
removeModuleData, removeModuleData,
removeToolboxModuleData, removeToolboxModuleData,

View file

@ -234,6 +234,8 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
setImage, setImage,
createChecklistFromList, createChecklistFromList,
addImageFiles, addImageFiles,
ensureModuleData: store.ensureModuleData,
isModuleDataReady: store.isModuleDataReady,
notify, notify,
removeModuleData: store.removeModuleData, removeModuleData: store.removeModuleData,
updateModuleData: store.updateModuleData, updateModuleData: store.updateModuleData,
@ -275,16 +277,20 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
return false; return false;
} }
}, },
exportToolbox: (id) => { exportToolbox: async (id) => {
if (!store.ready) return false; if (!store.ready) return false;
const toolbox = normalizeToolbox(store.toolboxes.find((item) => item.id === id)); const snapshot = await store.getExportSnapshot(id);
if (!snapshot) return false;
const toolbox = normalizeToolbox(snapshot.toolboxes.find((item) => item.id === id));
if (!toolbox) return; if (!toolbox) return;
downloadJson(createToolboxExportPayload(toolbox, store.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`); downloadJson(createToolboxExportPayload(toolbox, snapshot.moduleData), `${toolbox.name.toLowerCase().replace(/[^a-z0-9]+/gi, "-")}.sokkog.json`);
notify("Export de la toolbox lancé."); notify("Export de la toolbox lancé.");
}, },
exportAllToolboxes: () => { exportAllToolboxes: async () => {
if (!store.ready) return false; if (!store.ready) return false;
downloadJson(createGlobalExportPayload(store.toolboxes, store.links, store.moduleData), `sokkog-global-${new Date().toISOString().slice(0, 10)}.json`); 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`);
notify("Export global lancé."); notify("Export global lancé.");
} }
}; };

View file

@ -80,8 +80,27 @@ export async function setSetting(key, value) {
await setValue(`setting:${key}`, value); await setValue(`setting:${key}`, value);
} }
export async function getAllModuleData() { export async function getAllModuleData(excludedKeys = []) {
return storeRequest(MODULE_STORE, "readonly", (store) => store.getAll()); if (!excludedKeys.length) return storeRequest(MODULE_STORE, "readonly", (store) => store.getAll());
const excluded = new Set(excludedKeys);
const keys = await storeRequest(MODULE_STORE, "readonly", (store) => store.getAllKeys());
return getModuleDataEntries(keys.filter((key) => !excluded.has(key)));
}
export async function getModuleDataEntries(keys) {
if (!keys.length) return [];
const db = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = db.transaction(MODULE_STORE, "readonly");
const entries = [];
transaction.oncomplete = () => resolve(entries);
transaction.onerror = () => reject(transaction.error);
transaction.onabort = () => reject(transaction.error);
for (const key of keys) {
const request = transaction.objectStore(MODULE_STORE).get(key);
request.onsuccess = () => { if (request.result) entries.push(request.result); };
}
});
} }
export async function setModuleData(key, data) { export async function setModuleData(key, data) {
@ -105,7 +124,7 @@ export async function removeModuleDataKeys(keys) {
}); });
} }
export async function setToolboxSnapshot(toolboxes, links, moduleData) { export async function setToolboxSnapshot(toolboxes, links, moduleData, preservedKeys = []) {
const db = await openDatabase(); const db = await openDatabase();
await new Promise((resolve, reject) => { await new Promise((resolve, reject) => {
const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite"); const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite");
@ -117,7 +136,23 @@ export async function setToolboxSnapshot(toolboxes, links, moduleData) {
try { try {
kv.put(toolboxes, "toolboxes"); kv.put(toolboxes, "toolboxes");
kv.put(links, "links"); kv.put(links, "links");
modules.clear(); if (!preservedKeys.length) modules.clear();
else {
// Parcourir uniquement les clés évite de décoder les médias conservés sur disque.
const kept = new Set([...preservedKeys, ...Object.keys(moduleData || {})]);
const request = modules.openKeyCursor();
request.onsuccess = () => {
try {
const cursor = request.result;
if (!cursor) return;
if (!kept.has(cursor.key)) modules.delete(cursor.key);
cursor.continue();
} catch (error) {
transaction.abort();
reject(error);
}
};
}
Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data })); Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data }));
} catch (error) { } catch (error) {
transaction.abort(); transaction.abort();