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,15 +1,25 @@
<script lang="ts">
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
import * as Tooltip from '$lib/components/ui/tooltip/index';
import { BookOpenCheck, Download, Trash2, SquareCheckBig, X, Album, PlusIcon } from '@lucide/svelte';
import {
BookOpenCheck,
Download,
GitMerge,
Trash2,
SquareCheckBig,
X,
Album,
PlusIcon
} from '@lucide/svelte';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
import { getLibraryState } from '$lib/state/library.svelte';
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
import { invalidate } from '$app/navigation';
import ShelfCreateDialog from '../forms/shelf-create-dialog.svelte';
import MergeBooks from '../forms/merge-books/merge-books.svelte';
import type { Book } from '$lib/schema';
const libraryState = getLibraryState();
const bookshelfState = getBookshelfState();
@@ -17,10 +27,14 @@
const bookOps = getBookOperationsState();
const collectionState = getBookCollectionState();
let selectedBooks = $derived(selectionState.getSelectedBooks())
let selectedBooks = $derived(selectionState.getSelectedBooks());
let createShelfDialogOpen = $state(false)
let createShelfDialogOpen = $state(false);
// The books the merge dialog opened on. Snapshotted rather than read live from
// the selection, so clearing the selection on success cannot empty the dialog
// underneath itself.
let mergingBooks = $state<Book[] | null>(null);
</script>
<!-- Mark selected as finished button -->
@@ -95,12 +109,13 @@
{/each}
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onclick={() => createShelfDialogOpen = true}
class="text-muted-foreground ">
<PlusIcon class="size-4" />
New Shelf
</DropdownMenu.Item>
<DropdownMenu.Item
onclick={() => (createShelfDialogOpen = true)}
class="text-muted-foreground "
>
<PlusIcon class="size-4" />
New Shelf
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
@@ -128,6 +143,24 @@
</Tooltip.Root>
</Tooltip.Provider>
<!-- Merge selected button. Two books is the smallest thing a merge can mean, so
it appears only once there are two. -->
{#if selectedBooks.length > 1}
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => (mergingBooks = selectedBooks)}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<GitMerge />
</Tooltip.Trigger>
<Tooltip.Content>
<p>Merge {selectedBooks.length} books</p>
</Tooltip.Content>
</Tooltip.Root>
</Tooltip.Provider>
{/if}
<!-- Delete selected button -->
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
@@ -136,8 +169,8 @@
bookOps.deleteDialogTitle = `Delete ${selectionState.getSelectedIds().length} books?`;
bookOps.deleteFn = async (deleteFiles: boolean) => {
await bookOps.deleteBooks(selectionState.getSelectedIds(), deleteFiles);
libraryState.activeLibrary!.total! -= selectedBooks.length
bookshelfState.deletedBooks(selectedBooks)
libraryState.activeLibrary!.total! -= selectedBooks.length;
bookshelfState.deletedBooks(selectedBooks);
selectionState.deselectAll();
};
bookOps.deleteDialogOpen = true;
@@ -186,14 +219,37 @@
</Tooltip.Root>
</Tooltip.Provider>
<ShelfCreateDialog
bind:open={createShelfDialogOpen}
<ShelfCreateDialog
bind:open={createShelfDialogOpen}
onSubmit={async (name: string) => {
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, selectionState.getSelectedIds())
selectedBooks.forEach(book => book.lists.push(bookshelf))
selectionState.deselectAll()
createShelfDialogOpen = false
const bookshelf = await bookshelfState.addBookshelf(
name,
libraryState.activeLibrary!.id,
selectionState.getSelectedIds()
);
selectedBooks.forEach((book) => book.lists.push(bookshelf));
selectionState.deselectAll();
createShelfDialogOpen = false;
}}
/>
<!--
Keyed on the selection so opening merge on a different pair starts from those
records rather than the previous dialog's draft.
-->
{#if mergingBooks}
{#key mergingBooks.map((book) => book.id).join()}
<MergeBooks
books={mergingBooks}
libraryId={libraryState.activeLibrary!.id}
open={true}
onmerged={(_survivor, folded) => {
// Only the folded records are gone; the survivor is still in the library.
libraryState.activeLibrary!.total! -= folded.length;
bookshelfState.deletedBooks(folded);
selectionState.deselectAll();
mergingBooks = null;
}}
/>
{/key}
{/if}