(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, "&").replace(//g, ">") .replace(/"/g, """).replace(/'/g, "'"); } 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) => ``).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 `
${editable ? `` : ""}
`; } 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) + '' + 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 = '' + tags.map((tag) => ``).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 `
${escapeHtml(initial)} ${escapeHtml(resource.title)}${escapeHtml(creators)}${escapeHtml((resource.tags || []).slice(0, 3).join(" · "))} ${escapeHtml(resource.type)}${escapeHtml(resource.status)}${escapeHtml(resource.format)} ${progress.percent}% complete
`; } function statusGroupMarkup(group, resources) { const headingId = `rl-status-${group.id}`; const countLabel = `${resources.length} ${resources.length === 1 ? "resource" : "resources"}`; return `
${escapeHtml(group.label)}${escapeHtml(group.description)} ${resources.length}
${resources.map(cardMarkup).join("")}
`; } 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 = ``; })); } 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 = `

${filtered ? "Nothing matches this view" : "Your shelf is ready"}

${filtered ? "Try changing the folder, filters, or search." : "Add a physical book, save a link, or import a PDF to begin."}

`; 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 = '' + currentVault().folders.slice().sort((a, b) => a.name.localeCompare(b.name)).map((folder) => ``).join(""); field.value = selected || ""; } function resetCover() { $("#rl-cover-preview").innerHTML = "Cover"; } 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 = `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) => `
${escapeHtml(session.start)}–${escapeHtml(session.end)} ${escapeHtml(session.unit)} ${escapeHtml(session.note || "")}
`).join("") : '

No sessions logged yet.

'; $("#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) => `
${escapeHtml(vault.name)}
`).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 = `
${vaults}vaults
${folders}folders
${resources}resources
`; 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 = `
!

Server library is unavailable

${escapeHtml(error.message || "The shared Resource Loader library could not be loaded.")}

`; } finally { hideLoading(); } } initialise(); })();