import { Alpine } from "./alpine"; import { showToast } from "./toast"; import { getToken } from "./storage"; let selectedMediaItem: string | null = null; function searchMatches( progressId: string, sha256: string, title: string, ): void { const container = document.getElementById(`matches-${progressId}`); const matchesList = document.getElementById(`matches-list-${progressId}`); if (!container || !matchesList) return; container.classList.remove("hidden"); matchesList.innerHTML = '

Searching...

'; const token = getToken(); const url = sha256 ? `/api/books/match?sha256=${sha256}` : `/api/books/match?title=${encodeURIComponent(title)}`; fetch(url, { headers: { Authorization: `Bearer ${token}`, }, }) .then((response) => response.json()) .then((result) => { if (result.matches && result.matches.length > 0) { matchesList.innerHTML = result.matches .map( (match: any) => `
Cover
${match.title}

by ${match.author || "Unknown"}

${Math.round(match.confidence * 100)}% confidence ${match.match_method}
`, ) .join(""); } else { matchesList.innerHTML = '

No matches found. Try manual linking.

'; } }) .catch((error) => { console.error("Failed to search", error); matchesList.innerHTML = '

Failed to search

'; }); } function autoLinkBook( progressId: string, mediaItemId: string, confidence: number, ): void { if ( !confirm( "Link this book? The confidence score is " + Math.round(confidence * 100) + "%", ) ) { return; } const progressElement = document.getElementById(`matches-${progressId}`); const codeElement = progressElement?.querySelector("code"); const sha256Element = progressElement?.querySelector('[title="SHA-256"]'); const data = { device_file: { file_path: codeElement?.textContent || "", sha256: sha256Element?.textContent || "", }, media_item_id: mediaItemId, confidence_score: confidence, }; const token = getToken(); fetch(`/api/devices/sync/link-book`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }) .then((response) => response.json()) .then(() => { showToast("Book linked successfully", "success"); window.location.reload(); }) .catch((error) => { console.error("Failed to link book", error); showToast("Failed to link book", "error"); }); } function showManualLinkModal(progressId: string, bookTitle: string): void { const modal = document.getElementById("manual-link-modal"); const progressIdInput = document.getElementById( "link-progress-id", ) as HTMLInputElement; const bookTitleInput = document.getElementById( "link-book-title", ) as HTMLInputElement; const searchResults = document.getElementById("link-search-results"); if (modal) modal.classList.remove("hidden"); if (progressIdInput) progressIdInput.value = progressId; if (bookTitleInput) bookTitleInput.value = bookTitle; if (searchResults) searchResults.innerHTML = '

Search for books to link

'; selectedMediaItem = null; } function hideManualLinkModal(): void { const modal = document.getElementById("manual-link-modal"); const searchInput = document.getElementById( "link-search-input", ) as HTMLInputElement; if (modal) modal.classList.add("hidden"); if (searchInput) searchInput.value = ""; selectedMediaItem = null; } function searchBooksForLink(): void { const searchInput = document.getElementById( "link-search-input", ) as HTMLInputElement; const resultsContainer = document.getElementById("link-search-results"); if (!searchInput || !resultsContainer) return; const searchTerm = searchInput.value; if (searchTerm.length < 2) { resultsContainer.innerHTML = '

Enter at least 2 characters

'; return; } resultsContainer.innerHTML = '

Searching...

'; const token = getToken(); fetch(`/api/books/match?title=${encodeURIComponent(searchTerm)}`, { headers: { Authorization: `Bearer ${token}`, }, }) .then((response) => response.json()) .then((result) => { if (result.matches && result.matches.length > 0) { resultsContainer.innerHTML = result.matches .map( (match: any) => `
Cover
${match.title}

by ${match.author || "Unknown"}

${Math.round(match.confidence * 100)}% confidence

`, ) .join(""); } else { resultsContainer.innerHTML = '

No matches found

'; } }) .catch((error) => { console.error("Failed to search", error); resultsContainer.innerHTML = '

Failed to search

'; }); } function selectBookForLink(mediaItemId: string, _coverPath: string): void { selectedMediaItem = mediaItemId; const resultsContainer = document.getElementById("link-search-results"); if (!resultsContainer) return; const cards = resultsContainer.querySelectorAll(".card"); cards.forEach((card) => { card.classList.remove("border-2", "border-blue-500"); if ((card as HTMLElement).dataset.mediaItemId === mediaItemId) { card.classList.add("border-2", "border-blue-500"); } }); } function confirmManualLink(): void { if (!selectedMediaItem) { showToast("Please select a book to link", "error"); return; } const progressIdInput = document.getElementById( "link-progress-id", ) as HTMLInputElement; const confidenceInput = document.getElementById( "link-confidence", ) as HTMLInputElement; const bookTitleInput = document.getElementById( "link-book-title", ) as HTMLInputElement; const sha256Input = document.getElementById( "link-book-sha256", ) as HTMLInputElement; if (!progressIdInput) return; const confidence = parseFloat(confidenceInput?.value || "0"); const bookTitle = bookTitleInput?.value || ""; const sha256 = sha256Input?.value || ""; const data = { device_file: { file_path: bookTitle, sha256: sha256, }, media_item_id: selectedMediaItem, confidence_score: confidence, }; const token = getToken(); fetch(`/api/devices/sync/link-book`, { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify(data), }) .then((response) => response.json()) .then(() => { showToast("Book linked successfully", "success"); hideManualLinkModal(); window.location.reload(); }) .catch((error) => { console.error("Failed to link book", error); showToast("Failed to link book", "error"); }); } function toggleAllUnlinked(): void { const selectAll = document.getElementById( "select-all-unlinked", ) as HTMLInputElement; if (!selectAll) return; document.querySelectorAll(".unlinked-checkbox").forEach((cb) => { (cb as HTMLInputElement).checked = selectAll.checked; }); updateSelectedCount(); } function getSelectedUnlinked(): { progressId: string; title: string }[] { return Array.from( document.querySelectorAll(".unlinked-checkbox:checked"), ).map((cb) => ({ progressId: cb.getAttribute("data-progress-id") || "", title: cb.getAttribute("data-title") || "", })); } function updateSelectedCount(): void { const count = document.querySelectorAll(".unlinked-checkbox:checked").length; const countElement = document.getElementById("selected-count"); if (countElement) { countElement.textContent = `${count} selected`; } } async function bulkAutoLink(): Promise { const selected = getSelectedUnlinked(); if (selected.length === 0) { showToast("Please select at least one book", "error"); return; } if ( !confirm( `Auto-link ${selected.length} books with high confidence matches (≥80%)?`, ) ) { return; } const token = getToken(); try { const response = await fetch("/sync/auto-link-books", { method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }, body: JSON.stringify({ confidence_threshold: 0.8, limit: selected.length, }), }); const result = await response.json(); showToast( `Auto-linked ${result.auto_linked} books successfully`, "success", ); setTimeout(() => window.location.reload(), 1500); } catch (error) { console.error("Auto-link failed", error); showToast(`Auto-link failed: ${(error as Error).message}`, "error"); } } async function bulkGetSuggestions(): Promise { const selected = getSelectedUnlinked(); if (selected.length === 0) { showToast("Please select at least one book", "error"); return; } const token = getToken(); for (const book of selected) { try { const response = await fetch( `/sync/unlinked-books/${book.progressId}/suggestions`, { headers: { Authorization: `Bearer ${token}`, }, }, ); const result = await response.json(); displaySuggestions(book.progressId, result.suggestions, result.action); } catch (error) { console.error("Failed to get suggestions:", error); } } } function displaySuggestions( progressId: string, suggestions: any[], _action: string, ): void { const container = document.getElementById(`matches-${progressId}`); if (!container) return; container.classList.remove("hidden"); const listContainer = container.querySelector(".matches-list"); if (!listContainer) return; listContainer.innerHTML = ""; if (suggestions.length === 0) { listContainer.innerHTML = '

No matches found

'; return; } suggestions.forEach((match) => { const div = document.createElement("div"); div.className = "p-3 border rounded cursor-pointer hover:bg-opacity-80 transition-colors"; div.style.cssText = `background-color: var(--bg-primary); border-color: var(--border);`; div.innerHTML = `

${match.title}

Author: ${match.author || "Unknown"}

${(match.confidence * 100).toFixed(0)}% confidence
${match.match_method}
`; (div as HTMLElement).dataset.action = "select-match"; (div as HTMLElement).dataset.progressId = progressId; (div as HTMLElement).dataset.mediaItemId = match.media_item_id; (div as HTMLElement).dataset.confidence = String(match.confidence); listContainer.appendChild(div); }); } function showBulkManualLink(): void { const selected = getSelectedUnlinked(); if (selected.length === 0) { showToast("Please select at least one book", "error"); return; } showToast( `Bulk manual link for ${selected.length} books - select target book in library`, "info", ); window.location.href = "/library?mode=link&unlinked=" + selected.map((s) => s.progressId).join(","); } function setupEventDelegation(): void { document.addEventListener("click", (e) => { const target = e.target as HTMLElement; const card = target.closest("[data-action]") as HTMLElement; if (!card) return; const action = card.dataset.action; if (action === "auto-link") { autoLinkBook( card.dataset.progressId || "", card.dataset.mediaItemId || "", parseFloat(card.dataset.confidence || "0"), ); } else if (action === "select-book") { selectBookForLink( card.dataset.mediaItemId || "", card.dataset.cover || "", ); } else if (action === "select-match") { autoLinkBook( card.dataset.progressId || "", card.dataset.mediaItemId || "", parseFloat(card.dataset.confidence || "0"), ); } }); document.addEventListener("change", (e) => { const target = e.target as HTMLElement; if (target.classList.contains("unlinked-checkbox")) { updateSelectedCount(); } }); } export { bulkAutoLink, bulkGetSuggestions, confirmManualLink, displaySuggestions, hideManualLinkModal, searchBooksForLink, searchMatches, selectBookForLink, setupEventDelegation, showBulkManualLink, showManualLinkModal, toggleAllUnlinked, }; Alpine.data("unlinkedBooks", () => ({ bulkAutoLink, bulkGetSuggestions, confirmManualLink, displaySuggestions, hideManualLinkModal, searchBooksForLink, searchMatches, selectBookForLink, setupEventDelegation, showBulkManualLink, showManualLinkModal, toggleAllUnlinked, }));