Auto commit
All checks were successful
Build Org Website / build (push) Successful in 40s

This commit is contained in:
gitea-actions
2026-08-20 14:03:52 +01:00
parent 0b4a68b81b
commit 7a60d33610
11 changed files with 504 additions and 72 deletions

View File

@@ -9,6 +9,7 @@
const DB_VERSION = 1;
const META_STORE = "metadata";
const ATTACHMENT_STORE = "attachments";
const API_URL = "/api/resource-loader";
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const state = {
@@ -22,6 +23,10 @@
pendingImport: null,
pdfUrls: new Map(),
thumbnailUrls: new Map(),
uploadedThumbnails: new Set(),
serverEtag: null,
syncQueue: Promise.resolve(),
eventsBound: false,
};
let pdfJsPromise;
@@ -54,6 +59,7 @@
jsonInput: $("#rl-json-input"),
loading: $("#rl-loading"),
toast: $("#rl-toast"),
syncStatus: $("#rl-sync-status"),
};
function openDatabase() {
@@ -86,21 +92,93 @@
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 setSyncStatus(status, label) {
if (!els.syncStatus) return;
els.syncStatus.dataset.state = status;
els.syncStatus.querySelector("small").textContent = label;
}
async function fetchServerLibrary() {
const response = await fetch(API_URL, { cache: "no-store", credentials: "same-origin" });
if (response.status === 404) return null;
if (!response.ok) throw new Error(`Server library request failed (${response.status}).`);
const library = Model.normaliseLibrary(await response.json());
state.serverEtag = response.headers.get("ETag");
return library;
}
async function uploadLocalThumbnails(library) {
const ids = Array.from(new Set(library.vaults.flatMap((vault) => vault.resources.map((resource) => resource.thumbnailId).filter(Boolean))));
await Promise.all(ids.map(async (id) => {
if (state.uploadedThumbnails.has(id)) return;
const attachment = await getAttachment(id);
if (!attachment || !attachment.blob || attachment.kind !== "thumbnail") return;
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, {
method: "PUT",
credentials: "same-origin",
headers: { "Content-Type": attachment.type || attachment.blob.type || "image/jpeg" },
body: attachment.blob,
});
if (!response.ok) throw new Error(`Thumbnail upload failed (${response.status}).`);
state.uploadedThumbnails.add(id);
}));
}
async function pushServerLibrary(library, allowMerge) {
setSyncStatus("syncing", "syncing…");
const headers = { "Content-Type": "application/json" };
if (state.serverEtag) headers["If-Match"] = state.serverEtag;
const response = await fetch(API_URL, {
method: "PUT",
credentials: "same-origin",
headers,
body: JSON.stringify(library),
});
if (response.status === 412 && allowMerge) {
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);
}
if (!response.ok) throw new Error(`Server save failed (${response.status}).`);
state.serverEtag = response.headers.get("ETag");
await uploadLocalThumbnails(library);
setSyncStatus("synced", "synced to server");
}
async function saveLibrary() {
await setMetadata("library", state.library);
await setMetadata("library", Model.normaliseLibrary(state.library));
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
try {
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
} catch (error) {
console.warn("Resource Loader server sync is unavailable.", error);
setSyncStatus("offline", "offline · saved here");
}
});
return state.syncQueue;
}
async function loadLibrary() {
const saved = await getMetadata("library");
if (saved) return Model.normaliseLibrary(saved);
try {
const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" });
if (!response.ok) throw new Error("Seed library could not be loaded.");
return Model.normaliseLibrary(await response.json());
const remote = await fetchServerLibrary();
if (remote) {
setSyncStatus("synced", "synced to server");
await setMetadata("library", remote);
return remote;
}
} catch (error) {
console.warn(error);
return Model.createSeed();
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();
}
function currentVault() {
@@ -216,8 +294,17 @@
if (!id) return null;
if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id);
const attachment = await getAttachment(id);
if (!attachment || !attachment.blob) return null;
const url = URL.createObjectURL(attachment.blob);
let blob = attachment && attachment.blob;
if (!blob) {
try {
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, { credentials: "same-origin" });
if (response.ok) blob = await response.blob();
} catch (error) {
console.warn(`Thumbnail ${id} is unavailable.`, error);
}
}
if (!blob) return null;
const url = URL.createObjectURL(blob);
state.thumbnailUrls.set(id, url);
return url;
}
@@ -718,7 +805,7 @@
}
async function clearData() {
if (!confirm("Clear every Resource Loader vault, record, session, and retained PDF from this browser?")) return;
if (!confirm("Clear this browser's offline cache and retained PDFs? Server metadata and thumbnails will remain available.")) return;
closeDialog(els.storageDialog);
state.db.close();
await new Promise((resolve, reject) => {
@@ -730,6 +817,7 @@
}
function bindEvents() {
state.eventsBound = true;
els.vaultSelect.addEventListener("change", () => { state.vaultId = els.vaultSelect.value; state.folderId = "all"; render(); });
[els.search, els.typeFilter, els.statusFilter, els.tagFilter, els.sort].forEach((control) => control.addEventListener(control === els.search ? "input" : "change", renderResources));
$("#rl-view-list").addEventListener("click", () => { state.view = "list"; localStorage.setItem("rl-view", state.view); renderResources(); });
@@ -763,6 +851,7 @@
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
});
window.addEventListener("online", () => saveLibrary());
}
async function initialise() {