- BookMatch now carries title/author/cover (the fields the old client renderer always assumed but the API never sent — cards would have shown 'undefined'); filled during the family-lens pass in QueryBooks - new partial route GET /unlinked-books/matches renders the scoped match cascade server-side; searchMatches swaps it via htmx.ajax instead of rebuilding cards from JSON - NOTE: the unlinked-books surface is currently dark end-to-end — the templ page has no route, the live linking.ts renderer targets a container no template has, and its Link button calls a window.showMatchModal that does not exist. This commit ships the scoped backend + partial; wiring the UI is a product decision.
473 lines
14 KiB
TypeScript
473 lines
14 KiB
TypeScript
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 =
|
|
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
|
|
|
// Rendering ownership: results are server-rendered markup (scoped to
|
|
// the caller's visible libraries); swap in the templ partial instead of
|
|
// rebuilding cards from JSON.
|
|
const params = new URLSearchParams({ progress_id: progressId });
|
|
if (sha256) params.set("sha256", sha256);
|
|
if (title) params.set("title", title);
|
|
|
|
window.htmx
|
|
.ajax("GET", `/unlinked-books/matches?${params.toString()}`, {
|
|
target: `#matches-list-${progressId}`,
|
|
swap: "innerHTML",
|
|
})
|
|
.then((ok) => {
|
|
if (!ok) {
|
|
matchesList.innerHTML =
|
|
'<p class="text-sm" style="color: var(--status-danger)">Failed to search</p>';
|
|
}
|
|
});
|
|
}
|
|
|
|
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 =
|
|
'<p style="color: var(--text-secondary)">Search for books to link</p>';
|
|
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 =
|
|
'<p class="text-sm" style="color: var(--text-secondary)">Enter at least 2 characters</p>';
|
|
return;
|
|
}
|
|
|
|
resultsContainer.innerHTML =
|
|
'<p class="text-sm" style="color: var(--text-secondary)">Searching...</p>';
|
|
|
|
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) => `
|
|
<div class="card p-3 rounded-lg border cursor-pointer ${selectedMediaItem === match.media_item_id ? "border-2 border-blue-500" : ""}"
|
|
style="background-color: var(--bg-primary); border-color: var(--border);"
|
|
data-action="select-book" data-media-item-id="${match.media_item_id}" data-title="${match.title}" data-cover="${match.cover_image_path || ""}">
|
|
<div class="flex gap-3">
|
|
<img src="${match.cover_image_path || "/static/placeholder-book.svg"}"
|
|
alt="Cover" class="w-12 h-16 object-cover rounded">
|
|
<div>
|
|
<h5 class="font-semibold text-sm" style="color: var(--text-primary)">${match.title}</h5>
|
|
<p class="text-xs" style="color: var(--text-secondary)">by ${match.author || "Unknown"}</p>
|
|
<p class="text-xs" style="color: var(--accent)">${Math.round(match.confidence * 100)}% confidence</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
} else {
|
|
resultsContainer.innerHTML =
|
|
'<p class="text-sm" style="color: var(--text-secondary)">No matches found</p>';
|
|
}
|
|
})
|
|
.catch((error) => {
|
|
console.error("Failed to search", error);
|
|
resultsContainer.innerHTML =
|
|
'<p class="text-sm" style="color: var(--error)">Failed to search</p>';
|
|
});
|
|
}
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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 =
|
|
'<p style="color: var(--text-secondary)">No matches found</p>';
|
|
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 = `
|
|
<div class="flex justify-between items-center">
|
|
<div>
|
|
<h4 class="font-semibold" style="color: var(--text-primary)">${match.title}</h4>
|
|
<p class="text-sm" style="color: var(--text-secondary)">Author: ${match.author || "Unknown"}</p>
|
|
</div>
|
|
<div class="text-right">
|
|
<div class="text-sm font-semibold" style="color: var(--text-primary)">
|
|
${(match.confidence * 100).toFixed(0)}% confidence
|
|
</div>
|
|
<div class="text-xs" style="color: var(--text-secondary)">${match.match_method}</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
(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,
|
|
}));
|