Files
org_web/assets/scripts/pages/resource-loader.js
gitea-actions 4653a2caae
All checks were successful
Build Org Website / build (push) Successful in 50s
Make Resource Loader server-backed and drop local metadata cache
2026-08-20 14:19:00 +01:00

890 lines
43 KiB
JavaScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
const root = document.getElementById("resource-loader");
const Model = globalThis.ResourceLoaderModel;
if (!root || !Model) return;
const DB_NAME = "zxh-resource-loader";
const DB_VERSION = 2;
const ATTACHMENT_STORE = "attachments";
const API_URL = "/api/resource-loader";
const $ = (selector) => document.querySelector(selector);
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
const state = {
db: null,
library: null,
vaultId: null,
folderId: "all",
view: localStorage.getItem("rl-view") || "list",
importData: null,
nameAction: null,
pendingImport: null,
pdfUrls: new Map(),
thumbnailUrls: new Map(),
uploadedThumbnails: new Set(),
serverEtag: null,
syncQueue: Promise.resolve(),
eventsBound: false,
};
let pdfJsPromise;
const STATUS_GROUPS = [
{ id: "active", label: "In progress", description: "Resources you are actively reading or watching." },
{ id: "backlog", label: "Backlog", description: "Resources you intend to start later." },
{ id: "shelf", label: "On the shelf", description: "Resources kept close at hand, but not currently active." },
{ id: "completed", label: "Completed", description: "Resources you have finished." },
{ id: "archived", label: "Archived", description: "Resources retained outside your current library flow." },
];
const els = {
vaultSelect: $("#rl-vault-select"),
folderList: $("#rl-folder-list"),
resourceList: $("#rl-resource-list"),
contextLabel: $("#rl-context-label"),
search: $("#rl-search"),
typeFilter: $("#rl-filter-type"),
statusFilter: $("#rl-filter-status"),
tagFilter: $("#rl-filter-tag"),
sort: $("#rl-sort"),
resourceDialog: $("#rl-resource-dialog"),
resourceForm: $("#rl-resource-form"),
sessionDialog: $("#rl-session-dialog"),
nameDialog: $("#rl-name-dialog"),
vaultDialog: $("#rl-vault-dialog"),
importDialog: $("#rl-import-dialog"),
storageDialog: $("#rl-storage-dialog"),
pdfInput: $("#rl-pdf-input"),
jsonInput: $("#rl-json-input"),
loading: $("#rl-loading"),
toast: $("#rl-toast"),
syncStatus: $("#rl-sync-status"),
};
function openDatabase() {
return new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
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);
request.onerror = () => reject(request.error);
});
}
function request(storeName, mode, operation) {
return new Promise((resolve, reject) => {
const transaction = state.db.transaction(storeName, mode);
const store = transaction.objectStore(storeName);
const result = operation(store);
transaction.oncomplete = () => resolve(result && result.result);
transaction.onerror = () => reject(transaction.error || (result && result.error));
transaction.onabort = () => reject(transaction.error || new Error("Storage operation was aborted."));
});
}
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;
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}).`);
await deleteAttachment(id);
state.uploadedThumbnails.add(id);
}));
await deleteStoredThumbnails();
}
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;
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() {
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
try {
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
return true;
} catch (error) {
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 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() {
return state.library.vaults.find((vault) => vault.id === state.vaultId) || state.library.vaults[0];
}
function currentResource(id) {
return currentVault().resources.find((resource) => resource.id === id);
}
function escapeHtml(value) {
return String(value == null ? "" : value)
.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;")
.replace(/"/g, "&quot;").replace(/'/g, "&#039;");
}
function titleCase(value) {
return String(value || "").replace(/[-_]/g, " ").replace(/\b\w/g, (letter) => letter.toUpperCase());
}
function splitList(value) {
return String(value || "").split(",").map((item) => item.trim()).filter(Boolean);
}
function showLoading(message) {
els.loading.querySelector("p").textContent = message || "Working…";
els.loading.classList.add("is-visible");
}
function hideLoading() { els.loading.classList.remove("is-visible"); }
let toastTimer;
function toast(message, error) {
clearTimeout(toastTimer);
els.toast.textContent = message;
els.toast.classList.toggle("is-error", Boolean(error));
els.toast.classList.add("is-visible");
toastTimer = setTimeout(() => els.toast.classList.remove("is-visible"), 3600);
}
function closeDialog(dialog) {
if (dialog && dialog.open) dialog.close();
}
function setValue(id, value) {
const element = document.getElementById(id);
if (element) element.value = value == null ? "" : value;
}
function renderVaultSelect() {
const previous = state.vaultId;
els.vaultSelect.innerHTML = state.library.vaults.map((vault) =>
`<option value="${escapeHtml(vault.id)}">${escapeHtml(vault.name)}</option>`).join("");
state.vaultId = state.library.vaults.some((vault) => vault.id === previous) ? previous : state.library.vaults[0].id;
els.vaultSelect.value = state.vaultId;
}
function resourceCount(folderId) {
const resources = currentVault().resources;
if (folderId === "all") return resources.length;
if (folderId === "unfiled") return resources.filter((item) => !item.folderId).length;
return resources.filter((item) => item.folderId === folderId).length;
}
function folderRow(id, label, editable) {
return `<div class="rl-folder-row${state.folderId === id ? " is-active" : ""}" data-folder-row="${escapeHtml(id)}">
<button type="button" data-folder="${escapeHtml(id)}"><span>${escapeHtml(label)}</span><span>${resourceCount(id)}</span></button>
${editable ? `<button type="button" class="rl-folder-menu" data-folder-menu="${escapeHtml(id)}" aria-label="Manage ${escapeHtml(label)}">•••</button>` : ""}
</div>`;
}
function renderFolders() {
const vault = currentVault();
if (!vault.folders.some((folder) => folder.id === state.folderId) && !["all", "unfiled"].includes(state.folderId)) state.folderId = "all";
els.folderList.innerHTML = folderRow("all", "All resources", false) + folderRow("unfiled", "Unfiled", false) +
'<div class="rl-folder-rule" aria-hidden="true"></div>' +
vault.folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => folderRow(folder.id, folder.name, true)).join("");
els.folderList.querySelectorAll("[data-folder]").forEach((button) => button.addEventListener("click", () => {
state.folderId = button.dataset.folder;
render();
}));
els.folderList.querySelectorAll("[data-folder-menu]").forEach((button) => button.addEventListener("click", () => manageFolder(button.dataset.folderMenu)));
}
function populateTagFilter() {
const previous = els.tagFilter.value;
const tags = Array.from(new Set(currentVault().resources.flatMap((resource) => resource.tags || []))).sort((a, b) => a.localeCompare(b));
els.tagFilter.innerHTML = '<option value="">All tags</option>' + tags.map((tag) => `<option value="${escapeHtml(tag)}">${escapeHtml(tag)}</option>`).join("");
els.tagFilter.value = tags.includes(previous) ? previous : "";
}
function filteredResources() {
const query = els.search.value.trim().toLocaleLowerCase();
let resources = currentVault().resources.filter((resource) => {
if (state.folderId === "unfiled" && resource.folderId) return false;
if (!["all", "unfiled"].includes(state.folderId) && resource.folderId !== state.folderId) return false;
if (els.typeFilter.value && resource.type !== els.typeFilter.value) return false;
if (els.statusFilter.value && resource.status !== els.statusFilter.value) return false;
if (els.tagFilter.value && !(resource.tags || []).includes(els.tagFilter.value)) return false;
const haystack = [resource.title, ...(resource.creators || []), ...(resource.tags || []), resource.description, resource.notes].join(" ").toLocaleLowerCase();
return !query || haystack.includes(query);
});
resources.sort((a, b) => {
if (els.sort.value === "title") return a.title.localeCompare(b.title);
if (els.sort.value === "progress") return Model.progressFor(b).percent - Model.progressFor(a).percent;
return String(b.updatedAt).localeCompare(String(a.updatedAt));
});
return resources;
}
async function thumbnailUrl(id) {
if (!id) return null;
if (state.thumbnailUrls.has(id)) return state.thumbnailUrls.get(id);
const attachment = await getAttachment(id);
let blob = attachment && attachment.blob;
if (!blob) {
try {
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);
}
}
if (!blob) return null;
const url = URL.createObjectURL(blob);
state.thumbnailUrls.set(id, url);
return url;
}
function cardMarkup(resource) {
const progress = Model.progressFor(resource);
const initial = (resource.title || "?").trim().charAt(0).toUpperCase();
const creators = (resource.creators || []).join(", ") || "Unknown creator";
return `<article class="rl-resource-card rl-resource-card--${escapeHtml(resource.status)}" tabindex="0" role="button" data-resource-id="${escapeHtml(resource.id)}" aria-label="Open ${escapeHtml(resource.title)}">
<span class="rl-resource-cover" data-thumb="${escapeHtml(resource.thumbnailId || "")}">${escapeHtml(initial)}</span>
<span class="rl-resource-title"><strong>${escapeHtml(resource.title)}</strong><small>${escapeHtml(creators)}</small><small>${escapeHtml((resource.tags || []).slice(0, 3).join(" · "))}</small></span>
<span class="rl-badges"><span class="rl-badge">${escapeHtml(resource.type)}</span><span class="rl-badge">${escapeHtml(resource.status)}</span><span class="rl-badge">${escapeHtml(resource.format)}</span></span>
<span class="rl-progress"><span class="rl-progress__track"><span class="rl-progress__fill" style="width:${progress.percent}%"></span></span><small>${progress.percent}% complete</small></span>
<span class="rl-card-arrow" aria-hidden="true"></span>
</article>`;
}
function statusGroupMarkup(group, resources) {
const headingId = `rl-status-${group.id}`;
const countLabel = `${resources.length} ${resources.length === 1 ? "resource" : "resources"}`;
return `<section class="rl-status-group rl-status-group--${group.id}" aria-labelledby="${headingId}">
<header class="rl-status-group__head">
<span class="rl-status-group__marker" aria-hidden="true"></span>
<span class="rl-status-group__title"><strong id="${headingId}">${escapeHtml(group.label)}</strong><small>${escapeHtml(group.description)}</small></span>
<span class="rl-status-group__count" aria-label="${countLabel}">${resources.length}</span>
</header>
<div class="rl-status-resources">${resources.map(cardMarkup).join("")}</div>
</section>`;
}
async function hydrateThumbnails(container) {
const targets = Array.from(container.querySelectorAll("[data-thumb]")).filter((item) => item.dataset.thumb);
await Promise.all(targets.map(async (target) => {
const url = await thumbnailUrl(target.dataset.thumb);
if (url && target.isConnected) target.innerHTML = `<img src="${url}" alt="" />`;
}));
}
function bindResourceCards() {
els.resourceList.querySelectorAll("[data-resource-id]").forEach((card) => {
const open = () => openResourceDialog(currentResource(card.dataset.resourceId));
card.addEventListener("click", open);
card.addEventListener("keydown", (event) => {
if (event.key === "Enter" || event.key === " ") { event.preventDefault(); open(); }
});
});
}
function renderResources() {
const resources = filteredResources();
const folder = currentVault().folders.find((item) => item.id === state.folderId);
els.contextLabel.textContent = state.folderId === "all" ? "All resources" : state.folderId === "unfiled" ? "Unfiled" : folder ? folder.name : "All resources";
els.resourceList.classList.toggle("is-grid", state.view === "grid");
$("#rl-view-list").classList.toggle("is-active", state.view === "list");
$("#rl-view-grid").classList.toggle("is-active", state.view === "grid");
$("#rl-view-list").setAttribute("aria-pressed", String(state.view === "list"));
$("#rl-view-grid").setAttribute("aria-pressed", String(state.view === "grid"));
if (!resources.length) {
const filtered = currentVault().resources.length > 0;
els.resourceList.innerHTML = `<div class="rl-empty"><span class="rl-empty__mark">⌑</span><h3>${filtered ? "Nothing matches this view" : "Your shelf is ready"}</h3><p>${filtered ? "Try changing the folder, filters, or search." : "Add a physical book, save a link, or import a PDF to begin."}</p><button type="button" class="rl-primary" data-empty-add>Add resource</button></div>`;
els.resourceList.querySelector("[data-empty-add]").addEventListener("click", () => openResourceDialog());
return;
}
els.resourceList.innerHTML = STATUS_GROUPS.map((group) => {
const matching = resources.filter((resource) => resource.status === group.id);
return matching.length ? statusGroupMarkup(group, matching) : "";
}).join("");
bindResourceCards();
hydrateThumbnails(els.resourceList);
}
function renderStats() {
const resources = currentVault().resources;
$("#rl-stat-total").textContent = resources.length;
$("#rl-stat-active").textContent = resources.filter((item) => item.status === "active").length;
$("#rl-stat-backlog").textContent = resources.filter((item) => ["backlog", "shelf"].includes(item.status)).length;
$("#rl-stat-completed").textContent = resources.filter((item) => item.status === "completed").length;
}
function render() {
renderVaultSelect();
renderFolders();
populateTagFilter();
renderStats();
renderResources();
}
function populateFolderField(selected) {
const field = $("#rl-field-folder");
field.innerHTML = '<option value="">Unfiled</option>' + currentVault().folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => `<option value="${escapeHtml(folder.id)}">${escapeHtml(folder.name)}</option>`).join("");
field.value = selected || "";
}
function resetCover() {
$("#rl-cover-preview").innerHTML = "<span>Cover</span>";
}
function updateResourceFormForType() {
const type = $("#rl-field-type").value;
const isBook = type === "book";
els.resourceDialog.classList.toggle("is-compact", !isBook);
els.resourceDialog.querySelectorAll("[data-resource-types]").forEach((element) => {
element.hidden = !element.dataset.resourceTypes.split(/\s+/).includes(type);
});
$("#rl-creators-label").textContent = type === "video" ? "Presenter / creator" : "Author(s)";
$("#rl-publisher-label").textContent = type === "video" ? "Channel" : "Publisher";
if (!isBook) $("#rl-field-format").value = "web";
if (!isBook) $("#rl-field-pages").value = "";
if (type !== "video") $("#rl-field-duration").value = "";
}
async function showCover(id) {
resetCover();
const url = await thumbnailUrl(id);
if (url) $("#rl-cover-preview").innerHTML = `<img src="${url}" alt="Imported PDF cover" />`;
}
function renderSessions(resource) {
const section = $("#rl-session-section");
if (!resource) { section.hidden = true; return; }
section.hidden = false;
const sessions = (resource.sessions || []).slice().sort((a, b) => String(b.date).localeCompare(String(a.date)));
$("#rl-session-list").innerHTML = sessions.length ? sessions.map((session) => `<div class="rl-session-row">
<time datetime="${escapeHtml(session.date)}">${escapeHtml(session.date)}</time>
<strong>${escapeHtml(session.start)}${escapeHtml(session.end)} ${escapeHtml(session.unit)}</strong>
<span>${escapeHtml(session.note || "")}</span>
<button type="button" class="rl-danger" data-delete-session="${escapeHtml(session.id)}">Delete</button>
</div>`).join("") : '<p class="rl-help">No sessions logged yet.</p>';
$("#rl-session-list").querySelectorAll("[data-delete-session]").forEach((button) => button.addEventListener("click", async () => {
if (!confirm("Delete this session?")) return;
resource.sessions = resource.sessions.filter((item) => item.id !== button.dataset.deleteSession);
resource.updatedAt = Model.now();
if (!await saveLibrary()) return;
renderSessions(resource);
render();
toast("Session deleted.");
}));
}
async function openResourceDialog(resource, draft) {
els.resourceForm.reset();
resetCover();
const item = resource || draft || null;
state.pendingImport = draft ? { attachmentId: draft.attachmentId, thumbnailId: draft.thumbnailId } : null;
$("#rl-resource-dialog-title").textContent = resource ? "Edit resource" : draft ? "Review imported PDF" : "Add resource";
setValue("rl-resource-id", resource ? resource.id : "");
setValue("rl-attachment-id", item && item.attachmentId);
setValue("rl-thumbnail-id", item && item.thumbnailId);
setValue("rl-field-title", item && item.title);
setValue("rl-field-creators", item && (item.creators || []).join(", "));
setValue("rl-field-type", item ? item.type : "book");
setValue("rl-field-format", item ? item.format : "physical");
setValue("rl-field-status", item ? item.status : "backlog");
setValue("rl-field-pages", item && item.pageCount);
setValue("rl-field-duration", item && item.durationMinutes);
setValue("rl-field-url", item && item.url);
setValue("rl-field-publisher", item && item.publisher);
setValue("rl-field-published", item && item.published);
setValue("rl-field-tags", item && (item.tags || []).join(", "));
setValue("rl-field-description", item && item.description);
setValue("rl-field-notes", item && item.notes);
updateResourceFormForType();
populateFolderField(item && item.folderId ? item.folderId : (!["all", "unfiled"].includes(state.folderId) ? state.folderId : ""));
$("#rl-delete-resource").hidden = !resource;
$("#rl-open-resource").hidden = !(item && (item.url || item.attachmentId));
renderSessions(resource);
if (item && item.thumbnailId) showCover(item.thumbnailId);
els.resourceDialog.showModal();
$("#rl-field-title").focus();
}
function formResource(existing) {
const stamp = Model.now();
return {
id: existing ? existing.id : Model.id(),
title: $("#rl-field-title").value.trim(),
creators: splitList($("#rl-field-creators").value),
type: $("#rl-field-type").value,
format: $("#rl-field-format").value,
status: $("#rl-field-status").value,
folderId: $("#rl-field-folder").value || null,
pageCount: Number($("#rl-field-pages").value) || null,
durationMinutes: Number($("#rl-field-duration").value) || null,
url: $("#rl-field-url").value.trim(),
publisher: $("#rl-field-publisher").value.trim(),
published: $("#rl-field-published").value.trim(),
tags: splitList($("#rl-field-tags").value),
description: $("#rl-field-description").value.trim(),
notes: $("#rl-field-notes").value.trim(),
attachmentId: $("#rl-attachment-id").value || null,
thumbnailId: $("#rl-thumbnail-id").value || null,
sessions: existing ? existing.sessions || [] : [],
createdAt: existing ? existing.createdAt : stamp,
updatedAt: stamp,
};
}
async function saveResource(event) {
event.preventDefault();
const vault = currentVault();
const existing = currentResource($("#rl-resource-id").value);
const resource = formResource(existing);
if (!resource.title) return;
if (existing) vault.resources[vault.resources.findIndex((item) => item.id === existing.id)] = resource;
else vault.resources.push(resource);
vault.updatedAt = Model.now();
if (!await saveLibrary()) return;
state.pendingImport = null;
closeDialog(els.resourceDialog);
render();
toast(existing ? "Resource updated." : "Resource added.");
}
async function removeResource() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource || !confirm(`Delete “${resource.title}”? This also removes its retained PDF.`)) return;
const attachmentIds = [resource.attachmentId, resource.thumbnailId];
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
currentVault().updatedAt = Model.now();
if (!await saveLibrary()) return;
await Promise.all(attachmentIds.map(deleteAttachment));
closeDialog(els.resourceDialog);
render();
toast("Resource deleted.");
}
function openSessionDialog() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
const labels = resource.type === "book" ? ["Start page", "End page", "pages"] : resource.type === "video" ? ["Start minute", "End minute", "minutes"] : ["Start percentage", "End percentage", "percent"];
$("#rl-session-start-label").textContent = labels[0];
$("#rl-session-end-label").textContent = labels[1];
$("#rl-session-form").dataset.unit = labels[2];
$("#rl-session-form").reset();
$("#rl-session-date").value = new Date().toISOString().slice(0, 10);
const progress = Model.progressFor(resource);
$("#rl-session-start").value = progress.current || 0;
els.sessionDialog.showModal();
}
async function saveSession(event) {
event.preventDefault();
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
const start = Number($("#rl-session-start").value);
const end = Number($("#rl-session-end").value);
if (end < start) { toast("The end position must be at or after the start.", true); return; }
if (resource.type === "article" && end > 100) { toast("Article progress cannot exceed 100%.", true); return; }
resource.sessions.push({ id: Model.id(), date: $("#rl-session-date").value, start, end, unit: event.currentTarget.dataset.unit, note: $("#rl-session-note").value.trim(), createdAt: Model.now() });
resource.updatedAt = Model.now();
if (Model.progressFor(resource).percent >= 100) resource.status = "completed";
else if (["backlog", "shelf"].includes(resource.status)) resource.status = "active";
if (!await saveLibrary()) return;
closeDialog(els.sessionDialog);
renderSessions(resource);
render();
toast("Session logged.");
}
async function openLinkedResource() {
const resource = currentResource($("#rl-resource-id").value);
if (!resource) return;
if (resource.attachmentId) {
const record = await getAttachment(resource.attachmentId);
if (!record || !record.blob) { toast("This PDF is not attached in this browser. Import it again to restore the file.", true); return; }
if (state.pdfUrls.has(resource.attachmentId)) URL.revokeObjectURL(state.pdfUrls.get(resource.attachmentId));
const url = URL.createObjectURL(record.blob);
state.pdfUrls.set(resource.attachmentId, url);
window.open(url, "_blank", "noopener");
return;
}
if (resource.url) window.open(resource.url, "_blank", "noopener");
}
function openNameDialog(action, title, value, colour) {
state.nameAction = action;
$("#rl-name-title").textContent = title;
$("#rl-name-input").value = value || "";
$("#rl-colour-field").hidden = !String(action).includes("vault");
$("#rl-name-colour").value = colour || "#9b6b43";
$("#rl-name-delete").hidden = !String(action).startsWith("rename-folder:");
els.nameDialog.showModal();
$("#rl-name-input").focus();
}
async function submitName(event) {
event.preventDefault();
const name = $("#rl-name-input").value.trim();
if (!name) return;
const action = state.nameAction || "";
if (action === "add-folder") state.library = Model.addFolder(state.library, state.vaultId, name);
if (action.startsWith("rename-folder:")) {
const folder = currentVault().folders.find((item) => item.id === action.split(":")[1]);
folder.name = name; folder.updatedAt = Model.now(); currentVault().updatedAt = Model.now();
}
if (action === "add-vault") {
state.library = Model.addVault(state.library, name, $("#rl-name-colour").value);
state.vaultId = state.library.vaults[state.library.vaults.length - 1].id;
state.folderId = "all";
}
if (action.startsWith("rename-vault:")) {
state.library = Model.renameVault(state.library, action.split(":")[1], name);
const vault = state.library.vaults.find((item) => item.id === action.split(":")[1]);
vault.colour = $("#rl-name-colour").value;
}
if (!await saveLibrary()) return;
closeDialog(els.nameDialog);
render();
renderVaultManager();
toast("Saved.");
}
function manageFolder(folderId) {
const folder = currentVault().folders.find((item) => item.id === folderId);
if (!folder) return;
openNameDialog(`rename-folder:${folderId}`, "Manage folder", folder.name);
}
async function deleteNamedFolder() {
const action = state.nameAction || "";
if (!action.startsWith("rename-folder:")) return;
const folderId = action.split(":")[1];
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);
if (!await saveLibrary()) return;
closeDialog(els.nameDialog);
render();
toast("Folder deleted; resources moved to Unfiled.");
}
async function cleanupPendingImport() {
const pending = state.pendingImport;
if (!pending) return;
state.pendingImport = null;
await Promise.all([deleteAttachment(pending.attachmentId), deleteAttachment(pending.thumbnailId)]).catch(() => {});
}
function renderVaultManager() {
$("#rl-vault-list").innerHTML = state.library.vaults.map((vault) => `<div class="rl-vault-row">
<span class="rl-vault-dot" style="--vault-colour:${escapeHtml(vault.colour)}"></span><strong>${escapeHtml(vault.name)}</strong>
<button type="button" data-rename-vault="${escapeHtml(vault.id)}">Rename</button>
<button type="button" class="rl-danger" data-delete-vault="${escapeHtml(vault.id)}" ${state.library.vaults.length === 1 ? "disabled" : ""}>Delete</button>
</div>`).join("");
$("#rl-vault-list").querySelectorAll("[data-rename-vault]").forEach((button) => button.addEventListener("click", () => {
const vault = state.library.vaults.find((item) => item.id === button.dataset.renameVault);
closeDialog(els.vaultDialog);
openNameDialog(`rename-vault:${vault.id}`, "Rename vault", vault.name, vault.colour);
}));
$("#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;
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";
if (!await saveLibrary()) return;
await Promise.all(attachmentIds.map(deleteAttachment));
renderVaultManager(); render(); toast("Vault deleted.");
}));
}
function exportJson() {
const blob = new Blob([Model.serialise(state.library)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `resource-loader-${new Date().toISOString().slice(0, 10)}.json`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
toast("JSON backup exported. PDF files are not included.");
}
async function readJsonFile(file) {
try {
const data = JSON.parse(await file.text());
Model.validateLibrary(data);
state.importData = Model.normaliseLibrary(data);
const vaults = state.importData.vaults.length;
const folders = state.importData.vaults.reduce((sum, vault) => sum + vault.folders.length, 0);
const resources = state.importData.vaults.reduce((sum, vault) => sum + vault.resources.length, 0);
$("#rl-import-summary").innerHTML = `<div><strong>${vaults}</strong><span>vaults</span></div><div><strong>${folders}</strong><span>folders</span></div><div><strong>${resources}</strong><span>resources</span></div>`;
els.importDialog.showModal();
} catch (error) {
toast(`Import rejected: ${error.message}`, true);
} finally {
els.jsonInput.value = "";
}
}
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;
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";
if (!await saveLibrary()) return;
if (mode === "replace") await request(ATTACHMENT_STORE, "readwrite", (store) => store.clear());
state.importData = null;
closeDialog(els.importDialog);
render();
toast(mode === "merge" ? "Library merged." : "Library replaced.");
}
function cleanPdfTitle(filename) {
return filename.replace(/\.pdf$/i, "").replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim().replace(/\b\w/g, (letter) => letter.toUpperCase());
}
async function pdfCover(page) {
const viewport = page.getViewport({ scale: 0.7 });
const canvas = document.createElement("canvas");
const maximum = 420;
const scale = Math.min(1, maximum / viewport.width);
const scaled = page.getViewport({ scale: 0.7 * scale });
canvas.width = Math.ceil(scaled.width);
canvas.height = Math.ceil(scaled.height);
await page.render({ canvasContext: canvas.getContext("2d"), viewport: scaled }).promise;
return new Promise((resolve) => canvas.toBlob(resolve, "image/jpeg", .78));
}
function loadPdfJs() {
if (globalThis.pdfjsLib) return Promise.resolve(globalThis.pdfjsLib);
if (pdfJsPromise) return pdfJsPromise;
pdfJsPromise = new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = "/assets/scripts/vendor/pdf.min.js";
script.async = true;
script.onload = () => globalThis.pdfjsLib ? resolve(globalThis.pdfjsLib) : reject(new Error("PDF.js loaded without exposing its API."));
script.onerror = () => reject(new Error("The local PDF.js library could not be loaded."));
document.head.appendChild(script);
});
return pdfJsPromise;
}
async function importPdf(file) {
if (!file || (file.type && file.type !== "application/pdf") || !file.name.toLowerCase().endsWith(".pdf")) {
toast("Choose a PDF file.", true); return;
}
showLoading("Reading PDF metadata…");
let attachmentId;
let thumbnailId;
try {
const pdfjs = await loadPdfJs();
pdfjs.GlobalWorkerOptions.workerSrc = "/assets/scripts/vendor/pdf.worker.min.js";
const bytes = new Uint8Array(await file.arrayBuffer());
const documentTask = pdfjs.getDocument({ data: bytes });
const pdf = await documentTask.promise;
const metadata = await pdf.getMetadata().catch(() => ({ info: {}, metadata: null }));
const info = metadata.info || {};
attachmentId = Model.id();
await putAttachment({ id: attachmentId, kind: "pdf", name: file.name, type: file.type || "application/pdf", size: file.size, blob: file, createdAt: Model.now() });
const cover = await pdfCover(await pdf.getPage(1)).catch(() => null);
if (cover) {
thumbnailId = Model.id();
await putAttachment({ id: thumbnailId, kind: "thumbnail", name: `${file.name}.jpg`, type: "image/jpeg", size: cover.size, blob: cover, createdAt: Model.now() });
}
const keywords = Array.isArray(info.Keywords) ? info.Keywords : splitList(info.Keywords || "");
const draft = {
title: String(info.Title || "").trim() || cleanPdfTitle(file.name),
creators: splitList(info.Author || ""),
type: "book", format: "pdf", status: "shelf", pageCount: pdf.numPages,
description: String(info.Subject || "").trim(), tags: keywords,
publisher: String(info.Producer || info.Creator || "").trim(),
published: String(info.CreationDate || "").replace(/^D:/, "").slice(0, 8),
notes: "", attachmentId, thumbnailId: thumbnailId || null,
};
await pdf.destroy();
hideLoading();
await openResourceDialog(null, draft);
toast("PDF metadata loaded. Review it before saving.");
} catch (error) {
hideLoading();
if (attachmentId) await deleteAttachment(attachmentId).catch(() => {});
if (thumbnailId) await deleteAttachment(thumbnailId).catch(() => {});
const reason = error && error.name === "PasswordException" ? "The PDF is password protected." : error && error.name === "QuotaExceededError" ? "Browser storage is full." : "The PDF could not be read.";
const detail = error && error.message ? ` ${error.message}` : "";
toast(`${reason}${detail} No resource was created.`, true);
console.error(error);
} finally {
els.pdfInput.value = "";
}
}
async function showStorage() {
els.storageDialog.showModal();
if (navigator.storage && navigator.storage.estimate) {
const estimate = await navigator.storage.estimate();
const used = estimate.usage || 0;
const quota = estimate.quota || 0;
const format = (bytes) => bytes < 1024 * 1024 ? `${Math.round(bytes / 1024)} KB` : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
$("#rl-storage-summary").textContent = `${format(used)} used of approximately ${format(quota)} available to this site.`;
} else $("#rl-storage-summary").textContent = "This browser does not report storage estimates.";
}
async function requestPersistence() {
if (!navigator.storage || !navigator.storage.persist) { toast("Persistent storage is not supported by this browser.", true); return; }
const granted = await navigator.storage.persist();
toast(granted ? "Persistent storage granted." : "The browser did not grant persistent storage.", !granted);
}
async function clearData() {
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) => {
const deletion = indexedDB.deleteDatabase(DB_NAME);
deletion.onsuccess = resolve; deletion.onerror = () => reject(deletion.error); deletion.onblocked = resolve;
});
localStorage.removeItem("rl-view");
location.reload();
}
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(); });
$("#rl-view-grid").addEventListener("click", () => { state.view = "grid"; localStorage.setItem("rl-view", state.view); renderResources(); });
$("#rl-add-resource").addEventListener("click", () => openResourceDialog());
$("#rl-add-folder").addEventListener("click", () => openNameDialog("add-folder", "Add folder"));
$("#rl-manage-vaults").addEventListener("click", () => { renderVaultManager(); els.vaultDialog.showModal(); });
$("#rl-add-vault").addEventListener("click", () => { closeDialog(els.vaultDialog); openNameDialog("add-vault", "Add vault", "", "#9b6b43"); });
els.resourceForm.addEventListener("submit", saveResource);
$("#rl-delete-resource").addEventListener("click", removeResource);
$("#rl-open-resource").addEventListener("click", openLinkedResource);
$("#rl-add-session").addEventListener("click", openSessionDialog);
$("#rl-field-type").addEventListener("change", updateResourceFormForType);
$("#rl-session-form").addEventListener("submit", saveSession);
$("#rl-name-form").addEventListener("submit", submitName);
$("#rl-name-delete").addEventListener("click", deleteNamedFolder);
$("#rl-import-pdf").addEventListener("click", () => els.pdfInput.click());
els.pdfInput.addEventListener("change", () => importPdf(els.pdfInput.files[0]));
$("#rl-import-json").addEventListener("click", () => els.jsonInput.click());
els.jsonInput.addEventListener("change", () => readJsonFile(els.jsonInput.files[0]));
$("#rl-export-json").addEventListener("click", exportJson);
$("#rl-import-merge").addEventListener("click", () => applyImport("merge"));
$("#rl-import-replace").addEventListener("click", () => applyImport("replace"));
$("#rl-storage-button").addEventListener("click", showStorage);
$("#rl-request-persistence").addEventListener("click", requestPersistence);
$("#rl-clear-data").addEventListener("click", clearData);
$$('[data-close-dialog]').forEach((button) => button.addEventListener("click", () => closeDialog(button.closest("dialog"))));
$$('dialog').forEach((dialog) => dialog.addEventListener("click", (event) => { if (event.target === dialog) closeDialog(dialog); }));
els.resourceDialog.addEventListener("close", cleanupPendingImport);
window.addEventListener("beforeunload", () => {
state.pdfUrls.forEach((url) => URL.revokeObjectURL(url));
state.thumbnailUrls.forEach((url) => URL.revokeObjectURL(url));
});
}
async function initialise() {
showLoading("Opening your library…");
try {
state.db = await openDatabase();
state.library = await loadLibrary();
state.vaultId = state.library.vaults[0].id;
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);
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();
}
}
initialise();
})();