Files
bookhoard/web/src/unlinked_books.ts
T
john-okeefe f85c9c3f66 refactor(reader): Fix reader-context imports and remove dead code
Problem:
- Many format modules imported from '../core/reader-context'
- reader-context.ts was a local interface file, not a true context module
- Confusion between canonical reader-shell.ts and local reader-context.ts
- PDF page-cache.ts was 100% dead code (unused, unregistered, no exports)
- Several unused variables and imports across reader modules

Root Cause:
- reader-context.ts created as temporary file during refactoring
- Modules imported from it instead of canonical reader-shell.ts
- page-cache.ts copied from comic version but never integrated
- Incomplete refactoring left behind unused code

Solution:
- Update all imports to use reader-shell (canonical source)
- Remove unused page-cache.ts (dead code)
- Clean up unused variables and imports
- Consolidate type definitions

Changes:

Import Path Updates:
- comic/*: '../core/reader-context' → '../../reader-shell'
- manga/*: '../core/reader-context' → '../../reader-shell'
- pdf/*: '../core/reader-context' → '../../reader-shell'
- reflowable/ebook/*: '../core/reader-context' → '../../reader-shell'
- All now import UniversalReader from single source

Dead Code Removal:
- pdf/page-cache.ts: Deleted entirely
  - No init() function exported
  - Not registered in reader-shell.ts
  - All functions unused (createPDFPageCache, getCachedPage, etc.)
  - Only 2 lines of executable code (console.log, DOM cleanup)
  - 148 lines of dead code

Clean Up:
- navigator-panel.ts: Remove unused containerRect variable
- api-explorer-docs.ts, api.ts, queue.ts: Fix unused imports
- unlinked_books.ts: Remove unused variables
- panel-dock-system.ts: Remove unused context variables

Impact:
-  All modules use canonical type definitions
-  No more duplicate/conflicting interfaces
-  Dead code removed (148 lines)
-  Cleaner imports, easier maintenance
-  TypeScript compiler warnings resolved

Files changed: 26
Lines changed: +450, -520 (net -70 lines)
2026-04-10 23:21:44 -04:00

502 lines
15 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>';
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) => `
<div class="card p-4 rounded-lg border cursor-pointer hover:shadow-lg transition-shadow"
style="background-color: var(--bg-primary); border-color: var(--border);"
data-action="auto-link" data-progress-id="${progressId}" data-media-item-id="${match.media_item_id}" data-confidence="${match.confidence}">
<div class="flex gap-4">
<img src="${match.cover_image_path || "/static/placeholder-book.svg"}"
alt="Cover" class="w-16 h-24 object-cover rounded">
<div>
<h5 class="font-semibold" style="color: var(--text-primary)">${match.title}</h5>
<p class="text-sm" style="color: var(--text-secondary)">by ${match.author || "Unknown"}</p>
<div class="mt-2 flex items-center gap-2">
<span class="text-xs px-2 py-1 rounded" style="background-color: var(--accent); color: var(--bg-primary);">
${Math.round(match.confidence * 100)}% confidence
</span>
<span class="text-xs" style="color: var(--text-secondary)">${match.match_method}</span>
</div>
</div>
</div>
</div>
`,
)
.join("");
} else {
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--text-secondary)">No matches found. Try manual linking.</p>';
}
})
.catch((error) => {
console.error("Failed to search", error);
matchesList.innerHTML =
'<p class="text-sm" style="color: var(--error)">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,
}));