const BOOK_STATUSES = ["reading", "shelf", "paused", "finished"]; const RESOURCE_STATUSES = ["backlog", "in_progress", "done"]; const UNFILED = ""; const THEME_KEY = "resourceLoader.theme"; const catalogEl = document.getElementById("catalog"); const statsEl = document.getElementById("stats"); const searchEl = document.getElementById("search"); const toastEl = document.getElementById("toast"); const dateEl = document.getElementById("datestamp"); const folderListEl = document.getElementById("folder-list"); const filePathEl = document.getElementById("file-path"); const vaultNameEl = document.getElementById("vault-name"); const vaultSelectEl = document.getElementById("vault-select"); const dialogEl = document.getElementById("dialog"); const dialogForm = document.getElementById("dialog-form"); const dialogTitle = document.getElementById("dialog-title"); const dialogFields = document.getElementById("dialog-fields"); let catalog = { folders: [], books: [], resources: [] }; let settings = { vaults: [], activeVaultId: "" }; let vault = null; let vaultReady = false; let activeFolder = "all"; let dialogMode = null; dateEl.textContent = formatDate(new Date()); initTheme(); init(); async function init() { if (!window.libraryAPI) { catalogEl.innerHTML = `

Desktop app required.

Run npm start from this folder, or build an .exe with npm run build.

`; return; } bind(); await applyLoadResult(await window.libraryAPI.load()); } function preferredTheme() { const saved = localStorage.getItem(THEME_KEY); if (saved === "dark" || saved === "light") return saved; return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"; } function applyTheme(theme) { document.documentElement.dataset.theme = theme; const button = document.getElementById("theme-toggle"); if (button) button.textContent = theme === "dark" ? "Light mode" : "Dark mode"; } function initTheme() { applyTheme(preferredTheme()); } function toggleTheme() { const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark"; localStorage.setItem(THEME_KEY, next); applyTheme(next); } function bind() { searchEl.addEventListener("input", render); document.querySelectorAll(".filters input").forEach((input) => { input.addEventListener("change", render); }); document.getElementById("add-book").addEventListener("click", () => openBookDialog()); document.getElementById("add-resource").addEventListener("click", () => openResourceDialog()); document.getElementById("theme-toggle").addEventListener("click", toggleTheme); document.getElementById("import-pdf").addEventListener("click", () => importPdfs()); document.getElementById("new-folder").addEventListener("click", () => openFolderDialog()); document.getElementById("open-vault").addEventListener("click", openExistingVault); document.getElementById("new-vault").addEventListener("click", openNewVaultDialog); document.getElementById("remove-vault").addEventListener("click", removeActiveVault); vaultSelectEl.addEventListener("change", async (event) => { await applyLoadResult(await window.libraryAPI.switchVault(event.target.value)); }); document.getElementById("dialog-cancel").addEventListener("click", () => dialogEl.close()); dialogForm.addEventListener("submit", onDialogSubmit); folderListEl.addEventListener("click", onFolderClick); catalogEl.addEventListener("click", onCatalogClick); catalogEl.addEventListener("change", onCatalogChange); catalogEl.addEventListener("focusout", onCatalogFocusOut); bindDrop(); } function existingPdfNames() { return catalog.books.map((book) => book.sourceName).filter(Boolean); } function currentFolderId() { return activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : ""; } async function applyLoadResult(result) { settings = result.settings || { vaults: [], activeVaultId: "" }; vault = result.vault || null; vaultReady = Boolean(result.ok); catalog = result.ok && result.catalog ? result.catalog : { folders: [], books: [], resources: [] }; activeFolder = "all"; renderVaultChrome(); setWorkspaceEnabled(vaultReady); if (!result.ok) { catalogEl.innerHTML = vaultEmptyHtml(result); statsEl.innerHTML = ""; folderListEl.innerHTML = ""; return; } render(); } function renderVaultChrome() { vaultNameEl.textContent = vault ? vault.name : "No vault"; filePathEl.textContent = vault ? vault.path : "Choose a Google Drive folder as a vault."; vaultSelectEl.innerHTML = settings.vaults.length ? settings.vaults .map( (item) => `` ) .join("") : ``; vaultSelectEl.disabled = settings.vaults.length === 0; document.getElementById("remove-vault").disabled = !vault; } function setWorkspaceEnabled(enabled) { ["import-pdf", "add-book", "add-resource", "new-folder", "search"].forEach((id) => { const el = document.getElementById(id); if (el) el.disabled = !enabled; }); } function vaultEmptyHtml(result) { if (result.reason === "missing-folder") { return `

Vault folder not found.

${escapeHtml(result.path || "")}

If this lives on Google Drive, wait until Drive finishes syncing, then Open vault again. Don’t edit the same vault on two PCs at once.

`; } if (result.reason === "unreadable") { return `

Could not read library.json.

${escapeHtml(result.error || "")}

Drive may still be syncing. Try again in a moment.

`; } return `

No vault open on this computer.

A vault is a folder (usually in Google Drive) with library.json and a pdfs folder. Use one vault for Work and another for Personal.

  1. Install Google Drive for desktop and wait for folders to sync.
  2. New vault: create Work or Personal under ResourceLoader in Drive.
  3. Open vault: point this app at that folder. Work laptop: add Work only.

Don’t edit the same vault on two PCs at once. Last save wins.

`; } async function openExistingVault() { const result = await window.libraryAPI.openVault(); if (result.cancelled) return; await applyLoadResult(result); } function openNewVaultDialog() { dialogMode = "vault-create"; dialogTitle.textContent = "New vault"; dialogFields.innerHTML = `

Next you will choose the parent folder (for example your Google Drive ResourceLoader folder).

`; dialogEl.showModal(); } async function removeActiveVault() { if (!vault) return; if (!confirm(`Remove “${vault.name}” from this PC? Files in Drive are not deleted.`)) return; await applyLoadResult(await window.libraryAPI.removeVault(vault.id)); } async function importPdfs() { const result = await window.libraryAPI.importPdfs(existingPdfNames()); await applyImportResult(result); } async function applyImportResult(result) { if (!result || result.cancelled) return; if (result.ok === false) { showToast(result.reason === "no-vault" ? "Open a vault first." : vaultMessage(result)); return; } const folderId = currentFolderId(); for (const pdf of result.imported || []) { catalog.books.push({ id: makeId(pdf.title), title: pdf.title, author: pdf.author || "", format: "pdf", status: "shelf", totalPages: Number(pdf.totalPages) || 0, currentPage: 0, folderId, tags: Array.isArray(pdf.tags) ? pdf.tags : [], notes: pdf.notes || "", pdfPath: pdf.pdfPath || "", sourceName: pdf.sourceName || "", }); } if (result.imported && result.imported.length) { await persist(); render(); } const parts = []; if (result.imported?.length) parts.push(`Imported ${result.imported.length} PDF${result.imported.length === 1 ? "" : "s"}.`); if (result.skipped?.length) parts.push(`Skipped ${result.skipped.length}.`); if (result.errors?.length) parts.push(`Failed ${result.errors.length}.`); if (parts.length) showToast(parts.join(" ")); } function bindDrop() { const overlay = document.getElementById("drop-overlay"); let dragDepth = 0; window.addEventListener("dragenter", (event) => { event.preventDefault(); dragDepth += 1; overlay.hidden = false; }); window.addEventListener("dragover", (event) => { event.preventDefault(); overlay.hidden = false; }); window.addEventListener("dragleave", (event) => { event.preventDefault(); dragDepth = Math.max(0, dragDepth - 1); if (dragDepth === 0) overlay.hidden = true; }); window.addEventListener("drop", async (event) => { event.preventDefault(); dragDepth = 0; overlay.hidden = true; const files = [...(event.dataTransfer?.files || [])]; const paths = files .map((file) => { try { return window.libraryAPI.pathForFile(file); } catch { return ""; } }) .filter((filePath) => filePath && filePath.toLowerCase().endsWith(".pdf")); if (!paths.length) { showToast("Drop one or more PDF files."); return; } const result = await window.libraryAPI.importPdfPaths(paths, existingPdfNames()); await applyImportResult(result); }); } function vaultMessage(result) { if (result.reason === "missing-folder") return "Vault folder not found. Wait for Drive to sync."; if (result.reason === "unreadable") return "Could not read library.json."; if (result.reason === "no-vault") return "Open a vault first."; return result.error || "Vault is not ready."; } async function persist() { if (!vaultReady) return; const result = await window.libraryAPI.save(catalog); if (!result.ok) { showToast(vaultMessage(result)); return; } catalog = result.catalog; } function inFolder(item) { if (activeFolder === "all") return true; if (activeFolder === "unfiled") return !item.folderId; return item.folderId === activeFolder; } function itemCount(folderId) { const match = (item) => folderId === "unfiled" ? !item.folderId : item.folderId === folderId; return catalog.books.filter(match).length + catalog.resources.filter(match).length; } function folderOptions(selected) { const rows = [``]; for (const folder of sortedFolders()) { const prefix = folder.parentId ? "— " : ""; rows.push( `` ); } return rows.join(""); } function sortedFolders() { const roots = catalog.folders.filter((f) => !f.parentId).sort(byName); const children = catalog.folders.filter((f) => f.parentId); const ordered = []; for (const root of roots) { ordered.push(root); ordered.push(...children.filter((c) => c.parentId === root.id).sort(byName)); } const hanging = children.filter((c) => !ordered.includes(c)).sort(byName); return [...ordered, ...hanging]; } function byName(a, b) { return a.name.localeCompare(b.name); } function getFilters() { const checked = (name) => [...document.querySelectorAll(`input[name="${name}"]:checked`)].map((el) => el.value); return { query: searchEl.value.trim().toLowerCase(), formats: checked("format"), kinds: checked("kind"), statuses: checked("status"), }; } function matches(item, filters) { if (!inFolder(item)) return false; if (filters.statuses.length && !filters.statuses.includes(item.status)) return false; if (item.type === "book") { if (filters.formats.length && !filters.formats.includes(item.format)) return false; if (filters.kinds.length && !filters.formats.length) return false; } else { if (filters.kinds.length && !filters.kinds.includes(item.kind)) return false; if (filters.formats.length && !filters.kinds.length) return false; } if (!filters.query) return true; const hay = [ item.title, item.author, item.source, item.format, item.kind, ...(item.tags || []), ] .filter(Boolean) .join(" ") .toLowerCase(); return hay.includes(filters.query); } function tagged(list, type) { return list.map((item) => ({ ...item, type })); } function render() { renderFolders(); const books = tagged(catalog.books, "book"); const resources = tagged(catalog.resources, "resource"); const filters = getFilters(); const visibleBooks = books.filter((b) => matches(b, filters)); const visibleResources = resources.filter((r) => matches(r, filters)); renderStats(books.filter(inFolder), resources.filter(inFolder)); const nowReading = [ ...visibleBooks.filter((b) => b.status === "reading"), ...visibleResources.filter((r) => r.status === "in_progress"), ]; const shelf = [ ...visibleBooks.filter((b) => b.status === "shelf" || b.status === "paused"), ...visibleResources.filter((r) => r.status === "backlog"), ]; const finished = [ ...visibleBooks.filter((b) => b.status === "finished"), ...visibleResources.filter((r) => r.status === "done"), ]; catalogEl.innerHTML = [ section("Now reading", nowReading), section("On the shelf", shelf), section("Finished", finished), ].join(""); } function renderFolders() { const allCount = catalog.books.length + catalog.resources.length; const items = [ folderButton("all", "All items", allCount, false), folderButton("unfiled", "Unfiled", itemCount("unfiled"), false), ...sortedFolders().map((folder) => folderButton(folder.id, folder.name, itemCount(folder.id), Boolean(folder.parentId), folder.id) ), ]; folderListEl.innerHTML = items.join(""); } function folderButton(id, name, count, nested, folderId) { const active = activeFolder === id ? " active" : ""; const nest = nested ? " nested" : ""; const manage = folderId ? ` ` : ""; return `
${manage}
`; } function renderStats(books, resources) { const reading = books.filter((b) => b.status === "reading").length; const shelf = books.filter((b) => b.status === "shelf" || b.status === "paused").length; const pages = books.reduce((sum, b) => sum + (Number(b.currentPage) || 0), 0); const backlog = resources.filter((r) => r.status === "backlog").length; statsEl.innerHTML = [ stat(reading, "Now reading"), stat(shelf, "On the shelf"), stat(pages, "Pages logged"), stat(backlog, "Resource backlog"), ].join(""); } function stat(value, label) { return `
${escapeHtml(String(value))}${escapeHtml(label)}
`; } function section(title, items) { const body = items.length === 0 ? `

— none —

` : `
${items.map(card).join("")}
`; return `

${escapeHtml(title)}

${body}
`; } function card(item) { return item.type === "book" ? bookCard(item) : resourceCard(item); } function bookCard(book) { const total = Number(book.totalPages) || 0; const page = Number(book.currentPage) || 0; const pct = total ? Math.round((page / total) * 100) : 0; return `

${escapeHtml(book.title)}

${escapeHtml(book.author || "Unknown author")}

${tagsHtml(book.tags)}
${escapeHtml(book.format || "book")} ${escapeHtml(labelStatus(book.status))}
/ ${total}
${statusField(book.status, BOOK_STATUSES)} ${folderField(book.folderId)} ${notesField(book.notes)}
${pct}% of ${total} pp. ${book.pdfPath ? `` : ""}
`; } function resourceCard(item) { const url = item.url || ""; return `

${escapeHtml(item.title)}

${escapeHtml(item.source || "Unknown source")}

${tagsHtml(item.tags)}
${escapeHtml(item.kind || "resource")} ${escapeHtml(labelStatus(item.status))}
${statusField(item.status, RESOURCE_STATUSES)} ${folderField(item.folderId)} ${notesField(item.notes)}
${ url ? `Open ${escapeHtml(item.kind || "link")}` : `No URL` }
`; } function statusField(status, options) { const opts = options .map( (value) => `` ) .join(""); return ``; } function folderField(folderId) { return ``; } function notesField(notes) { return ``; } function tagsHtml(tags) { if (!tags || !tags.length) return ""; return `

${tags.map((t) => `#${escapeHtml(t)}`).join(" ")}

`; } function labelStatus(status) { return String(status || "").replaceAll("_", " "); } function onFolderClick(event) { const manage = event.target.closest("[data-folder-action]"); if (manage) { const id = manage.dataset.id; if (manage.dataset.folderAction === "rename") openFolderDialog(id); if (manage.dataset.folderAction === "delete") deleteFolder(id); return; } const button = event.target.closest("[data-folder]"); if (!button) return; activeFolder = button.dataset.folder; render(); } async function deleteFolder(id) { const folder = catalog.folders.find((f) => f.id === id); if (!folder) return; if (!confirm(`Delete folder “${folder.name}”? Items inside move to Unfiled.`)) return; catalog.folders = catalog.folders.filter((f) => f.id !== id && f.parentId !== id); for (const book of catalog.books) { if (book.folderId === id) book.folderId = ""; } for (const resource of catalog.resources) { if (resource.folderId === id) resource.folderId = ""; } if (activeFolder === id) activeFolder = "all"; await persist(); render(); showToast(`Deleted folder ${folder.name}.`); } async function onCatalogClick(event) { const button = event.target.closest("button[data-action]"); if (!button) return; const cardEl = button.closest(".card"); if (!cardEl) return; const id = cardEl.dataset.id; const kind = cardEl.dataset.type; if (button.dataset.action === "delete") { if (!confirm("Delete this item?")) return; if (kind === "book") catalog.books = catalog.books.filter((b) => b.id !== id); else catalog.resources = catalog.resources.filter((r) => r.id !== id); await persist(); render(); showToast("Deleted."); return; } if (button.dataset.action === "open-pdf") { const book = catalog.books.find((b) => b.id === id); if (!book?.pdfPath) return; const opened = await window.libraryAPI.openPdf(book.pdfPath); if (!opened.ok) showToast(opened.error || "Could not open PDF."); return; } if (button.dataset.action === "page") { const book = catalog.books.find((b) => b.id === id); if (!book) return; const next = clamp( (Number(book.currentPage) || 0) + Number(button.dataset.delta), 0, Number(book.totalPages) || 0 ); book.currentPage = next; await persist(); render(); } } async function onCatalogChange(event) { const field = event.target.dataset.field; const cardEl = event.target.closest(".card"); if (!field || !cardEl || field === "notes") return; const item = findItem(cardEl.dataset.id, cardEl.dataset.type); if (!item) return; if (field === "status") item.status = event.target.value; if (field === "folderId") item.folderId = event.target.value; if (field === "currentPage") { const total = Number(item.totalPages) || 0; item.currentPage = clamp(Number(event.target.value) || 0, 0, total); } await persist(); render(); } async function onCatalogFocusOut(event) { if (event.target.dataset.field !== "notes") return; const cardEl = event.target.closest(".card"); if (!cardEl) return; const item = findItem(cardEl.dataset.id, cardEl.dataset.type); if (!item || item.notes === event.target.value) return; item.notes = event.target.value; await persist(); } function findItem(id, type) { return type === "book" ? catalog.books.find((b) => b.id === id) : catalog.resources.find((r) => r.id === id); } function openBookDialog() { dialogMode = "book"; dialogTitle.textContent = "Add book"; const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : ""; dialogFields.innerHTML = ` `; dialogEl.showModal(); } function openResourceDialog() { dialogMode = "resource"; dialogTitle.textContent = "Add resource"; const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : ""; dialogFields.innerHTML = ` `; dialogEl.showModal(); } function openFolderDialog(id) { const existing = catalog.folders.find((f) => f.id === id); dialogMode = existing ? "folder-edit" : "folder"; dialogTitle.textContent = existing ? "Rename folder" : "New folder"; const parentPrefill = !existing && activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : ""; dialogFields.innerHTML = ` ${ existing ? "" : `` } `; dialogEl.showModal(); } function options(values, selected) { return values .map( (value) => `` ) .join(""); } async function onDialogSubmit(event) { event.preventDefault(); const data = Object.fromEntries(new FormData(dialogForm)); if (dialogMode === "book") { catalog.books.push({ id: makeId(data.title), title: data.title.trim(), author: (data.author || "").trim(), format: data.format === "pdf" ? "pdf" : "physical", status: BOOK_STATUSES.includes(data.status) ? data.status : "shelf", totalPages: Math.max(0, Number(data.totalPages) || 0), currentPage: clamp(Number(data.currentPage) || 0, 0, Math.max(0, Number(data.totalPages) || 0)), folderId: data.folderId || "", tags: parseTags(data.tags), notes: data.notes || "", }); showToast("Book added."); } else if (dialogMode === "resource") { catalog.resources.push({ id: makeId(data.title), title: data.title.trim(), kind: data.kind === "video" ? "video" : "article", url: (data.url || "").trim(), source: (data.source || "").trim(), status: RESOURCE_STATUSES.includes(data.status) ? data.status : "backlog", folderId: data.folderId || "", tags: parseTags(data.tags), notes: data.notes || "", }); showToast("Resource added."); } else if (dialogMode === "folder") { catalog.folders.push({ id: makeId(data.name), name: data.name.trim(), parentId: data.parentId || "", }); showToast("Folder created."); } else if (dialogMode === "folder-edit") { const folder = catalog.folders.find((f) => f.id === data.id); if (folder) folder.name = data.name.trim(); showToast("Folder renamed."); } else if (dialogMode === "vault-create") { dialogEl.close(); const result = await window.libraryAPI.createVault(data.name.trim()); if (result.cancelled) return; await applyLoadResult(result); showToast(result.ok ? "Vault created." : vaultMessage(result)); return; } dialogEl.close(); await persist(); render(); } function parseTags(value) { return String(value || "") .split(",") .map((t) => t.trim()) .filter(Boolean); } function makeId(title) { const slug = String(title || "item") .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/^-|-$/g, "") .slice(0, 40); return `${slug || "item"}-${Date.now().toString(36)}`; } function showToast(message) { toastEl.hidden = false; toastEl.textContent = message; } function formatDate(date) { return date .toLocaleDateString("en-GB", { day: "2-digit", month: "short", year: "numeric", }) .toUpperCase(); } function clamp(n, min, max) { return Math.min(max, Math.max(min, n)); } function escapeHtml(value) { return String(value) .replaceAll("&", "&") .replaceAll("<", "<") .replaceAll(">", ">") .replaceAll('"', """); } function escapeAttr(value) { return escapeHtml(value); }