feat: review and dismiss duplicate books

Adds the per-library review screen, a muted note in the upload tray for a book
that may already be held, and the remote functions behind them. The tray note
is deliberately weaker than the file-level one, which means something stronger.
This commit is contained in:
2026-08-15 21:55:20 -04:00
parent 5047277845
commit fc6b97bf38
8 changed files with 465 additions and 12 deletions
@@ -0,0 +1,10 @@
import { listDuplicateBooks } from '$lib/api/book.remote.js';
export async function load({ params, depends }) {
// Dismissing a group re-runs this, so the card leaves the screen.
depends('app:duplicate-books');
return {
groups: await listDuplicateBooks(params.libraryId)
};
}
@@ -0,0 +1,149 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { invalidate } from '$app/navigation';
import { toast } from 'svelte-sonner';
import { CopyCheck, Fingerprint, Type } from '@lucide/svelte';
import * as Card from '$lib/components/ui/card/index.js';
import * as Empty from '$lib/components/ui/empty/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import BookImage from '$lib/components/view/book-image.svelte';
import { dismissDuplicateBooks } from '$lib/api/book.remote';
import type { DuplicateBookGroup } from '$lib/schema';
let { data }: { data: { groups: DuplicateBookGroup[] } } = $props();
// Which groups are being dismissed, so a slow round trip cannot be started twice.
let dismissing = $state<number[]>([]);
/** A group is named by its lowest book id, which the backend orders it by. */
function keyOf(group: DuplicateBookGroup) {
return group.books[0].book_id;
}
function reasonLabel(reason: string) {
if (reason === 'identifier') return 'Same identifier';
if (reason === 'title-author') return 'Same title and author';
return reason;
}
/**
* Dismiss every pairing in a group at once.
*
* A group is held together pair by pair, so saying "these are not duplicates"
* about three books means saying it about all three pairs — dismissing only the
* first would leave the rest of the group standing and the screen unchanged.
*/
async function notDuplicates(group: DuplicateBookGroup) {
const key = keyOf(group);
if (dismissing.includes(key)) return;
dismissing = [...dismissing, key];
const ids = group.books.map((book) => book.book_id);
const pairs = ids.flatMap((a, index) =>
ids.slice(index + 1).map((b) => ({ book_a_id: a, book_b_id: b }))
);
try {
await Promise.all(pairs.map((pair) => dismissDuplicateBooks(pair)));
await invalidate('app:duplicate-books');
toast.success('Marked as different books');
} catch {
toast.error('Could not mark these as different books');
} finally {
dismissing = dismissing.filter((id) => id !== key);
}
}
</script>
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-4">
<div>
<h2 class="text-lg font-semibold">Possible duplicates</h2>
<p class="text-sm text-muted-foreground">
Books whose metadata matches. A second edition, a translation and a different scan of one book
all look like this, so nothing here has been changed or removed — this is a list to read, not
a problem to fix.
</p>
</div>
{#if data.groups.length === 0}
<Empty.Root>
<Empty.Header>
<Empty.Media variant="icon">
<CopyCheck />
</Empty.Media>
<Empty.Title>Nothing looks duplicated</Empty.Title>
<Empty.Description>
No two books in this library share an identifier, or a title and an author.
</Empty.Description>
</Empty.Header>
</Empty.Root>
{:else}
{#each data.groups as group (keyOf(group))}
{@const busy = dismissing.includes(keyOf(group))}
<Card.Root>
<Card.Header>
<Card.Title>{group.books.length} books look like the same book</Card.Title>
<Card.Action>
<Button
variant="outline"
size="sm"
disabled={busy}
onclick={() => notDuplicates(group)}
>
Not duplicates
</Button>
</Card.Action>
</Card.Header>
<Card.Content>
<ul class="flex flex-wrap gap-4">
{#each group.books as book (book.book_id)}
<li class="w-36">
<a
href={resolve('/(root)/(library)/book/[bookId]', {
bookId: String(book.book_id)
})}
class="group flex flex-col gap-2"
>
<span
class="block aspect-9/12 w-full overflow-hidden rounded-sm bg-muted shadow-md transition-all group-hover:brightness-75"
>
{#if book.cover_image}
<BookImage src="/api/{book.cover_image}" class="h-full w-full object-cover" />
{/if}
</span>
<span class="line-clamp-2 font-serif text-sm group-hover:underline">
{book.title}
</span>
</a>
<p class="line-clamp-2 text-xs text-muted-foreground">
{book.authors.join(', ')}
</p>
<!-- Why this book is in the group, so the reader can judge the
evidence rather than take the grouping on trust. -->
<p class="mt-1 flex flex-wrap gap-1">
{#each book.matched_on as reason (reason)}
<Badge variant="secondary" class="gap-1 text-[10px] font-normal">
{#if reason === 'identifier'}
<Fingerprint class="size-3" />
{:else}
<Type class="size-3" />
{/if}
{reasonLabel(reason)}
</Badge>
{/each}
</p>
</li>
{/each}
</ul>
</Card.Content>
</Card.Root>
{/each}
{/if}
</div>