Covers the copy-then-delete-later workflow: a user copies books to a new
library, deletes the originals, and ends up with an active copy in the new
library plus an archived twin holding the real reading history. Until now
those twins could only be purged (destroying the history) or restored
(showing a permanently broken entry).
- POST /api/media-items/:id/merge (admin only, body {target_id}):
validates the source is archived/missing, the target is active, and
both share the same file_sha256; then re-parents every child row onto
the target via the existing reparent_media_item_children function (the
same machinery as hash-conflict resolution) and deletes the source row.
Per-user collisions keep the active copy's data, mirroring that flow.
Affected data: reading progress, speed, ratings, highlights, notes,
bookmarks (including tombstoned deleted-annotation history), formats,
collections, kobo shelves/entitlements, sync rows, panel data,
processing issues, and device aliases.
- GET /admin/archived: ListHiddenMediaItems' match_* columns surface each
row's best active twin; rows with a twin get a confirm-guarded "Merge"
button (data-merge-source/-target) next to Restore/Delete, wired in
web/src/admin.ts like the existing unarchive/delete handlers.
- templates.ArchivedItem gains MatchID/MatchTitle/MatchLibraryName;
frontend.go populates them from the listing row.
After a merge the archived row is gone, so the retention purge can never
destroy the merged data. Deleted-annotation tombstones carry over and
remain restorable from the target book's "recently deleted" history.
404 lines
12 KiB
TypeScript
404 lines
12 KiB
TypeScript
import { Alpine } from "./alpine";
|
|
import { showToast } from "./toast";
|
|
|
|
async function scanAllLibraries(): Promise<void> {
|
|
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<string, string> = {};
|
|
|
|
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<string, string>,
|
|
): 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) => `
|
|
<div id="progress-${jobId}" class="p-3 rounded border"
|
|
style="background-color: var(--bg-primary); border-color: var(--border);">
|
|
<div class="flex justify-between items-center mb-2">
|
|
<span class="font-medium" style="color: var(--text-primary)">
|
|
${libraryNames[jobId]}
|
|
</span>
|
|
<span id="status-${jobId}" class="text-sm" style="color: var(--text-secondary)">
|
|
Pending...
|
|
</span>
|
|
</div>
|
|
<div class="w-full bg-gray-700 rounded-full h-2">
|
|
<div id="bar-${jobId}"
|
|
class="h-2 rounded-full transition-all duration-500"
|
|
style="width: 0%; background-color: var(--accent);">
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`,
|
|
)
|
|
.join("");
|
|
|
|
pollScanProgress(jobIds, libraryNames);
|
|
}
|
|
|
|
let scanPollInterval: ReturnType<typeof setInterval> | undefined = undefined;
|
|
|
|
function pollScanProgress(
|
|
jobIds: string[],
|
|
_libraryNames: Record<string, string>,
|
|
): void {
|
|
const token = localStorage.getItem("token");
|
|
const startTime = Date.now();
|
|
scanPollInterval = setInterval(async () => {
|
|
let allComplete = true;
|
|
let totalProgress = 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);
|
|
|
|
totalProgress += status.progress || 0;
|
|
totalFiles += status.files_scanned || 0;
|
|
totalNewItems += status.new_items || 0;
|
|
totalErrors += status.errors || 0;
|
|
|
|
if (status.status !== "completed" && status.status !== "failed") {
|
|
allComplete = false;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error(`Failed to poll job ${jobId}:`, error);
|
|
}
|
|
}
|
|
|
|
const overallProgress = Math.round(totalProgress / jobIds.length);
|
|
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!);
|
|
showScanResults(
|
|
jobIds.length,
|
|
totalFiles,
|
|
totalNewItems,
|
|
totalErrors,
|
|
elapsed,
|
|
);
|
|
}
|
|
}, 2000);
|
|
}
|
|
|
|
function updateLibraryProgress(jobId: string, status: any): void {
|
|
const bar = document.getElementById(`bar-${jobId}`) as HTMLElement;
|
|
const statusText = document.getElementById(`status-${jobId}`) as HTMLElement;
|
|
|
|
if (bar) {
|
|
bar.style.width = (status.progress || 0) + "%";
|
|
}
|
|
|
|
if (statusText) {
|
|
const statusMessages: Record<string, string> = {
|
|
pending: "Pending...",
|
|
running: `Scanning... ${status.progress || 0}%`,
|
|
completed: `✓ Complete (${status.new_items || 0} items)`,
|
|
failed: `✗ Failed`,
|
|
};
|
|
statusText.textContent = statusMessages[status.status] || status.status;
|
|
}
|
|
}
|
|
|
|
function showScanResults(
|
|
libCount: number,
|
|
files: number,
|
|
items: number,
|
|
errors: number,
|
|
elapsed: 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 = `
|
|
<p>• ${libCount} librar${libCount === 1 ? "y" : "ies"} scanned</p>
|
|
<p>• ${files} files processed</p>
|
|
<p>• ${items} new items added</p>
|
|
${errors > 0 ? `<p style="color: var(--accent);">• ${errors} errors</p>` : ""}
|
|
<p style="color: var(--text-secondary)">Completed in ${elapsed} seconds</p>
|
|
`;
|
|
|
|
resultsDiv.classList.remove("hidden");
|
|
|
|
const statusText = document.getElementById("scan-status") as HTMLElement;
|
|
if (statusText) statusText.textContent = "Scan complete!";
|
|
}
|
|
|
|
function hideScanProgress(): void {
|
|
const container = document.getElementById(
|
|
"scan-progress-container",
|
|
) as HTMLElement;
|
|
if (container) container.classList.add("hidden");
|
|
}
|
|
|
|
async function loadWatchStatus(): Promise<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<void> {
|
|
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<HTMLElement>("[data-unarchive]").forEach((el) => {
|
|
el.addEventListener("click", () => unarchiveItem(el.dataset.unarchive || ""));
|
|
});
|
|
document.querySelectorAll<HTMLElement>("[data-delete-archived]").forEach((el) => {
|
|
el.addEventListener("click", () =>
|
|
deleteArchivedItem(el.dataset.deleteArchived || ""),
|
|
);
|
|
});
|
|
document.querySelectorAll<HTMLElement>("[data-merge-source]").forEach((el) => {
|
|
el.addEventListener("click", () =>
|
|
mergeArchivedItem(el.dataset.mergeSource || "", el.dataset.mergeTarget || ""),
|
|
);
|
|
});
|
|
|
|
export {
|
|
hideScanProgress,
|
|
loadWatchStatus,
|
|
scanAllLibraries,
|
|
stopScanStatusPolling,
|
|
};
|
|
|
|
Alpine.data("admin", () => ({
|
|
hideScanProgress,
|
|
loadWatchStatus,
|
|
scanAllLibraries,
|
|
stopScanStatusPolling,
|
|
}));
|