Files
ResourceLoader/js/app.js
Zaine Qayyum 9e075ec941 Testing
2026-08-19 09:56:06 +01:00

845 lines
29 KiB
JavaScript
Raw Permalink 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.
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 = `
<div class="error">
<p><strong>Desktop app required.</strong></p>
<p>Run <code>npm start</code> from this folder, or build an .exe with <code>npm run build</code>.</p>
</div>`;
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) =>
`<option value="${escapeAttr(item.id)}" ${item.id === settings.activeVaultId ? "selected" : ""}>${escapeHtml(item.name)}</option>`
)
.join("")
: `<option value="">No vaults on this PC</option>`;
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 `
<div class="error empty-vault">
<p><strong>Vault folder not found.</strong></p>
<p>${escapeHtml(result.path || "")}</p>
<p>If this lives on Google Drive, wait until Drive finishes syncing, then Open vault again. Dont edit the same vault on two PCs at once.</p>
</div>`;
}
if (result.reason === "unreadable") {
return `
<div class="error empty-vault">
<p><strong>Could not read library.json.</strong></p>
<p>${escapeHtml(result.error || "")}</p>
<p>Drive may still be syncing. Try again in a moment.</p>
</div>`;
}
return `
<div class="empty-vault">
<p><strong>No vault open on this computer.</strong></p>
<p>A vault is a folder (usually in Google Drive) with <code>library.json</code> and a <code>pdfs</code> folder. Use one vault for Work and another for Personal.</p>
<ol>
<li>Install Google Drive for desktop and wait for folders to sync.</li>
<li>New vault: create Work or Personal under <code>ResourceLoader</code> in Drive.</li>
<li>Open vault: point this app at that folder. Work laptop: add Work only.</li>
</ol>
<p>Dont edit the same vault on two PCs at once. Last save wins.</p>
</div>`;
}
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 = `
<label class="field wide"><span>Name</span><input name="name" required placeholder="Work or Personal" /></label>
<p class="muted">Next you will choose the parent folder (for example your Google Drive ResourceLoader folder).</p>
`;
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 = [`<option value="${UNFILED}" ${!selected ? "selected" : ""}>Unfiled</option>`];
for (const folder of sortedFolders()) {
const prefix = folder.parentId ? "— " : "";
rows.push(
`<option value="${escapeAttr(folder.id)}" ${folder.id === selected ? "selected" : ""}>${prefix}${escapeHtml(folder.name)}</option>`
);
}
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
? `<button type="button" data-folder-action="rename" data-id="${escapeAttr(folderId)}" title="Rename">✎</button>
<button type="button" data-folder-action="delete" data-id="${escapeAttr(folderId)}" title="Delete">×</button>`
: "";
return `
<div class="folder-row">
<button type="button" class="folder-item${active}${nest}" data-folder="${escapeAttr(id)}">
<span>${escapeHtml(name)}</span>
<span class="count">${count}</span>
</button>
${manage}
</div>`;
}
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 `<div class="stat"><span class="value">${escapeHtml(String(value))}</span><span class="label">${escapeHtml(label)}</span></div>`;
}
function section(title, items) {
const body =
items.length === 0
? `<p class="empty">— none —</p>`
: `<div class="cards">${items.map(card).join("")}</div>`;
return `<section class="section"><h2>${escapeHtml(title)}</h2>${body}</section>`;
}
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 `
<article class="card" data-id="${escapeAttr(book.id)}" data-type="book">
<div class="card-head">
<div>
<h3>${escapeHtml(book.title)}</h3>
<p class="meta">${escapeHtml(book.author || "Unknown author")}</p>
${tagsHtml(book.tags)}
</div>
<div class="stamps">
<span class="stamp">${escapeHtml(book.format || "book")}</span>
<span class="stamp filled">${escapeHtml(labelStatus(book.status))}</span>
</div>
</div>
<div class="progress-row">
<button type="button" data-action="page" data-delta="-1" aria-label="Previous page"></button>
<input type="number" min="0" max="${total}" value="${page}" data-field="currentPage" aria-label="Current page" />
<span class="of">/ ${total}</span>
<button type="button" data-action="page" data-delta="1" aria-label="Next page">+</button>
</div>
<div class="bar" aria-hidden="true"><span style="width:${pct}%"></span></div>
<div class="card-controls">
${statusField(book.status, BOOK_STATUSES)}
${folderField(book.folderId)}
${notesField(book.notes)}
</div>
<div class="card-foot">
<span>${pct}% of ${total} pp.</span>
<span class="card-foot-actions">
${book.pdfPath ? `<button type="button" data-action="open-pdf">Open PDF</button>` : ""}
<button type="button" data-action="delete">Delete</button>
</span>
</div>
</article>`;
}
function resourceCard(item) {
const url = item.url || "";
return `
<article class="card" data-id="${escapeAttr(item.id)}" data-type="resource">
<div class="card-head">
<div>
<h3>${escapeHtml(item.title)}</h3>
<p class="meta">${escapeHtml(item.source || "Unknown source")}</p>
${tagsHtml(item.tags)}
</div>
<div class="stamps">
<span class="stamp">${escapeHtml(item.kind || "resource")}</span>
<span class="stamp filled">${escapeHtml(labelStatus(item.status))}</span>
</div>
</div>
<div class="card-controls">
${statusField(item.status, RESOURCE_STATUSES)}
${folderField(item.folderId)}
${notesField(item.notes)}
</div>
<div class="card-foot">
${
url
? `<a href="${escapeAttr(url)}" target="_blank" rel="noopener noreferrer">Open ${escapeHtml(item.kind || "link")}</a>`
: `<span class="muted">No URL</span>`
}
<button type="button" data-action="delete">Delete</button>
</div>
</article>`;
}
function statusField(status, options) {
const opts = options
.map(
(value) =>
`<option value="${escapeAttr(value)}" ${value === status ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
)
.join("");
return `<label class="field"><span>Status</span><select data-field="status">${opts}</select></label>`;
}
function folderField(folderId) {
return `<label class="field"><span>Folder</span><select data-field="folderId">${folderOptions(folderId || "")}</select></label>`;
}
function notesField(notes) {
return `<label class="field wide"><span>Notes</span><textarea data-field="notes" rows="2">${escapeHtml(notes || "")}</textarea></label>`;
}
function tagsHtml(tags) {
if (!tags || !tags.length) return "";
return `<p class="tags">${tags.map((t) => `#${escapeHtml(t)}`).join(" ")}</p>`;
}
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 = `
<label class="field wide"><span>Title</span><input name="title" required /></label>
<label class="field"><span>Author</span><input name="author" /></label>
<label class="field"><span>Format</span>
<select name="format">
<option value="physical">Physical</option>
<option value="pdf">PDF</option>
</select>
</label>
<label class="field"><span>Status</span>
<select name="status">${options(BOOK_STATUSES, "shelf")}</select>
</label>
<label class="field"><span>Total pages</span><input name="totalPages" type="number" min="0" value="0" /></label>
<label class="field"><span>Current page</span><input name="currentPage" type="number" min="0" value="0" /></label>
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
<label class="field"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
`;
dialogEl.showModal();
}
function openResourceDialog() {
dialogMode = "resource";
dialogTitle.textContent = "Add resource";
const folderPrefill = activeFolder !== "all" && activeFolder !== "unfiled" ? activeFolder : "";
dialogFields.innerHTML = `
<label class="field wide"><span>Title</span><input name="title" required /></label>
<label class="field"><span>Kind</span>
<select name="kind">
<option value="article">Article</option>
<option value="video">Video</option>
</select>
</label>
<label class="field"><span>Status</span>
<select name="status">${options(RESOURCE_STATUSES, "backlog")}</select>
</label>
<label class="field wide"><span>URL</span><input name="url" type="url" placeholder="https://" /></label>
<label class="field"><span>Source</span><input name="source" placeholder="site or channel" /></label>
<label class="field"><span>Folder</span><select name="folderId">${folderOptions(folderPrefill)}</select></label>
<label class="field wide"><span>Tags</span><input name="tags" placeholder="comma separated" /></label>
<label class="field wide"><span>Notes</span><textarea name="notes" rows="3"></textarea></label>
`;
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 = `
<input type="hidden" name="id" value="${escapeAttr(existing ? existing.id : "")}" />
<label class="field wide"><span>Name</span><input name="name" required value="${escapeAttr(existing ? existing.name : "")}" /></label>
${
existing
? ""
: `<label class="field wide"><span>Inside</span>
<select name="parentId">
<option value="">Top level</option>
${catalog.folders
.filter((f) => !f.parentId)
.sort(byName)
.map(
(f) =>
`<option value="${escapeAttr(f.id)}" ${f.id === parentPrefill ? "selected" : ""}>${escapeHtml(f.name)}</option>`
)
.join("")}
</select>
</label>`
}
`;
dialogEl.showModal();
}
function options(values, selected) {
return values
.map(
(value) =>
`<option value="${escapeAttr(value)}" ${value === selected ? "selected" : ""}>${escapeHtml(labelStatus(value))}</option>`
)
.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("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
function escapeAttr(value) {
return escapeHtml(value);
}