Files
org_web/assets/scripts/pages/resource-loader.js
gitea-actions 65885dbb3a
All checks were successful
Build Org Website / build (push) Successful in 38s
updates
2026-08-20 11:32:08 +01:00

788 lines
39 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 = 1;
const META_STORE = "metadata";
const ATTACHMENT_STORE = "attachments";
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(),
};
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"),
};
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(META_STORE)) db.createObjectStore(META_STORE);
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 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(); }
async function saveLibrary() {
await setMetadata("library", state.library);
}
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());
} catch (error) {
console.warn(error);
return Model.createSeed();
}
}
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);
if (!attachment || !attachment.blob) return null;
const url = URL.createObjectURL(attachment.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();
await saveLibrary();
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();
await saveLibrary();
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;
await Promise.all([deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]);
currentVault().resources = currentVault().resources.filter((item) => item.id !== resource.id);
currentVault().updatedAt = Model.now();
await saveLibrary();
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";
await saveLibrary();
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;
}
await saveLibrary();
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);
await saveLibrary();
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;
await Promise.all(vault.resources.flatMap((resource) => [deleteAttachment(resource.attachmentId), deleteAttachment(resource.thumbnailId)]));
state.library = Model.deleteVault(state.library, vault.id);
state.vaultId = state.library.vaults[0].id;
state.folderId = "all";
await saveLibrary();
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;
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();
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("Clear every Resource Loader vault, record, session, and retained PDF from this browser?")) 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() {
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();
if (!state.library.vaults.length) state.library = Model.createSeed();
state.vaultId = state.library.vaults[0].id;
await saveLibrary();
bindEvents();
render();
} 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>`;
} finally {
hideLoading();
}
}
initialise();
})();