Make Resource Loader server-backed and drop local metadata cache
All checks were successful
Build Org Website / build (push) Successful in 50s
All checks were successful
Build Org Website / build (push) Successful in 50s
This commit is contained in:
@@ -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();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user