feat(kanban): add local recovery controls

req: local/003\nreq: security/007
This commit is contained in:
slhx agent
2026-07-13 14:59:05 +02:00
parent 9a3c0335eb
commit d946975f2d
4 changed files with 290 additions and 5 deletions
+88 -3
View File
@@ -2,6 +2,8 @@ const DATABASE = "hemx-kanban-v1";
const COMMANDS = "commands";
const META = "meta";
const COMMAND_SCHEMA = 1;
const EXPORT_SCHEMA = 1;
const SESSION = "hemx-kanban-session-v1";
const ROOT = '[data-hemx-root][data-hemx-client-module="/kanban_client.js"]';
function result(request) {
@@ -52,11 +54,10 @@ async function prepareOfflineShell(root) {
}
function stableSession() {
const key = "hemx-kanban-session-v1";
let session = sessionStorage.getItem(key);
let session = sessionStorage.getItem(SESSION);
if (!session) {
session = crypto.randomUUID();
sessionStorage.setItem(key, session);
sessionStorage.setItem(SESSION, session);
}
return session;
}
@@ -126,6 +127,89 @@ function report(root, stage, error) {
root.dispatchEvent(new CustomEvent("kanban:command-error", { detail: { stage, message } }));
}
function announce(root, message) {
const status = root.querySelector('[role="status"]');
if (status) status.textContent = message;
}
function exportCommands(root, commands) {
const payload = { schemaVersion: EXPORT_SCHEMA, commands };
const json = JSON.stringify(payload, null, 2);
const url = URL.createObjectURL(new Blob([json], { type: "application/json" }));
const download = document.createElement("a");
download.href = url;
download.download = "hemx-kanban-commands.json";
download.hidden = true;
document.body.append(download);
download.click();
download.remove();
setTimeout(() => URL.revokeObjectURL(url), 0);
announce(root, `Exported ${commands.length} command${commands.length === 1 ? "" : "s"}.`);
root.dispatchEvent(new CustomEvent("kanban:commands-exported", { detail: payload }));
}
async function clearCommands(database) {
const transaction = database.transaction(COMMANDS, "readwrite");
const done = completed(transaction);
transaction.objectStore(COMMANDS).clear();
await done;
}
async function resetLocalData(database) {
database.close();
await result(indexedDB.deleteDatabase(DATABASE));
sessionStorage.removeItem(SESSION);
await Promise.all((await caches.keys()).filter((name) => name.startsWith("hemx-kanban-shell-")).map((name) => caches.delete(name)));
await Promise.all((await navigator.serviceWorker.getRegistrations()).map((registration) => registration.unregister()));
}
function disarmRecoveryControls(controls) {
for (const control of controls) {
if (!control.dataset.confirmLabel) continue;
control.textContent = control.dataset.confirmLabel;
delete control.dataset.confirmLabel;
}
}
function installRecoveryControls(root, database) {
const controls = [...root.querySelectorAll("[data-kanban-command-action]")];
for (const control of controls) {
control.addEventListener("click", async () => {
const action = control.getAttribute("data-kanban-command-action");
if ((action === "delete" || action === "reset") && !control.dataset.confirmLabel) {
disarmRecoveryControls(controls);
control.dataset.confirmLabel = control.textContent;
control.textContent = `Confirm ${control.textContent.toLowerCase()}`;
announce(root, `${control.dataset.confirmLabel} requires confirmation.`);
return;
}
if (action === "export") disarmRecoveryControls(controls);
controls.forEach((item) => { item.disabled = true; });
try {
if (action === "export") {
exportCommands(root, await storedCommands(database));
controls.forEach((item) => { item.disabled = false; });
return;
}
if (action === "delete") {
await clearCommands(database);
root.dispatchEvent(new CustomEvent("kanban:commands-deleted"));
} else if (action === "reset") {
await resetLocalData(database);
root.dispatchEvent(new CustomEvent("kanban:local-data-reset"));
} else {
throw new Error(`unsupported recovery action ${action}`);
}
location.reload();
} catch (error) {
controls.forEach((item) => { item.disabled = false; });
disarmRecoveryControls(controls);
report(root, action || "recovery", error);
}
});
}
}
async function start() {
const root = document.querySelector(ROOT);
if (!root) return;
@@ -163,6 +247,7 @@ async function start() {
wasmHandler = window.hemx.registerClientHandler("reorder_card", durableHandler);
if (typeof wasmHandler !== "function") throw new Error("reorder_card WASM handler is not registered");
const database = await databasePromise;
installRecoveryControls(root, database);
try {
const commands = await storedCommands(database);
for (const command of commands) window.hemx.applyBatch(await project(root, wasmHandler, command), root);
@@ -5,5 +5,11 @@
{+ card +}
</template>
</ul>
<fieldset>
<legend>Offline commands</legend>
<button type="button" data-kanban-command-action="export">Export commands</button>
<button type="button" data-kanban-command-action="delete">Delete commands</button>
<button type="button" data-kanban-command-action="reset">Reset local data</button>
</fieldset>
<div data-hemx-handle="reorder_card" data-hemx-on="drop" data-hemx-client="reorder_card" data-hemx-client-event="drop" data-hemx-client-policy="latest">Drop card</div>
</section>