877 lines
42 KiB
JavaScript
Executable File
877 lines
42 KiB
JavaScript
Executable File
(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 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(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(); }
|
||
|
||
function setSyncStatus(status, label) {
|
||
if (!els.syncStatus) return;
|
||
els.syncStatus.dataset.state = status;
|
||
els.syncStatus.querySelector("small").textContent = label;
|
||
}
|
||
|
||
async function fetchServerLibrary() {
|
||
const response = await fetch(API_URL, { cache: "no-store", credentials: "same-origin" });
|
||
if (response.status === 404) return null;
|
||
if (!response.ok) throw new Error(`Server library request failed (${response.status}).`);
|
||
const library = Model.normaliseLibrary(await response.json());
|
||
state.serverEtag = response.headers.get("ETag");
|
||
return library;
|
||
}
|
||
|
||
async function uploadLocalThumbnails(library) {
|
||
const ids = Array.from(new Set(library.vaults.flatMap((vault) => vault.resources.map((resource) => resource.thumbnailId).filter(Boolean))));
|
||
await Promise.all(ids.map(async (id) => {
|
||
if (state.uploadedThumbnails.has(id)) return;
|
||
const attachment = await getAttachment(id);
|
||
if (!attachment || !attachment.blob || attachment.kind !== "thumbnail") return;
|
||
const response = await fetch(`${API_URL}/thumbnails/${encodeURIComponent(id)}`, {
|
||
method: "PUT",
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": attachment.type || attachment.blob.type || "image/jpeg" },
|
||
body: attachment.blob,
|
||
});
|
||
if (!response.ok) throw new Error(`Thumbnail upload failed (${response.status}).`);
|
||
state.uploadedThumbnails.add(id);
|
||
}));
|
||
}
|
||
|
||
async function pushServerLibrary(library, allowMerge) {
|
||
setSyncStatus("syncing", "syncing…");
|
||
const headers = { "Content-Type": "application/json" };
|
||
if (state.serverEtag) headers["If-Match"] = state.serverEtag;
|
||
const response = await fetch(API_URL, {
|
||
method: "PUT",
|
||
credentials: "same-origin",
|
||
headers,
|
||
body: JSON.stringify(library),
|
||
});
|
||
if (response.status === 412 && allowMerge) {
|
||
const remote = await fetchServerLibrary();
|
||
const merged = Model.mergeForSync(remote, library);
|
||
state.library = merged;
|
||
await setMetadata("library", merged);
|
||
if (state.eventsBound) render();
|
||
toast("Changes from another device were merged.");
|
||
return pushServerLibrary(merged, false);
|
||
}
|
||
if (!response.ok) throw new Error(`Server save failed (${response.status}).`);
|
||
state.serverEtag = response.headers.get("ETag");
|
||
await uploadLocalThumbnails(library);
|
||
setSyncStatus("synced", "synced to server");
|
||
}
|
||
|
||
async function saveLibrary() {
|
||
await setMetadata("library", Model.normaliseLibrary(state.library));
|
||
state.syncQueue = state.syncQueue.catch(() => {}).then(async () => {
|
||
try {
|
||
await pushServerLibrary(Model.normaliseLibrary(state.library), true);
|
||
} catch (error) {
|
||
console.warn("Resource Loader server sync is unavailable.", error);
|
||
setSyncStatus("offline", "offline · saved here");
|
||
}
|
||
});
|
||
return state.syncQueue;
|
||
}
|
||
|
||
async function loadLibrary() {
|
||
const saved = await getMetadata("library");
|
||
try {
|
||
const remote = await fetchServerLibrary();
|
||
if (remote) {
|
||
setSyncStatus("synced", "synced to server");
|
||
await setMetadata("library", remote);
|
||
return remote;
|
||
}
|
||
} catch (error) {
|
||
console.warn("Resource Loader server is unavailable; using this browser's cache.", error);
|
||
setSyncStatus("offline", "offline · using cache");
|
||
}
|
||
if (saved) return Model.normaliseLibrary(saved);
|
||
const response = await fetch("/assets/content/resource-loader.json", { cache: "no-store" });
|
||
if (response.ok) return Model.normaliseLibrary(await response.json());
|
||
return Model.createSeed();
|
||
}
|
||
|
||
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, """).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) =>
|
||
`<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)}`, { 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();
|
||
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 this browser's offline cache and retained PDFs? Server metadata and thumbnails will remain available.")) return;
|
||
closeDialog(els.storageDialog);
|
||
state.db.close();
|
||
await new Promise((resolve, reject) => {
|
||
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));
|
||
});
|
||
window.addEventListener("online", () => saveLibrary());
|
||
}
|
||
|
||
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();
|
||
})();
|