import { Alpine } from "./alpine"; import { showToast } from "./toast"; async function scanAllLibraries(): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const libsResp = await fetch("/api/libraries", { headers: { Authorization: `Bearer ${token}` }, }); if (!libsResp.ok) { throw new Error("Failed to get libraries"); } const libsData = await libsResp.json(); if (!libsData.data || libsData.data.length === 0) { showToast("No libraries found. Please create a library first.", "error"); return; } const libraries = libsData.data; const jobs: string[] = []; const libraryNames: Record = {}; for (const lib of libraries) { const scanResp = await fetch(`/api/libraries/${lib.id}/scan`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ force: true }), }); if (scanResp.ok) { const result = await scanResp.json(); jobs.push(result.job_id); libraryNames[result.job_id] = lib.name; } else { console.error(`Failed to scan library: ${lib.name}`); } } if (jobs.length === 0) { showToast("Failed to start scan for any library", "error"); return; } showScanProgress(jobs, libraryNames); } catch (error) { console.error("Scan error:", error); showToast("Failed to start scan: " + (error as Error).message, "error"); } } function showScanProgress( jobIds: string[], libraryNames: Record, ): void { const container = document.getElementById( "scan-progress-container", ) as HTMLElement; const list = document.getElementById("library-progress-list") as HTMLElement; if (!container || !list) return; container.classList.remove("hidden"); container.classList.remove("opacity-0", "-translate-y-2.5"); list.innerHTML = jobIds .map( (jobId) => `
${libraryNames[jobId]} Pending...
`, ) .join(""); // The rows are TS-rendered (sanctioned scan-progress island); bind the // cancel buttons here since inline handlers can't reach module scope. list.querySelectorAll("[data-cancel-job]").forEach((btn) => { btn.addEventListener("click", () => cancelScan(btn.dataset.cancelJob || "")); }); setCancelAllVisible(true); pollScanProgress(jobIds, libraryNames); } let scanPollInterval: ReturnType | undefined = undefined; function pollScanProgress( jobIds: string[], _libraryNames: Record, ): void { const token = localStorage.getItem("token"); const startTime = Date.now(); const cancelledJobs = new Set(); scanPollInterval = setInterval(async () => { let allComplete = true; let totalProgress = 0; let progressJobs = 0; let totalFiles = 0; let totalNewItems = 0; let totalErrors = 0; for (const jobId of jobIds) { try { const resp = await fetch(`/api/scanner/status/${jobId}`, { headers: { Authorization: `Bearer ${token}` }, }); if (resp.ok) { const status = await resp.json(); updateLibraryProgress(jobId, status); totalFiles += status.files_scanned || 0; totalNewItems += status.new_items || 0; totalErrors += status.errors || 0; // Cancelled jobs are terminal and keep their last real // progress; average only jobs that can still advance so the // bar can reach 100%. if (status.status === "cancelled") { cancelledJobs.add(jobId); } else { totalProgress += status.progress || 0; progressJobs++; } if ( status.status !== "completed" && status.status !== "failed" && status.status !== "cancelled" ) { allComplete = false; } } } catch (error) { console.error(`Failed to poll job ${jobId}:`, error); } } // status.progress arrives 0-1 from the server; render as percent. const overallProgress = progressJobs > 0 ? Math.round((totalProgress / progressJobs) * 100) : 0; const progressBar = document.getElementById( "scan-progress-bar", ) as HTMLElement; const progressText = document.getElementById( "scan-progress-text", ) as HTMLElement; const statusText = document.getElementById("scan-status") as HTMLElement; if (progressBar) progressBar.style.width = overallProgress + "%"; if (progressText) progressText.textContent = overallProgress + "%"; const elapsed = Math.round((Date.now() - startTime) / 1000); if (!allComplete && statusText) { statusText.textContent = `Scanning... ${elapsed}s elapsed • ${totalFiles} files processed`; } if (allComplete) { clearInterval(scanPollInterval!); setCancelAllVisible(false); showScanResults( jobIds.length, totalFiles, totalNewItems, totalErrors, elapsed, cancelledJobs.size, ); } }, 2000); } function updateLibraryProgress(jobId: string, status: any): void { const bar = document.getElementById(`bar-${jobId}`) as HTMLElement; const statusText = document.getElementById(`status-${jobId}`) as HTMLElement; // status.progress arrives 0-1 from the server; render as percent, // otherwise the bar sits at sub-1% forever and never fills. const percent = Math.round((status.progress || 0) * 100); if (bar) { bar.style.width = percent + "%"; } if (statusText) { const statusMessages: Record = { pending: "Pending...", running: `Scanning... ${percent}%`, completed: `✓ Complete (${status.new_items || 0} items)`, failed: `✗ Failed`, cancelled: `✗ Cancelled`, }; statusText.textContent = statusMessages[status.status] || status.status; } // Terminal jobs no longer need their per-row cancel button. if ( status.status === "completed" || status.status === "failed" || status.status === "cancelled" ) { document.querySelector(`[data-cancel-job="${jobId}"]`)?.remove(); } } // Cancel one scan job (POST /api/scanner/cancel/:jobId). The server // aborts the walk at its next context check and skips the archive pass, // so cancelling never archives unvisited files. async function cancelScan(jobId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; const btn = document.querySelector( `[data-cancel-job="${jobId}"]`, ); if (btn) { btn.disabled = true; btn.textContent = "Cancelling..."; } try { const resp = await fetch(`/api/scanner/cancel/${jobId}`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || "Failed to cancel scan"); } showToast("Scan cancellation requested", "success"); } catch (e) { showToast( e instanceof Error ? e.message : "Failed to cancel scan", "error", ); if (btn) { btn.disabled = false; btn.textContent = "Cancel"; } } } // Cancel every active scan (admin): resolves the server's active job // list so it also covers jobs this page never started (e.g. started // before a refresh), then cancels each one. async function cancelAllScans(): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const resp = await fetch("/api/scanner/active", { headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) { throw new Error("Failed to list active scans"); } const data = await resp.json(); const jobs = (data.jobs || []) as Array<{ job_id: string }>; if (jobs.length === 0) return; await Promise.allSettled(jobs.map((job) => cancelScan(job.job_id))); } catch (e) { showToast( e instanceof Error ? e.message : "Failed to cancel scans", "error", ); } } function setCancelAllVisible(visible: boolean): void { const btn = document.getElementById("scan-cancel-all-btn"); if (btn) btn.classList.toggle("hidden", !visible); } // On page load: if a scan is already running (started before this page // was opened, or the page was refreshed mid-scan), surface the progress // widget and re-attach polling. Websocket start-events only reach pages // that were already open when the scan began. async function checkActiveScans(): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const resp = await fetch("/api/scanner/active", { headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) return; const data = await resp.json(); const jobs = (data.jobs || []) as Array<{ job_id: string; library_id?: string; }>; if (jobs.length === 0) return; // Label rows by library name where we can resolve one. const libraryNames: Record = {}; try { const libsResp = await fetch("/api/libraries", { headers: { Authorization: `Bearer ${token}` }, }); if (libsResp.ok) { const libsData = await libsResp.json(); for (const lib of libsData.data || []) { libraryNames[lib.id] = lib.name; } } } catch { // Names fall back to ids below. } const jobIds = jobs.map((job) => job.job_id); const names: Record = {}; for (const job of jobs) { names[job.job_id] = (job.library_id && libraryNames[job.library_id]) || `Library ${(job.library_id || job.job_id).slice(0, 8)}`; } showScanProgress(jobIds, names); pollScanProgress(jobIds, names); } catch (error) { console.error("Failed to check for active scans:", error); } } function showScanResults( libCount: number, files: number, items: number, errors: number, elapsed: number, cancelled: number, ): void { const resultsDiv = document.getElementById("scan-results") as HTMLElement; const contentDiv = document.getElementById( "scan-results-content", ) as HTMLElement; if (!resultsDiv || !contentDiv) return; contentDiv.innerHTML = `

• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned

• ${files} files processed

• ${items} new items added

${cancelled > 0 ? `

• ${cancelled} scan${cancelled === 1 ? "" : "s"} cancelled

` : ""} ${errors > 0 ? `

• ${errors} errors

` : ""}

Completed in ${elapsed} seconds

`; resultsDiv.classList.remove("hidden"); const statusText = document.getElementById("scan-status") as HTMLElement; if (statusText) { statusText.textContent = cancelled > 0 ? "Scan finished (some scans were cancelled)" : "Scan complete!"; } } function hideScanProgress(): void { const container = document.getElementById( "scan-progress-container", ) as HTMLElement; if (container) container.classList.add("hidden"); setCancelAllVisible(false); } async function loadWatchStatus(): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const response = await fetch("/api/scanner/watch/status", { headers: { Authorization: `Bearer ${token}` }, }); if (response.ok) { const data = await response.json(); const countEl = document.getElementById("watch-count"); if (countEl) { countEl.textContent = data.total_watching?.toString() || "0"; } } } catch (error) { console.error("Failed to load watch status:", error); } } function stopScanStatusPolling(): void { if (scanPollInterval !== undefined) { clearInterval(scanPollInterval); scanPollInterval = undefined; } } // Purge all archived items (files missing from disk for 2+ scans). Exposed on // window for the library admin page's inline button; reading history is // deleted with the rows, so a confirm dialog guards it. async function purgeArchivedItems(): Promise { const token = localStorage.getItem("token"); if (!token) return; if ( !window.confirm( "Permanently delete all archived items? Their reading progress, notes, and highlights will be lost.", ) ) { return; } try { const resp = await fetch("/api/media-items/purge-archived", { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || "Failed to purge archived items"); } const data = await resp.json(); showToast( `Purged ${data.purged} archived item${data.purged === 1 ? "" : "s"}`, "success", ); setTimeout(() => window.location.reload(), 700); } catch (e) { showToast( e instanceof Error ? e.message : "Failed to purge archived items", "error", ); } } (window as any).purgeArchivedItems = purgeArchivedItems; // Restore a hidden/archived item to library visibility without waiting for // its file to return (POST /api/media-items/:id/unarchive). If the file is // still gone the next scan hides it again. async function unarchiveItem(id: string): Promise { const token = localStorage.getItem("token"); if (!token) return; try { const resp = await fetch(`/api/media-items/${id}/unarchive`, { method: "POST", headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || "Failed to restore item"); } showToast("Item restored to libraries", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { showToast(e instanceof Error ? e.message : "Failed to restore item", "error"); } } // Permanently delete one archived item and its reading history // (DELETE /api/media-items/:id). async function deleteArchivedItem(id: string): Promise { const token = localStorage.getItem("token"); if (!token) return; if ( !window.confirm( "Permanently delete this item? Its reading progress, notes, and highlights will be lost.", ) ) { return; } try { const resp = await fetch(`/api/media-items/${id}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` }, }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || "Failed to delete item"); } showToast("Item deleted", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { showToast(e instanceof Error ? e.message : "Failed to delete item", "error"); } } // Merge an archived item's reading data into its active duplicate with the // same content hash (POST /api/media-items/:id/merge), then remove the // archived entry. Covers copy-then-delete-later moves between libraries. async function mergeArchivedItem(sourceId: string, targetId: string): Promise { const token = localStorage.getItem("token"); if (!token) return; if ( !window.confirm( "Merge this archived copy's reading progress, notes, and highlights into the active copy? The archived entry is removed.", ) ) { return; } try { const resp = await fetch(`/api/media-items/${sourceId}/merge`, { method: "POST", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", }, body: JSON.stringify({ target_id: targetId }), }); if (!resp.ok) { const err = await resp.json().catch(() => ({})); throw new Error(err.error || "Failed to merge item"); } showToast("Reading data merged into active copy", "success"); setTimeout(() => window.location.reload(), 500); } catch (e) { showToast(e instanceof Error ? e.message : "Failed to merge item", "error"); } } // The archived-items page renders per-row buttons with data attributes // (escaping-safe); bind them here. Module scripts run after DOM parse. document.querySelectorAll("[data-unarchive]").forEach((el) => { el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || "")); }); document.querySelectorAll("[data-delete-archived]").forEach((el) => { el.addEventListener("click", () => deleteArchivedItem(el.dataset.deleteArchived || ""), ); }); document.querySelectorAll("[data-merge-source]").forEach((el) => { el.addEventListener("click", () => mergeArchivedItem(el.dataset.mergeSource || "", el.dataset.mergeTarget || ""), ); }); export { checkActiveScans, hideScanProgress, loadWatchStatus, scanAllLibraries, stopScanStatusPolling, }; // Library admin page helpers (rendering-ownership: these orchestrate htmx // swaps and modal visibility on server-rendered DOM; they render nothing). function toggleLibraryPanel(btn: HTMLElement): void { const libId = btn.dataset.libId || ""; const panel = document.getElementById(`library-panel-${libId}`); if (!panel) return; if (panel.innerHTML.trim() !== "") { panel.innerHTML = ""; } else { window.htmx.ajax("GET", `/admin/library/${libId}/panel`, { target: `#library-panel-${libId}`, swap: "innerHTML", }); } } function openEditModal(id: string, name: string, description: string): void { const form = document.getElementById("edit-library-form"); if (!form) return; form.setAttribute("hx-put", `/admin/library/${id}`); const nameInput = document.getElementById("edit-library-name"); if (nameInput) (nameInput as HTMLInputElement).value = name; const descInput = document.getElementById("edit-library-desc"); if (descInput) (descInput as HTMLInputElement).value = description; window.htmx.process(form); document.getElementById("edit-library-modal")?.classList.remove("hidden"); } function openDeleteModal(id: string, name: string): void { const btn = document.getElementById("delete-library-confirm"); if (!btn) return; btn.setAttribute("hx-delete", `/admin/library/${id}`); btn.setAttribute("hx-target", "#libraries-container"); btn.setAttribute("hx-swap", "innerHTML"); const nameEl = document.getElementById("delete-library-name"); if (nameEl) nameEl.textContent = name; window.htmx.process(btn); document.getElementById("delete-library-modal")?.classList.remove("hidden"); } function openFolderBrowser(targetInputId: string, libraryId: string): void { const content = document.getElementById("folder-browser-content"); if (!content) return; content.setAttribute("hx-get", "/admin/library/browse"); content.setAttribute( "hx-vals", JSON.stringify({ target_input: targetInputId, library_id: libraryId }), ); content.setAttribute("hx-trigger", "load"); window.htmx.process(content); document.getElementById("folder-browser-modal")?.classList.remove("hidden"); } // Close the delete-library modal after a successful delete: the htmx swap // already removes the deleted row from the libraries list, but the modal // lives outside the swapped container and would otherwise stay open. function bindDeleteLibraryModalClose(): void { document .getElementById("delete-library-confirm") ?.addEventListener("htmx:afterRequest", function (evt) { const detail = (evt as CustomEvent).detail as { successful?: boolean }; if (detail.successful) { document .getElementById("delete-library-modal") ?.classList.add("hidden"); } }); } Alpine.data("admin", () => ({ init() { bindDeleteLibraryModalClose(); }, cancelAllScans, checkActiveScans, hideScanProgress, loadWatchStatus, scanAllLibraries, stopScanStatusPolling, openDeleteModal, openEditModal, openFolderBrowser, toggleLibraryPanel, }));