feat: merge books from the duplicates screen and the toolbar

A workbench with the folded records in a rail, the survivor's fields live and
editable beside them, and per-field actions chosen by what kind of field it is.
Fields the records agree on stay out of the way. Reachable from a duplicate
group and from the selection toolbar, which is the only way to merge a pair the
detector never proposed.
This commit is contained in:
2026-08-16 20:21:19 -04:00
parent 22539644a9
commit 699a1a7fa2
8 changed files with 781 additions and 32 deletions
@@ -1,10 +1,18 @@
import { listDuplicateBooks } from '$lib/api/book.remote.js';
import { listBooks, 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.
// Dismissing or merging a group re-runs this, so the card leaves the screen.
depends('app:duplicate-books');
return {
groups: await listDuplicateBooks(params.libraryId)
};
const groups = await listDuplicateBooks(params.libraryId);
// The groups carry only enough to render a card. Merging needs the whole record —
// identifiers, description, publisher — so fetch them in one go rather than per
// card, and let the dialog pick out the books for its own group.
const ids = [...new Set(groups.flatMap((group) => group.books.map((book) => book.book_id)))];
const books = ids.length
? await listBooks({ ids, pageSize: ids.length })
: { items: [] };
return { groups, books: books.items };
}
@@ -1,4 +1,5 @@
<script lang="ts">
import { page } from '$app/state';
import { resolve } from '$app/paths';
import { invalidate } from '$app/navigation';
import { toast } from 'svelte-sonner';
@@ -10,9 +11,20 @@
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';
import MergeBooks from '$lib/components/forms/merge-books/merge-books.svelte';
import type { Book, DuplicateBookGroup } from '$lib/schema';
let { data }: { data: { groups: DuplicateBookGroup[] } } = $props();
let { data }: { data: { groups: DuplicateBookGroup[]; books: Book[] } } = $props();
// The group being merged, or null when the dialog is closed.
let merging = $state<DuplicateBookGroup | null>(null);
/** The full records behind a group, in the order the group lists them. */
function recordsFor(group: DuplicateBookGroup): Book[] {
return group.books
.map(({ book_id }) => data.books.find((book) => book.id === book_id))
.filter((book): book is Book => book !== undefined);
}
// Which groups are being dismissed, so a slow round trip cannot be started twice.
let dismissing = $state<number[]>([]);
@@ -86,7 +98,7 @@
<Card.Root>
<Card.Header>
<Card.Title>{group.books.length} books look like the same book</Card.Title>
<Card.Action>
<Card.Action class="flex gap-2">
<Button
variant="outline"
size="sm"
@@ -95,6 +107,15 @@
>
Not duplicates
</Button>
<!-- Disabled until every record in the group came back, so the
dialog can never open on a partial group. -->
<Button
size="sm"
disabled={busy || recordsFor(group).length !== group.books.length}
onclick={() => (merging = group)}
>
Merge…
</Button>
</Card.Action>
</Card.Header>
@@ -147,3 +168,18 @@
{/each}
{/if}
</div>
<!--
Keyed on the group so a second merge starts from that group's records rather
than the previous one's draft, the same way the edit dialog keys on the book.
-->
{#if merging}
{#key merging.books[0].book_id}
<MergeBooks
books={recordsFor(merging)}
libraryId={page.params.libraryId!}
open={true}
onmerged={() => (merging = null)}
/>
{/key}
{/if}