Make Resource Loader server-backed and drop local metadata cache
All checks were successful
Build Org Website / build (push) Successful in 50s

This commit is contained in:
gitea-actions
2026-08-20 14:19:00 +01:00
parent 7a60d33610
commit 4653a2caae
8 changed files with 78 additions and 58 deletions

View File

@@ -6,8 +6,7 @@
if (!root || !Model) return;
const DB_NAME = "zxh-resource-loader";
const DB_VERSION = 1;
const META_STORE = "metadata";
const DB_VERSION = 2;
const ATTACHMENT_STORE = "attachments";
const API_URL = "/api/resource-loader";
const $ = (selector) => document.querySelector(selector);
@@ -67,7 +66,7 @@
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(META_STORE)) db.createObjectStore(META_STORE);
if (db.objectStoreNames.contains("metadata")) db.deleteObjectStore("metadata");
if (!db.objectStoreNames.contains(ATTACHMENT_STORE)) db.createObjectStore(ATTACHMENT_STORE, { keyPath: "id" });
};
request.onsuccess = () => resolve(request.result);
@@ -86,11 +85,21 @@
});
}
function getMetadata(key) { return request(META_STORE, "readonly", (store) => store.get(key)); }
function setMetadata(key, value) { return request(META_STORE, "readwrite", (store) => store.put(value, key)); }
function getAttachment(id) { return id ? request(ATTACHMENT_STORE, "readonly", (store) => store.get(id)) : Promise.resolve(null); }
function putAttachment(record) { return request(ATTACHMENT_STORE, "readwrite", (store) => store.put(record)); }
function deleteAttachment(id) { return id ? request(ATTACHMENT_STORE, "readwrite", (store) => store.delete(id)) : Promise.resolve(); }
function deleteStoredThumbnails() {
return request(ATTACHMENT_STORE, "readwrite", (store) => {
const cursorRequest = store.openCursor();
cursorRequest.onsuccess = () => {
const cursor = cursorRequest.result;
if (!cursor) return;
if (cursor.value && cursor.value.kind === "thumbnail") cursor.delete();
cursor.continue();
};
return cursorRequest;
});
}
function setSyncStatus(status, label) {
if (!els.syncStatus) return;
@@ -120,8 +129,10 @@
body: attachment.blob,
});
if (!response.ok) throw new Error(`Thumbnail upload failed (${response.status}).`);
await deleteAttachment(id);
state.uploadedThumbnails.add(id);
}));
await deleteStoredThumbnails();
}
async function pushServerLibrary(library, allowMerge) {
@@ -138,7 +149,6 @@
const remote = await fetchServerLibrary();
const merged = Model.mergeForSync(remote, library);
state.library = merged;
await setMetadata("library", merged);
if (state.eventsBound) render();
toast("Changes from another device were merged.");
return pushServerLibrary(merged, false);
@@ -150,35 +160,34 @@
}
async function saveLibrary() {
await setMetadata("library", Model.normaliseLibrary(state.library));
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
try {
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
return true;
} catch (error) {
console.warn("Resource Loader server sync is unavailable.", error);
setSyncStatus("offline", "offline · saved here");
console.error("Resource Loader could not save to the server.", error);
setSyncStatus("error", "server unavailable");
toast("The change was not saved. Reloading the shared server library.", true);
try {
const remote = await fetchServerLibrary();
if (remote) {
state.library = remote;
if (state.eventsBound) render();
}
} catch (reloadError) {
console.warn("The authoritative server library could not be reloaded.", reloadError);
}
return false;
}
});
return state.syncQueue;
}
async function loadLibrary() {
const saved = await getMetadata("library");
try {
const remote = await fetchServerLibrary();
if (remote) {
setSyncStatus("synced", "synced to server");
await setMetadata("library", remote);
return remote;
}
} catch (error) {
console.warn("Resource Loader server is unavailable; using this browser's cache.", error);
setSyncStatus("offline", "offline · using cache");
}
if (saved) return Model.normaliseLibrary(saved);
const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" });
if (response.ok) return Model.normaliseLibrary(await response.json());
return Model.createSeed();
const remote = await fetchServerLibrary();
if (!remote) throw new Error("The server Resource Loader library has not been initialised.");
setSyncStatus("synced", "synced to server");
return remote;
}
function currentVault() {
@@ -297,7 +306,7 @@
let blob = attachment && attachment.blob;
if (!blob) {
try {
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { credentials: "same-origin" });
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { cache: "no-store", credentials: "same-origin" });
if (response.ok) blob = await response.blob();
} catch (error) {
console.warn(`Thumbnail ${id} is unavailable.`, error);
@@ -438,7 +447,7 @@
if (!confirm("Delete this session?")) return;
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
resource.updatedAt = Model.now();
await saveLibrary();
if (!await saveLibrary()) return;
renderSessions(resource);
render();
toast("Session deleted.");
@@ -512,7 +521,7 @@
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
else vault.resources.push(resource);
vault.updatedAt = Model.now();
await saveLibrary();
if (!await saveLibrary()) return;
state.pendingImport = null;
closeDialog(els.resourceDialog);
render();
@@ -522,10 +531,11 @@
async function removeResource() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
await Promise.all([deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]);
const attachmentIds = [resource.attachmentId, resource.thumbnailId];
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
currentVault().updatedAt = Model.now();
await saveLibrary();
if (!await saveLibrary()) return;
await Promise.all(attachmentIds.map(deleteAttachment));
closeDialog(els.resourceDialog);
render();
toast("Resource deleted.");
@@ -557,7 +567,7 @@
resource.updatedAt = Model.now();
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
await saveLibrary();
if (!await saveLibrary()) return;
closeDialog(els.sessionDialog);
renderSessions(resource);
render();
@@ -610,7 +620,7 @@
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
vault.colour = $("#rl-name-colour").value;
}
await saveLibrary();
if (!await saveLibrary()) return;
closeDialog(els.nameDialog);
render();
renderVaultManager();
@@ -630,7 +640,7 @@
const folder = currentVault().folders.find((item) => item.id === folderId);
if (!folder || !confirm(`Delete “${folder.name}”? Its resources will move to Unfiled.`)) return;
state.library = Model.deleteFolder(state.library, state.vaultId, folderId);
await saveLibrary();
if (!await saveLibrary()) return;
closeDialog(els.nameDialog);
render();
toast("Folder deleted; resources moved to Unfiled.");
@@ -657,11 +667,12 @@
$("#rl-vault-list").querySelectorAll("[data-delete-vault]").forEach((button) => button.addEventListener("click", async () => {
const vault = state.library.vaults.find((item) => item.id === button.dataset.deleteVault);
if (!confirm(`Delete the “${vault.name}” vault and all of its records? Retained PDFs will also be removed.`)) return;
await Promise.all(vault.resources.flatMap((resource) => [deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]));
const attachmentIds = vault.resources.flatMap((resource) => [resource.attachmentId, resource.thumbnailId]);
state.library = Model.deleteVault(state.library, vault.id);
state.vaultId = state.library.vaults[0].id;
state.folderId = "all";
await saveLibrary();
if (!await saveLibrary()) return;
await Promise.all(attachmentIds.map(deleteAttachment));
renderVaultManager(); render(); toast("Vault deleted.");
}));
}
@@ -697,11 +708,11 @@
async function applyImport(mode) {
if (!state.importData) return;
if (mode === "replace" && !confirm("Replace every vault and resource? Retained PDF files will also be cleared.")) return;
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
state.library = mode === "merge" ? Model.mergeLibraries(state.library, state.importData) : state.importData;
state.vaultId = state.library.vaults[0] && state.library.vaults[0].id;
state.folderId = "all";
await saveLibrary();
if (!await saveLibrary()) return;
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
state.importData = null;
closeDialog(els.importDialog);
render();
@@ -805,7 +816,7 @@
}
async function clearData() {
if (!confirm("Clear this browser's offline cache and retained PDFs? Server metadata and thumbnails will remain available.")) return;
if (!confirm("Remove retained PDFs from this browser? Shared server metadata and thumbnails will remain available.")) return;
closeDialog(els.storageDialog);
state.db.close();
await new Promise((resolve, reject) => {
@@ -851,7 +862,6 @@
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
});
window.addEventListener("online", () => saveLibrary());
}
async function initialise() {
@@ -859,14 +869,17 @@
try {
state.db = await openDatabase();
state.library = await loadLibrary();
if (!state.library.vaults.length) state.library = Model.createSeed();
state.vaultId = state.library.vaults[0].id;
await saveLibrary();
bindEvents();
render();
uploadLocalThumbnails(state.library).catch((error) => {
console.warn("Some generated covers could not be uploaded yet.", error);
toast("The library is synced, but a generated cover could not be uploaded.", true);
});
} catch (error) {
console.error(error);
root.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">!</span><h3>Library storage is unavailable</h3><p>${escapeHtml(error.message || "This browser could not open IndexedDB.")}</p></div>`;
setSyncStatus("error", "server unavailable");
root.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">!</span><h3>Server library is unavailable</h3><p>${escapeHtml(error.message || "The shared Resource Loader library could not be loaded.")}</p></div>`;
} finally {
hideLoading();
}