Serialize toolbox writes and preserve concurrent edits
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
Some checks failed
Deploy Sokko G / deploy (push) Failing after 2s
This commit is contained in:
parent
7521d50626
commit
a4cf633ab4
12 changed files with 381 additions and 320 deletions
|
|
@ -23,3 +23,4 @@
|
||||||
{"id":"int-88c39f90fdd5b81110acc4bdae55836c","kind":"field_change","created_at":"2026-09-10T11:49:36.768770213Z","actor":"Shinuwa","issue_id":"sokko-g-vfo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared lazy React registry consumed by toolbox and library; obsolete static import assertions removed; all 17 library types exercised; npm run check and 13 E2E tests pass."}}
|
{"id":"int-88c39f90fdd5b81110acc4bdae55836c","kind":"field_change","created_at":"2026-09-10T11:49:36.768770213Z","actor":"Shinuwa","issue_id":"sokko-g-vfo","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Shared lazy React registry consumed by toolbox and library; obsolete static import assertions removed; all 17 library types exercised; npm run check and 13 E2E tests pass."}}
|
||||||
{"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}}
|
{"id":"int-7ec422ecc24735e12910a965567f32fe","kind":"field_change","created_at":"2026-09-10T12:17:38.573656548Z","actor":"Shinuwa","issue_id":"sokko-g-531","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Concurrent image batches merge current data after compression; calendar selection handlers update drafts outside state updaters. Behavioral image test and reverse week/month selection E2E pass; npm run check passes."}}
|
||||||
{"id":"int-1668982a1dda5d8304440ae699a32d40","kind":"field_change","created_at":"2026-09-10T12:22:11.969660039Z","actor":"Shinuwa","issue_id":"sokko-g-5u5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recursive cache enabled for completed evaluations. Before: 131054 reads for 16 cells; after: 50 reads for 50 cells. Regression test failed before fix and passes after; npm run check and table persistence E2E pass."}}
|
{"id":"int-1668982a1dda5d8304440ae699a32d40","kind":"field_change","created_at":"2026-09-10T12:22:11.969660039Z","actor":"Shinuwa","issue_id":"sokko-g-5u5","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Recursive cache enabled for completed evaluations. Before: 131054 reads for 16 cells; after: 50 reads for 50 cells. Regression test failed before fix and passes after; npm run check and table persistence E2E pass."}}
|
||||||
|
{"id":"int-903a25fde459880bbbadc2764da920a2","kind":"field_change","created_at":"2026-09-10T16:58:56.754421196Z","actor":"Shinuwa","issue_id":"sokko-g-hf6","extra":{"field":"status","new_value":"closed","old_value":"in_progress","reason":"Unified confirmed-state queue for all writes; imports use current-state updaters; atomic creation/link/deletion and synchronous transaction abort. Removed obsolete checkpoints. Deterministic concurrency/failure tests and real IndexedDB abort E2E pass; full suite 15/15 and npm run check pass."}}
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ Checklist à suivre lors de l'ajout ou de la modification d'un outil, d'une page
|
||||||
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
|
- Mettre à jour la normalisation et le stockage compact dans `website/src/features/toolboxes/storage/modules/<outil>.js`, puis vérifier l'export public dans `website/src/features/toolboxes/storage/toolboxStorage.js`.
|
||||||
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
|
- Mettre à jour `docs/STORAGE_SCHEMA.md`.
|
||||||
- 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.
|
||||||
- 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.
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,23 @@ Entrée type dans `modules` :
|
||||||
|
|
||||||
## Toolbox
|
## Toolbox
|
||||||
|
|
||||||
|
### Ordre et confirmation des écritures
|
||||||
|
|
||||||
|
Dans une instance de l'application, le registre, les liens, les modules et la
|
||||||
|
position des notifications partagent une file d'écritures. L'interface présente
|
||||||
|
les modifications en attente ; en cas d'échec, elle est reconstruite depuis le
|
||||||
|
dernier état confirmé, puis les opérations restantes sont réappliquées.
|
||||||
|
|
||||||
|
La création, l'association, l'import et la suppression d'une toolbox utilisent
|
||||||
|
une transaction commune aux stores `kv` et `modules`. Les imports ajoutent leur
|
||||||
|
contenu à l'état courant au moment de l'exécution et annoncent leur succès après
|
||||||
|
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
|
||||||
|
sur sa clé et ne réécrivent pas les images des autres outils.
|
||||||
|
|
||||||
|
Cette coordination est locale à l'instance React ; elle ne synchronise pas
|
||||||
|
l'édition entre plusieurs onglets.
|
||||||
|
|
||||||
Les toolboxes sont stockées dans `kv/toolboxes`.
|
Les toolboxes sont stockées dans `kv/toolboxes`.
|
||||||
|
|
||||||
```json
|
```json
|
||||||
|
|
|
||||||
29
e2e/persistence.spec.js
Normal file
29
e2e/persistence.spec.js
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
// Rôle : vérifie l'atomicité des snapshots avec une vraie transaction IndexedDB.
|
||||||
|
import { test, expect } from "@playwright/test";
|
||||||
|
import { resetSokkoStorage } from "./helpers/storage.js";
|
||||||
|
|
||||||
|
test("a synchronous snapshot error aborts all its IndexedDB writes", 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: [] }], { game: "saved" }, { "saved:note": { text: "Keep" } });
|
||||||
|
let errorName = "";
|
||||||
|
try {
|
||||||
|
await db.setToolboxSnapshot([{ id: "broken" }], {}, { "broken:note": { invalid: () => {} } });
|
||||||
|
} catch (error) {
|
||||||
|
errorName = error.name;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
errorName,
|
||||||
|
toolboxes: await db.getToolboxes(),
|
||||||
|
links: await db.getLinks(),
|
||||||
|
modules: await db.getAllModuleData()
|
||||||
|
};
|
||||||
|
});
|
||||||
|
expect(result).toEqual({
|
||||||
|
errorName: "DataCloneError",
|
||||||
|
toolboxes: [{ id: "saved", modules: [] }],
|
||||||
|
links: { game: "saved" },
|
||||||
|
modules: [{ key: "saved:note", data: { text: "Keep" } }]
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -1,80 +0,0 @@
|
||||||
// Rôle : teste les rollbacks optimistes par clé des données de modules IndexedDB.
|
|
||||||
import { test } from "node:test";
|
|
||||||
import assert from "node:assert/strict";
|
|
||||||
import {
|
|
||||||
applyModuleDataValue,
|
|
||||||
commitModuleDataCheckpoints,
|
|
||||||
createModuleDataCheckpoint,
|
|
||||||
createModuleDataCheckpoints,
|
|
||||||
removeModuleDataKeys,
|
|
||||||
rollbackModuleDataCheckpoints
|
|
||||||
} from "../website/src/features/toolboxes/storage/optimisticModuleData.js";
|
|
||||||
|
|
||||||
test("module data rollback restores a failed simple write", () => {
|
|
||||||
const initialData = { "tbx:mod-a": { text: "before" } };
|
|
||||||
const initialVersions = {};
|
|
||||||
const checkpoint = createModuleDataCheckpoint(initialData, initialVersions, "tbx:mod-a");
|
|
||||||
const versions = commitModuleDataCheckpoints(initialVersions, [checkpoint]);
|
|
||||||
const optimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after" });
|
|
||||||
|
|
||||||
assert.deepEqual(rollbackModuleDataCheckpoints(optimisticData, versions, [checkpoint]), initialData);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("module data rollback ignores a stale failure for the same module", () => {
|
|
||||||
const initialData = { "tbx:mod-a": { text: "before" } };
|
|
||||||
const firstCheckpoint = createModuleDataCheckpoint(initialData, {}, "tbx:mod-a");
|
|
||||||
const firstVersions = commitModuleDataCheckpoints({}, [firstCheckpoint]);
|
|
||||||
const firstOptimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after" });
|
|
||||||
const secondCheckpoint = createModuleDataCheckpoint(firstOptimisticData, firstVersions, "tbx:mod-a");
|
|
||||||
const secondVersions = commitModuleDataCheckpoints(firstVersions, [secondCheckpoint]);
|
|
||||||
const secondOptimisticData = applyModuleDataValue(firstOptimisticData, "tbx:mod-a", { text: "latest" });
|
|
||||||
|
|
||||||
assert.strictEqual(rollbackModuleDataCheckpoints(secondOptimisticData, secondVersions, [firstCheckpoint]), secondOptimisticData);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("module data rollback restores one module even when another module changed later", () => {
|
|
||||||
const initialData = {
|
|
||||||
"tbx:mod-a": { text: "before-a" },
|
|
||||||
"tbx:mod-b": { text: "before-b" }
|
|
||||||
};
|
|
||||||
const firstCheckpoint = createModuleDataCheckpoint(initialData, {}, "tbx:mod-a");
|
|
||||||
const firstVersions = commitModuleDataCheckpoints({}, [firstCheckpoint]);
|
|
||||||
const firstOptimisticData = applyModuleDataValue(initialData, "tbx:mod-a", { text: "after-a" });
|
|
||||||
const secondCheckpoint = createModuleDataCheckpoint(firstOptimisticData, firstVersions, "tbx:mod-b");
|
|
||||||
const secondVersions = commitModuleDataCheckpoints(firstVersions, [secondCheckpoint]);
|
|
||||||
const secondOptimisticData = applyModuleDataValue(firstOptimisticData, "tbx:mod-b", { text: "after-b" });
|
|
||||||
|
|
||||||
assert.deepEqual(rollbackModuleDataCheckpoints(secondOptimisticData, secondVersions, [firstCheckpoint]), {
|
|
||||||
"tbx:mod-a": { text: "before-a" },
|
|
||||||
"tbx:mod-b": { text: "after-b" }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("module data rollback for batch removal restores only untouched keys", () => {
|
|
||||||
const initialData = {
|
|
||||||
"tbx:mod-a": { text: "before-a" },
|
|
||||||
"tbx:mod-b": { text: "before-b" },
|
|
||||||
"tbx:mod-c": { text: "before-c" }
|
|
||||||
};
|
|
||||||
const batchCheckpoints = createModuleDataCheckpoints(initialData, {}, ["tbx:mod-a", "tbx:mod-b"]);
|
|
||||||
const batchVersions = commitModuleDataCheckpoints({}, batchCheckpoints);
|
|
||||||
const removedData = removeModuleDataKeys(initialData, ["tbx:mod-a", "tbx:mod-b"]);
|
|
||||||
const laterCheckpoint = createModuleDataCheckpoint(removedData, batchVersions, "tbx:mod-b");
|
|
||||||
const laterVersions = commitModuleDataCheckpoints(batchVersions, [laterCheckpoint]);
|
|
||||||
const laterData = applyModuleDataValue(removedData, "tbx:mod-b", { text: "latest-b" });
|
|
||||||
|
|
||||||
assert.deepEqual(rollbackModuleDataCheckpoints(laterData, laterVersions, batchCheckpoints), {
|
|
||||||
"tbx:mod-a": { text: "before-a" },
|
|
||||||
"tbx:mod-b": { text: "latest-b" },
|
|
||||||
"tbx:mod-c": { text: "before-c" }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("module data rollback keeps removal of an initially missing key stable", () => {
|
|
||||||
const initialData = { "tbx:mod-a": { text: "before-a" } };
|
|
||||||
const checkpoint = createModuleDataCheckpoint(initialData, {}, "tbx:missing");
|
|
||||||
const versions = commitModuleDataCheckpoints({}, [checkpoint]);
|
|
||||||
const optimisticData = removeModuleDataKeys(initialData, ["tbx:missing"]);
|
|
||||||
|
|
||||||
assert.deepEqual(rollbackModuleDataCheckpoints(optimisticData, versions, [checkpoint]), initialData);
|
|
||||||
});
|
|
||||||
155
tests/persistence-queue.test.mjs
Normal file
155
tests/persistence-queue.test.mjs
Normal file
|
|
@ -0,0 +1,155 @@
|
||||||
|
// Rôle : teste les écritures confirmées, échecs successifs et imports concurrents.
|
||||||
|
import { test } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createPersistenceQueue } from "../website/src/features/toolboxes/storage/persistenceQueue.js";
|
||||||
|
import { useToolboxActions } from "../website/src/features/toolboxes/useToolboxActions.js";
|
||||||
|
import { applyModuleDataValue, removeModuleDataKeys } from "../website/src/features/toolboxes/storage/optimisticModuleData.js";
|
||||||
|
|
||||||
|
function deferred() {
|
||||||
|
let resolve, reject;
|
||||||
|
const promise = new Promise((ok, fail) => { resolve = ok; reject = fail; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const field of ["toolboxes", "links", "moduleData", "toastPosition"]) {
|
||||||
|
for (const outcomes of [[true, true], [true, false], [false, true], [false, false]]) {
|
||||||
|
test(`${field}: confirms only successful writes ${outcomes}`, async () => {
|
||||||
|
let disk = { [field]: "A" };
|
||||||
|
const errors = [];
|
||||||
|
const queue = createPersistenceQueue(disk, () => {});
|
||||||
|
const gates = [deferred(), deferred()];
|
||||||
|
let active = 0;
|
||||||
|
const write = (index) => queue.enqueue(
|
||||||
|
(current) => ({ ...current, [field]: index ? "C" : "B" }),
|
||||||
|
async (next) => {
|
||||||
|
assert.equal(++active, 1);
|
||||||
|
try { await gates[index].promise; disk = next; }
|
||||||
|
finally { active--; }
|
||||||
|
},
|
||||||
|
(error) => errors.push(error.message)
|
||||||
|
);
|
||||||
|
const first = write(0);
|
||||||
|
const second = write(1);
|
||||||
|
assert.equal(queue.getCurrent()[field], "C");
|
||||||
|
outcomes[0] ? gates[0].resolve() : gates[0].reject(new Error("first"));
|
||||||
|
assert.equal(await first, outcomes[0]);
|
||||||
|
assert.equal(queue.getCurrent()[field], "C");
|
||||||
|
outcomes[1] ? gates[1].resolve() : gates[1].reject(new Error("second"));
|
||||||
|
assert.equal(await second, outcomes[1]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), disk);
|
||||||
|
assert.equal(disk[field], outcomes[1] ? "C" : outcomes[0] ? "B" : "A");
|
||||||
|
assert.equal(errors.length, outcomes.filter((ok) => !ok).length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("failed snapshot cannot undo a later edit or reintroduce a failed earlier write", async () => {
|
||||||
|
const initial = { toolboxes: ["existing"], links: {}, moduleData: { note: "A" } };
|
||||||
|
let disk = initial;
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const gate = deferred();
|
||||||
|
const earlier = queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), () => gate.promise);
|
||||||
|
const snapshot = queue.enqueue(
|
||||||
|
(s) => ({ ...s, toolboxes: ["imported", ...s.toolboxes] }),
|
||||||
|
async () => { throw new Error("abort"); }
|
||||||
|
);
|
||||||
|
const edit = queue.enqueue((s) => ({ ...s, moduleData: { note: "C" } }), async (next) => { disk = next; });
|
||||||
|
gate.reject(new Error("quota"));
|
||||||
|
assert.deepEqual(await Promise.all([earlier, snapshot, edit]), [false, false, true]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), { ...initial, moduleData: { note: "C" } });
|
||||||
|
assert.deepEqual(disk, queue.getCurrent());
|
||||||
|
});
|
||||||
|
|
||||||
|
test("imports merge after file reading and preserve edits queued during the transaction", async () => {
|
||||||
|
const initial = { toolboxes: [{ id: "existing", modules: [] }], links: {}, moduleData: { note: "A" } };
|
||||||
|
let disk = initial;
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const transaction = deferred();
|
||||||
|
const fileRead = deferred();
|
||||||
|
const notices = [];
|
||||||
|
const actions = useToolboxActions({
|
||||||
|
store: {
|
||||||
|
...initial,
|
||||||
|
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => {
|
||||||
|
await transaction.promise;
|
||||||
|
disk = next;
|
||||||
|
})
|
||||||
|
},
|
||||||
|
notify: (value) => notices.push(value),
|
||||||
|
setConfirmModal: (value) => assert.fail(value.message)
|
||||||
|
});
|
||||||
|
const imported = actions.importToolbox({ text: () => fileRead.promise }, "game");
|
||||||
|
await queue.enqueue((s) => ({ ...s, moduleData: { note: "B" } }), async (next) => { disk = next; });
|
||||||
|
fileRead.resolve(JSON.stringify({ toolbox: { id: "source", name: "Import", modules: [] }, modules: {} }));
|
||||||
|
await Promise.resolve();
|
||||||
|
assert.equal(notices.length, 0);
|
||||||
|
const edit = queue.enqueue((s) => ({ ...s, moduleData: { ...s.moduleData, note: "C" } }), async (next) => { disk = next; });
|
||||||
|
transaction.resolve();
|
||||||
|
const toolbox = await imported;
|
||||||
|
await edit;
|
||||||
|
assert.equal(disk.moduleData.note, "C");
|
||||||
|
assert.deepEqual(disk.toolboxes.map((t) => t.id), [toolbox.id, "existing"]);
|
||||||
|
assert.equal(disk.links.game, toolbox.id);
|
||||||
|
assert.deepEqual(queue.getCurrent(), disk);
|
||||||
|
assert.equal(notices.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed module removal restores the confirmed value after an earlier failed edit", async () => {
|
||||||
|
const initial = { moduleData: { note: { text: "A" }, other: { text: "Other" } } };
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const first = queue.enqueue(
|
||||||
|
(s) => ({ ...s, moduleData: applyModuleDataValue(s.moduleData, "note", { text: "B" }) }),
|
||||||
|
async () => { throw new Error("quota"); }
|
||||||
|
);
|
||||||
|
const second = queue.enqueue(
|
||||||
|
(s) => ({ ...s, moduleData: removeModuleDataKeys(s.moduleData, ["note"]) }),
|
||||||
|
async () => { throw new Error("abort"); }
|
||||||
|
);
|
||||||
|
assert.deepEqual(await Promise.all([first, second]), [false, false]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), initial);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global imports started from the same render both survive", async () => {
|
||||||
|
const initial = { toolboxes: [], links: {}, moduleData: {} };
|
||||||
|
let disk = initial;
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const actions = useToolboxActions({
|
||||||
|
store: {
|
||||||
|
...initial,
|
||||||
|
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async (next) => { disk = next; })
|
||||||
|
},
|
||||||
|
notify() {},
|
||||||
|
setConfirmModal: (value) => assert.fail(value.message)
|
||||||
|
});
|
||||||
|
const file = (id) => ({ text: async () => JSON.stringify({
|
||||||
|
toolboxes: [{ id, name: id, modules: [{ id: "note", type: "notepad" }] }],
|
||||||
|
links: { [id]: id },
|
||||||
|
modules: { [`${id}:note`]: { text: id } }
|
||||||
|
}) });
|
||||||
|
assert.deepEqual(await Promise.all([
|
||||||
|
actions.importAllToolboxes(file("one")), actions.importAllToolboxes(file("two"))
|
||||||
|
]), [true, true]);
|
||||||
|
assert.equal(disk.toolboxes.length, 2);
|
||||||
|
assert.equal(Object.keys(disk.moduleData).length, 2);
|
||||||
|
assert.deepEqual(Object.keys(disk.links).sort(), ["one", "two"]);
|
||||||
|
assert.deepEqual(queue.getCurrent(), disk);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("failed toolbox deletion preserves registry links and modules together", async () => {
|
||||||
|
const initial = {
|
||||||
|
toolboxes: [{ id: "existing", modules: [{ id: "note", type: "notepad" }] }],
|
||||||
|
links: { game: "existing" },
|
||||||
|
moduleData: { "existing:note": { text: "Keep" } }
|
||||||
|
};
|
||||||
|
const queue = createPersistenceQueue(initial, () => {});
|
||||||
|
const actions = useToolboxActions({
|
||||||
|
store: {
|
||||||
|
...initial,
|
||||||
|
persistToolboxSnapshot: (apply) => queue.enqueue(apply, async () => { throw new Error("abort"); })
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const deleted = actions.deleteToolbox("existing");
|
||||||
|
assert.deepEqual(queue.getCurrent(), { toolboxes: [], links: {}, moduleData: {} });
|
||||||
|
assert.equal(await deleted, false);
|
||||||
|
assert.deepEqual(queue.getCurrent(), initial);
|
||||||
|
});
|
||||||
|
|
@ -29,7 +29,6 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
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 indexedToolboxesHook = await readFile("website/src/features/toolboxes/storage/useIndexedToolboxes.js", "utf8");
|
||||||
const optimisticModuleData = await readFile("website/src/features/toolboxes/storage/optimisticModuleData.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");
|
||||||
|
|
@ -43,10 +42,6 @@ test("toolbox storage, cards and pages are wired", async () => {
|
||||||
assert.match(source, /features\/toolboxes\/useToolboxActions\.js/);
|
assert.match(source, /features\/toolboxes\/useToolboxActions\.js/);
|
||||||
assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/);
|
assert.match(indexedToolboxesHook, /export function useIndexedToolboxes/);
|
||||||
assert.match(indexedToolboxesHook, /getStorageEstimate/);
|
assert.match(indexedToolboxesHook, /getStorageEstimate/);
|
||||||
assert.match(indexedToolboxesHook, /moduleDataByKey/);
|
|
||||||
assert.match(indexedToolboxesHook, /rollbackModuleDataCheckpoints/);
|
|
||||||
assert.match(optimisticModuleData, /export function createModuleDataCheckpoint/);
|
|
||||||
assert.match(optimisticModuleData, /export function rollbackModuleDataCheckpoints/);
|
|
||||||
assert.match(toolboxActions, /export function useToolboxActions/);
|
assert.match(toolboxActions, /export function useToolboxActions/);
|
||||||
assert.match(toolboxActions, /importAllToolboxesPayload/);
|
assert.match(toolboxActions, /importAllToolboxesPayload/);
|
||||||
assert.match(toolboxActions, /async function addImageFiles/);
|
assert.match(toolboxActions, /async function addImageFiles/);
|
||||||
|
|
|
||||||
|
|
@ -1,34 +1,4 @@
|
||||||
// Rôle : fournit les helpers purs de versioning et rollback optimiste des données modules.
|
// Rôle : applique les changements immuables de données de modules.
|
||||||
function hasOwnValue(data, key) {
|
|
||||||
return Object.prototype.hasOwnProperty.call(data, key);
|
|
||||||
}
|
|
||||||
|
|
||||||
function uniqueKeys(keys) {
|
|
||||||
return [...new Set((Array.isArray(keys) ? keys : []).map((key) => String(key || "")).filter(Boolean))];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createModuleDataCheckpoint(moduleData, versionsByKey, key) {
|
|
||||||
const version = (versionsByKey[key] || 0) + 1;
|
|
||||||
return {
|
|
||||||
key,
|
|
||||||
version,
|
|
||||||
hadValue: hasOwnValue(moduleData, key),
|
|
||||||
value: moduleData[key]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createModuleDataCheckpoints(moduleData, versionsByKey, keys) {
|
|
||||||
return uniqueKeys(keys).map((key) => createModuleDataCheckpoint(moduleData, versionsByKey, key));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function commitModuleDataCheckpoints(versionsByKey, checkpoints) {
|
|
||||||
const nextVersions = { ...versionsByKey };
|
|
||||||
checkpoints.forEach((checkpoint) => {
|
|
||||||
nextVersions[checkpoint.key] = checkpoint.version;
|
|
||||||
});
|
|
||||||
return nextVersions;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyModuleDataValue(moduleData, key, value) {
|
export function applyModuleDataValue(moduleData, key, value) {
|
||||||
const nextModuleData = { ...moduleData };
|
const nextModuleData = { ...moduleData };
|
||||||
if (value == null) delete nextModuleData[key];
|
if (value == null) delete nextModuleData[key];
|
||||||
|
|
@ -38,19 +8,6 @@ export function applyModuleDataValue(moduleData, key, value) {
|
||||||
|
|
||||||
export function removeModuleDataKeys(moduleData, keys) {
|
export function removeModuleDataKeys(moduleData, keys) {
|
||||||
const nextModuleData = { ...moduleData };
|
const nextModuleData = { ...moduleData };
|
||||||
uniqueKeys(keys).forEach((key) => {
|
for (const key of keys) delete nextModuleData[key];
|
||||||
delete nextModuleData[key];
|
|
||||||
});
|
|
||||||
return nextModuleData;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function rollbackModuleDataCheckpoints(moduleData, versionsByKey, checkpoints) {
|
|
||||||
let nextModuleData = moduleData;
|
|
||||||
checkpoints.forEach((checkpoint) => {
|
|
||||||
if (versionsByKey[checkpoint.key] !== checkpoint.version) return;
|
|
||||||
if (nextModuleData === moduleData) nextModuleData = { ...moduleData };
|
|
||||||
if (checkpoint.hadValue) nextModuleData[checkpoint.key] = checkpoint.value;
|
|
||||||
else delete nextModuleData[checkpoint.key];
|
|
||||||
});
|
|
||||||
return nextModuleData;
|
return nextModuleData;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
50
website/src/features/toolboxes/storage/persistenceQueue.js
Normal file
50
website/src/features/toolboxes/storage/persistenceQueue.js
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
// Rôle : sérialise les écritures et reconstruit l'état optimiste depuis les succès confirmés.
|
||||||
|
export function createPersistenceQueue(initialState, onChange) {
|
||||||
|
let confirmed = initialState;
|
||||||
|
let current = initialState;
|
||||||
|
let running = false;
|
||||||
|
const pending = [];
|
||||||
|
|
||||||
|
function publish() {
|
||||||
|
current = pending.reduce((state, operation) => operation.apply(state), confirmed);
|
||||||
|
onChange(current);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function drain() {
|
||||||
|
if (running) return;
|
||||||
|
running = true;
|
||||||
|
while (pending.length) {
|
||||||
|
const operation = pending[0];
|
||||||
|
let success = false;
|
||||||
|
let failure;
|
||||||
|
try {
|
||||||
|
const next = operation.apply(confirmed);
|
||||||
|
await operation.persist(next);
|
||||||
|
confirmed = next;
|
||||||
|
success = true;
|
||||||
|
} catch (error) {
|
||||||
|
failure = error;
|
||||||
|
}
|
||||||
|
pending.shift();
|
||||||
|
publish();
|
||||||
|
operation.resolve(success);
|
||||||
|
if (!success) operation.onError?.(failure);
|
||||||
|
}
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
getCurrent: () => current,
|
||||||
|
initialize(state) {
|
||||||
|
if (running || pending.length) throw new Error("Cannot initialize while writes are pending");
|
||||||
|
confirmed = state;
|
||||||
|
publish();
|
||||||
|
},
|
||||||
|
enqueue(apply, persist, onError) {
|
||||||
|
const result = new Promise((resolve) => pending.push({ apply, persist, onError, resolve }));
|
||||||
|
publish();
|
||||||
|
void drain();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// Rôle : synchronise les toolboxes, données d'outils, liens de jeux et quota via IndexedDB.
|
// Rôle : synchronise l'état optimiste des toolboxes avec une file d'écritures IndexedDB.
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import {
|
import {
|
||||||
getAllModuleData as dbGetAllModuleData,
|
getAllModuleData as dbGetAllModuleData,
|
||||||
getLinks as dbGetLinks,
|
getLinks as dbGetLinks,
|
||||||
|
|
@ -20,59 +20,22 @@ import {
|
||||||
normalizeToolbox,
|
normalizeToolbox,
|
||||||
prepareModuleDataForStorage
|
prepareModuleDataForStorage
|
||||||
} from "./toolboxStorage.js";
|
} from "./toolboxStorage.js";
|
||||||
import {
|
import { applyModuleDataValue, removeModuleDataKeys } from "./optimisticModuleData.js";
|
||||||
applyModuleDataValue,
|
import { createPersistenceQueue } from "./persistenceQueue.js";
|
||||||
commitModuleDataCheckpoints,
|
|
||||||
createModuleDataCheckpoint,
|
function normalizeToolboxes(toolboxes) {
|
||||||
createModuleDataCheckpoints,
|
return compactToolboxesForStorage(toolboxes).map(normalizeToolbox).filter(Boolean);
|
||||||
removeModuleDataKeys,
|
}
|
||||||
rollbackModuleDataCheckpoints
|
|
||||||
} from "./optimisticModuleData.js";
|
|
||||||
|
|
||||||
export function useIndexedToolboxes(onError) {
|
export function useIndexedToolboxes(onError) {
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
const [toolboxes, setToolboxesState] = useState([]);
|
const [state, setState] = useState({ toolboxes: [], links: {}, moduleData: {}, toastPosition: "right" });
|
||||||
const [links, setLinksState] = useState({});
|
const [queue] = useState(() => createPersistenceQueue(state, setState));
|
||||||
const [moduleData, setModuleDataState] = useState({});
|
|
||||||
const [toastPosition, setToastPositionState] = useState("right");
|
|
||||||
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
const [storageUsage, setStorageUsage] = useState({ used: 0, limit: 0, ratio: 0 });
|
||||||
const toolboxesRef = useRef(toolboxes);
|
|
||||||
const linksRef = useRef(links);
|
|
||||||
const moduleDataRef = useRef(moduleData);
|
|
||||||
const confirmedModuleDataRef = useRef(moduleData);
|
|
||||||
const moduleWriteQueuesRef = useRef({});
|
|
||||||
const toastPositionRef = useRef(toastPosition);
|
|
||||||
const writeVersionsRef = useRef({
|
|
||||||
toolboxes: 0,
|
|
||||||
links: 0,
|
|
||||||
moduleDataByKey: {},
|
|
||||||
toastPosition: 0
|
|
||||||
});
|
|
||||||
|
|
||||||
const reportStorageError = useCallback((error) => {
|
const reportStorageError = useCallback((error) => {
|
||||||
onError(error?.message || "Erreur de stockage IndexedDB.");
|
onError(error?.message || "Erreur de stockage IndexedDB.");
|
||||||
}, [onError]);
|
}, [onError]);
|
||||||
|
|
||||||
function commitToolboxes(nextToolboxes) {
|
|
||||||
toolboxesRef.current = nextToolboxes;
|
|
||||||
setToolboxesState(nextToolboxes);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitLinks(nextLinks) {
|
|
||||||
linksRef.current = nextLinks;
|
|
||||||
setLinksState(nextLinks);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitModuleData(nextModuleData) {
|
|
||||||
moduleDataRef.current = nextModuleData;
|
|
||||||
setModuleDataState(nextModuleData);
|
|
||||||
}
|
|
||||||
|
|
||||||
function commitToastPosition(nextToastPosition) {
|
|
||||||
toastPositionRef.current = nextToastPosition;
|
|
||||||
setToastPositionState(nextToastPosition);
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshQuota = useCallback(async () => {
|
const refreshQuota = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
const estimate = await getStorageEstimate();
|
const estimate = await getStorageEstimate();
|
||||||
|
|
@ -88,19 +51,16 @@ export function useIndexedToolboxes(onError) {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
async function loadStore() {
|
async function loadStore() {
|
||||||
try {
|
try {
|
||||||
const [storedToolboxes, storedLinks, storedModules, storedToastPosition] = await Promise.all([
|
const [toolboxes, links, modules, toastPosition] = await Promise.all([
|
||||||
dbGetToolboxes(),
|
dbGetToolboxes(), dbGetLinks(), dbGetAllModuleData(), dbGetSetting("toastPosition", "right")
|
||||||
dbGetLinks(),
|
|
||||||
dbGetAllModuleData(),
|
|
||||||
dbGetSetting("toastPosition", "right")
|
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
commitToolboxes((Array.isArray(storedToolboxes) ? storedToolboxes : []).map(normalizeToolbox).filter(Boolean));
|
queue.initialize({
|
||||||
commitLinks(storedLinks && typeof storedLinks === "object" ? storedLinks : {});
|
toolboxes: (Array.isArray(toolboxes) ? toolboxes : []).map(normalizeToolbox).filter(Boolean),
|
||||||
const loadedModuleData = Object.fromEntries((storedModules || []).map((entry) => [entry.key, entry.data]));
|
links: links && typeof links === "object" ? links : {},
|
||||||
confirmedModuleDataRef.current = loadedModuleData;
|
moduleData: Object.fromEntries((modules || []).map((entry) => [entry.key, entry.data])),
|
||||||
commitModuleData(loadedModuleData);
|
toastPosition: toastPosition === "left" ? "left" : "right"
|
||||||
commitToastPosition(storedToastPosition === "left" ? "left" : "right");
|
});
|
||||||
setReady(true);
|
setReady(true);
|
||||||
refreshQuota();
|
refreshQuota();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -112,132 +72,84 @@ export function useIndexedToolboxes(onError) {
|
||||||
}
|
}
|
||||||
loadStore();
|
loadStore();
|
||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [refreshQuota, reportStorageError]);
|
}, [queue, refreshQuota, reportStorageError]);
|
||||||
|
|
||||||
function persistToolboxes(nextToolboxes) {
|
function enqueue(apply, persist) {
|
||||||
const normalized = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
|
const result = queue.enqueue(apply, persist, reportStorageError);
|
||||||
const previousToolboxes = toolboxesRef.current;
|
void result.then((success) => { if (success) refreshQuota(); });
|
||||||
const version = ++writeVersionsRef.current.toolboxes;
|
return result;
|
||||||
commitToolboxes(normalized);
|
}
|
||||||
dbSetToolboxes(compactToolboxesForStorage(normalized))
|
|
||||||
.then(refreshQuota)
|
function persistToolboxes(value) {
|
||||||
.catch((error) => {
|
const apply = typeof value === "function" ? value : () => value;
|
||||||
if (writeVersionsRef.current.toolboxes === version) commitToolboxes(previousToolboxes);
|
enqueue(
|
||||||
reportStorageError(error);
|
(current) => ({ ...current, toolboxes: normalizeToolboxes(apply(current.toolboxes)) }),
|
||||||
});
|
(next) => dbSetToolboxes(compactToolboxesForStorage(next.toolboxes))
|
||||||
|
);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function persistLinks(nextLinks) {
|
function persistLinks(value) {
|
||||||
const previousLinks = linksRef.current;
|
enqueue(
|
||||||
const version = ++writeVersionsRef.current.links;
|
(current) => ({ ...current, links: typeof value === "function" ? value(current.links) : value }),
|
||||||
commitLinks(nextLinks);
|
(next) => dbSetLinks(next.links)
|
||||||
dbSetLinks(nextLinks)
|
);
|
||||||
.then(refreshQuota)
|
|
||||||
.catch((error) => {
|
|
||||||
if (writeVersionsRef.current.links === version) commitLinks(previousLinks);
|
|
||||||
reportStorageError(error);
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModuleData(toolboxId, moduleId, fallback) {
|
function getModuleData(toolboxId, moduleId, fallback) {
|
||||||
const value = moduleDataRef.current[moduleStorageKey(toolboxId, moduleId)];
|
return queue.getCurrent().moduleData[moduleStorageKey(toolboxId, moduleId)] ?? fallback;
|
||||||
return value == null ? fallback : value;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
function updateModuleData(toolboxId, moduleId, value, moduleType = "") {
|
||||||
const compact = prepareModuleDataForStorage(toolboxesRef.current, 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 checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key);
|
const hasModule = (current) => current.toolboxes.some((toolbox) => toolbox.id === toolboxId && toolbox.modules.some((module) => module.id === moduleId));
|
||||||
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]);
|
enqueue(
|
||||||
const nextModuleData = applyModuleDataValue(moduleDataRef.current, key, compact);
|
(current) => hasModule(current) ? { ...current, moduleData: applyModuleDataValue(current.moduleData, key, compact) } : current,
|
||||||
commitModuleData(nextModuleData);
|
(next) => {
|
||||||
const previousQueue = moduleWriteQueuesRef.current[key] || Promise.resolve();
|
if (!hasModule(next)) return;
|
||||||
const operation = previousQueue.catch(() => {}).then(async () => {
|
return compact == null ? dbRemoveModuleData(key) : dbSetModuleData(key, compact);
|
||||||
if (compact == null) await dbRemoveModuleData(key);
|
|
||||||
else await dbSetModuleData(key, compact);
|
|
||||||
confirmedModuleDataRef.current = applyModuleDataValue(confirmedModuleDataRef.current, key, compact);
|
|
||||||
await refreshQuota();
|
|
||||||
}).catch((error) => {
|
|
||||||
if (moduleDataRef.current[key] === compact) {
|
|
||||||
commitModuleData(applyModuleDataValue(moduleDataRef.current, key, confirmedModuleDataRef.current[key]));
|
|
||||||
}
|
}
|
||||||
reportStorageError(error);
|
);
|
||||||
});
|
|
||||||
const queuedOperation = operation.finally(() => {
|
|
||||||
if (moduleWriteQueuesRef.current[key] === queuedOperation) delete moduleWriteQueuesRef.current[key];
|
|
||||||
});
|
|
||||||
moduleWriteQueuesRef.current[key] = queuedOperation;
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeModuleData(toolboxId, moduleId) {
|
function removeModuleData(toolboxId, moduleId) {
|
||||||
const key = moduleStorageKey(toolboxId, moduleId);
|
const key = moduleStorageKey(toolboxId, moduleId);
|
||||||
const checkpoint = createModuleDataCheckpoint(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, key);
|
return enqueue(
|
||||||
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, [checkpoint]);
|
(current) => ({ ...current, moduleData: removeModuleDataKeys(current.moduleData, [key]) }),
|
||||||
commitModuleData(removeModuleDataKeys(moduleDataRef.current, [key]));
|
() => dbRemoveModuleData(key)
|
||||||
dbRemoveModuleData(key).then(refreshQuota).catch((error) => {
|
);
|
||||||
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, [checkpoint]));
|
|
||||||
reportStorageError(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeToolboxModuleData(toolbox) {
|
function removeToolboxModuleData(toolbox) {
|
||||||
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
const keys = (toolbox?.modules || []).map((module) => moduleStorageKey(toolbox.id, module.id));
|
||||||
if (!keys.length) return;
|
if (!keys.length) return Promise.resolve(true);
|
||||||
const checkpoints = createModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, keys);
|
return enqueue(
|
||||||
writeVersionsRef.current.moduleDataByKey = commitModuleDataCheckpoints(writeVersionsRef.current.moduleDataByKey, checkpoints);
|
(current) => ({ ...current, moduleData: removeModuleDataKeys(current.moduleData, keys) }),
|
||||||
commitModuleData(removeModuleDataKeys(moduleDataRef.current, keys));
|
() => dbRemoveModuleDataKeys(keys)
|
||||||
dbRemoveModuleDataKeys(keys).then(refreshQuota).catch((error) => {
|
);
|
||||||
commitModuleData(rollbackModuleDataCheckpoints(moduleDataRef.current, writeVersionsRef.current.moduleDataByKey, checkpoints));
|
|
||||||
reportStorageError(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData) {
|
function persistToolboxSnapshot(updater) {
|
||||||
const normalizedToolboxes = compactToolboxesForStorage(nextToolboxes).map(normalizeToolbox).filter(Boolean);
|
return enqueue((current) => {
|
||||||
const previous = {
|
const next = updater(current);
|
||||||
toolboxes: toolboxesRef.current,
|
return { ...current, ...next, toolboxes: normalizeToolboxes(next.toolboxes) };
|
||||||
links: linksRef.current,
|
}, (next) => dbSetToolboxSnapshot(compactToolboxesForStorage(next.toolboxes), next.links, next.moduleData));
|
||||||
moduleData: moduleDataRef.current
|
|
||||||
};
|
|
||||||
commitToolboxes(normalizedToolboxes);
|
|
||||||
commitLinks(nextLinks);
|
|
||||||
commitModuleData(nextModuleData);
|
|
||||||
try {
|
|
||||||
await dbSetToolboxSnapshot(compactToolboxesForStorage(normalizedToolboxes), nextLinks, nextModuleData);
|
|
||||||
confirmedModuleDataRef.current = nextModuleData;
|
|
||||||
await refreshQuota();
|
|
||||||
return true;
|
|
||||||
} catch (error) {
|
|
||||||
commitToolboxes(previous.toolboxes);
|
|
||||||
commitLinks(previous.links);
|
|
||||||
commitModuleData(previous.moduleData);
|
|
||||||
reportStorageError(error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function setToastPosition(position) {
|
function setToastPosition(position) {
|
||||||
const normalized = position === "left" ? "left" : "right";
|
const toastPosition = position === "left" ? "left" : "right";
|
||||||
const previousToastPosition = toastPositionRef.current;
|
enqueue(
|
||||||
const version = ++writeVersionsRef.current.toastPosition;
|
(current) => ({ ...current, toastPosition }),
|
||||||
commitToastPosition(normalized);
|
() => dbSetSetting("toastPosition", toastPosition)
|
||||||
dbSetSetting("toastPosition", normalized)
|
);
|
||||||
.then(refreshQuota)
|
|
||||||
.catch((error) => {
|
|
||||||
if (writeVersionsRef.current.toastPosition === version) commitToastPosition(previousToastPosition);
|
|
||||||
reportStorageError(error);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...state,
|
||||||
ready,
|
ready,
|
||||||
toolboxes,
|
|
||||||
links,
|
|
||||||
moduleData,
|
|
||||||
toastPosition,
|
|
||||||
storageUsage,
|
storageUsage,
|
||||||
setToolboxes: persistToolboxes,
|
setToolboxes: persistToolboxes,
|
||||||
setLinks: persistLinks,
|
setLinks: persistLinks,
|
||||||
|
|
|
||||||
|
|
@ -46,41 +46,53 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
if (options.tutorial === true) toolbox.tutorial = true;
|
if (options.tutorial === true) toolbox.tutorial = true;
|
||||||
if (!store.setToolboxes([toolbox, ...store.toolboxes])) return null;
|
const memos = compactModuleDataForStorage("memos", { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] });
|
||||||
store.updateModuleData(toolbox.id, memosModuleId, { items: [{ id: uid("memo"), text: welcomeText, color: "#f6c453", updatedAt: now }] }, "memos");
|
const links = compactModuleDataForStorage("links", { links: [libraryLink] });
|
||||||
store.updateModuleData(toolbox.id, linksModuleId, { links: [libraryLink] }, "links");
|
void store.persistToolboxSnapshot((current) => ({
|
||||||
if (gameId) store.setLinks({ ...store.links, [gameId]: toolbox.id });
|
toolboxes: [toolbox, ...current.toolboxes],
|
||||||
|
links: gameId ? { ...current.links, [gameId]: toolbox.id } : current.links,
|
||||||
|
moduleData: {
|
||||||
|
...current.moduleData,
|
||||||
|
[globalModuleKey(toolbox.id, memosModuleId)]: memos,
|
||||||
|
[globalModuleKey(toolbox.id, linksModuleId)]: links
|
||||||
|
}
|
||||||
|
}));
|
||||||
return toolbox;
|
return toolbox;
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateToolbox(nextToolbox) {
|
function updateToolbox(nextToolbox) {
|
||||||
store.setToolboxes(store.toolboxes.map((toolbox) => toolbox.id === nextToolbox.id
|
const updatedAt = new Date().toISOString();
|
||||||
? { ...nextToolbox, updatedAt: new Date().toISOString() }
|
store.setToolboxes((toolboxes) => toolboxes.map((toolbox) => toolbox.id === nextToolbox.id
|
||||||
|
? { ...nextToolbox, updatedAt }
|
||||||
: toolbox));
|
: toolbox));
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteToolbox(id) {
|
function deleteToolbox(id) {
|
||||||
const toolbox = store.toolboxes.find((item) => item.id === id);
|
return store.persistToolboxSnapshot((current) => {
|
||||||
const nextToolboxes = store.toolboxes.filter((item) => item.id !== id);
|
const toolbox = current.toolboxes.find((item) => item.id === id);
|
||||||
const nextModuleData = { ...store.moduleData };
|
const moduleData = { ...current.moduleData };
|
||||||
(toolbox?.modules || []).forEach((module) => delete nextModuleData[`${id}:${module.id}`]);
|
(toolbox?.modules || []).forEach((module) => delete moduleData[`${id}:${module.id}`]);
|
||||||
const nextLinks = { ...store.links };
|
return {
|
||||||
Object.entries(nextLinks).forEach(([gameId, toolboxId]) => {
|
toolboxes: current.toolboxes.filter((item) => item.id !== id),
|
||||||
if (toolboxId === id) delete nextLinks[gameId];
|
moduleData,
|
||||||
|
links: Object.fromEntries(Object.entries(current.links).filter(([, toolboxId]) => toolboxId !== id))
|
||||||
|
};
|
||||||
});
|
});
|
||||||
return store.persistToolboxSnapshot(nextToolboxes, nextLinks, nextModuleData);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function linkToolboxToGame(gameId, toolboxId) {
|
function linkToolboxToGame(gameId, toolboxId) {
|
||||||
const previousToolboxId = store.links[gameId] || "";
|
const updatedAt = new Date().toISOString();
|
||||||
const nextLinks = { ...store.links };
|
return store.persistToolboxSnapshot((current) => {
|
||||||
if (toolboxId) nextLinks[gameId] = toolboxId;
|
const links = { ...current.links };
|
||||||
else delete nextLinks[gameId];
|
const touched = new Set([links[gameId], toolboxId].filter(Boolean));
|
||||||
store.setLinks(nextLinks);
|
if (toolboxId) links[gameId] = toolboxId;
|
||||||
const touched = new Set([previousToolboxId, toolboxId].filter(Boolean));
|
else delete links[gameId];
|
||||||
store.setToolboxes(store.toolboxes.map((toolbox) => touched.has(toolbox.id)
|
return {
|
||||||
? { ...toolbox, updatedAt: new Date().toISOString() }
|
...current,
|
||||||
: toolbox));
|
links,
|
||||||
|
toolboxes: current.toolboxes.map((toolbox) => touched.has(toolbox.id) ? { ...toolbox, updatedAt } : toolbox)
|
||||||
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addImageFiles(toolboxId, moduleId, files) {
|
async function addImageFiles(toolboxId, moduleId, files) {
|
||||||
|
|
@ -125,8 +137,9 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
confirmLabel: "Créer",
|
confirmLabel: "Créer",
|
||||||
onResolve: (confirmed) => {
|
onResolve: (confirmed) => {
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
store.setToolboxes(store.toolboxes.map((item) => item.id === toolboxId
|
const updatedAt = new Date().toISOString();
|
||||||
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt: new Date().toISOString() }
|
store.setToolboxes((toolboxes) => toolboxes.map((item) => item.id === toolboxId
|
||||||
|
? { ...item, modules: [...item.modules, { id: moduleId, type: "checklist", title: list.title || "Checklist" }], updatedAt }
|
||||||
: item));
|
: item));
|
||||||
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
|
store.updateModuleData(toolboxId, moduleId, { sections }, "checklist");
|
||||||
notify("Checklist créée dans la toolbox associée.");
|
notify("Checklist créée dans la toolbox associée.");
|
||||||
|
|
@ -147,7 +160,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
});
|
});
|
||||||
imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap);
|
imported.moduleOrder = remapModuleOrderIds(imported.moduleOrder, moduleIdMap);
|
||||||
const normalizedImport = normalizeToolbox(imported);
|
const normalizedImport = normalizeToolbox(imported);
|
||||||
const nextModuleData = { ...store.moduleData };
|
const nextModuleData = {};
|
||||||
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
|
Object.entries(payload.modules || {}).forEach(([oldId, data]) => {
|
||||||
const nextId = moduleIdMap.get(oldId);
|
const nextId = moduleIdMap.get(oldId);
|
||||||
const module = normalizedImport.modules.find((item) => item.id === nextId);
|
const module = normalizedImport.modules.find((item) => item.id === nextId);
|
||||||
|
|
@ -156,9 +169,11 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
if (compact) nextModuleData[`${normalizedImport.id}:${nextId}`] = compact;
|
if (compact) nextModuleData[`${normalizedImport.id}:${nextId}`] = compact;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
const nextLinks = { ...store.links };
|
const saved = await store.persistToolboxSnapshot((current) => ({
|
||||||
if (gameId) nextLinks[gameId] = normalizedImport.id;
|
toolboxes: [normalizedImport, ...current.toolboxes],
|
||||||
const saved = await store.persistToolboxSnapshot([normalizedImport, ...store.toolboxes], nextLinks, nextModuleData);
|
links: gameId ? { ...current.links, [gameId]: normalizedImport.id } : current.links,
|
||||||
|
moduleData: { ...current.moduleData, ...nextModuleData }
|
||||||
|
}));
|
||||||
if (!saved) return null;
|
if (!saved) return null;
|
||||||
return normalizedImport;
|
return normalizedImport;
|
||||||
}
|
}
|
||||||
|
|
@ -183,12 +198,12 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap);
|
const moduleOrder = remapModuleOrderIds(toolbox.moduleOrder, localModuleIdMap);
|
||||||
return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() });
|
return normalizeToolbox({ ...toolbox, id: nextToolboxId, name: `${toolbox.name || "Toolbox"} (import)`, modules, moduleOrder, updatedAt: new Date().toISOString() });
|
||||||
});
|
});
|
||||||
const nextLinks = { ...store.links };
|
const nextLinks = {};
|
||||||
Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {
|
Object.entries(payload.links || {}).forEach(([gameId, oldToolboxId]) => {
|
||||||
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
||||||
if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
|
if (nextToolboxId) nextLinks[gameId] = nextToolboxId;
|
||||||
});
|
});
|
||||||
const nextModuleData = { ...store.moduleData };
|
const nextModuleData = {};
|
||||||
Object.entries(payload.modules).forEach(([key, data]) => {
|
Object.entries(payload.modules).forEach(([key, data]) => {
|
||||||
const [oldToolboxId] = key.split(":");
|
const [oldToolboxId] = key.split(":");
|
||||||
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
const nextToolboxId = toolboxIdMap.get(oldToolboxId);
|
||||||
|
|
@ -198,7 +213,11 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
const compact = compactModuleDataForStorage(module?.type, data);
|
const compact = compactModuleDataForStorage(module?.type, data);
|
||||||
if (nextToolboxId && nextModuleId && compact) nextModuleData[`${nextToolboxId}:${nextModuleId}`] = compact;
|
if (nextToolboxId && nextModuleId && compact) nextModuleData[`${nextToolboxId}:${nextModuleId}`] = compact;
|
||||||
});
|
});
|
||||||
const saved = await store.persistToolboxSnapshot([...importedToolboxes, ...store.toolboxes], nextLinks, nextModuleData);
|
const saved = await store.persistToolboxSnapshot((current) => ({
|
||||||
|
toolboxes: [...importedToolboxes, ...current.toolboxes],
|
||||||
|
links: { ...current.links, ...nextLinks },
|
||||||
|
moduleData: { ...current.moduleData, ...nextModuleData }
|
||||||
|
}));
|
||||||
if (!saved) throw new Error("Impossible d'enregistrer l'import global.");
|
if (!saved) throw new Error("Impossible d'enregistrer l'import global.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -219,7 +238,7 @@ export function useToolboxActions({ store, notify, creationTemplate, setConfirmM
|
||||||
updateModuleData: store.updateModuleData,
|
updateModuleData: store.updateModuleData,
|
||||||
updateToolboxOrder: (orderedIds) => {
|
updateToolboxOrder: (orderedIds) => {
|
||||||
const order = new Map(orderedIds.map((id, index) => [id, index]));
|
const order = new Map(orderedIds.map((id, index) => [id, index]));
|
||||||
store.setToolboxes([...store.toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999)));
|
store.setToolboxes((toolboxes) => [...toolboxes].sort((a, b) => (order.get(a.id) ?? 9999) - (order.get(b.id) ?? 9999)));
|
||||||
},
|
},
|
||||||
importToolbox: async (file, gameId = "") => {
|
importToolbox: async (file, gameId = "") => {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -108,13 +108,18 @@ export async function setToolboxSnapshot(toolboxes, links, moduleData) {
|
||||||
const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite");
|
const transaction = db.transaction([KV_STORE, MODULE_STORE], "readwrite");
|
||||||
const kv = transaction.objectStore(KV_STORE);
|
const kv = transaction.objectStore(KV_STORE);
|
||||||
const modules = transaction.objectStore(MODULE_STORE);
|
const modules = transaction.objectStore(MODULE_STORE);
|
||||||
kv.put(toolboxes, "toolboxes");
|
|
||||||
kv.put(links, "links");
|
|
||||||
modules.clear();
|
|
||||||
Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data }));
|
|
||||||
transaction.oncomplete = () => resolve();
|
transaction.oncomplete = () => resolve();
|
||||||
transaction.onerror = () => reject(transaction.error);
|
transaction.onerror = () => reject(transaction.error);
|
||||||
transaction.onabort = () => reject(transaction.error);
|
transaction.onabort = () => reject(transaction.error);
|
||||||
|
try {
|
||||||
|
kv.put(toolboxes, "toolboxes");
|
||||||
|
kv.put(links, "links");
|
||||||
|
modules.clear();
|
||||||
|
Object.entries(moduleData || {}).forEach(([key, data]) => modules.put({ key, data }));
|
||||||
|
} catch (error) {
|
||||||
|
transaction.abort();
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue