From a3443d36f1f847489907737438bd7074795f5df4 Mon Sep 17 00:00:00 2001 From: patrick Date: Mon, 17 Aug 2026 10:35:35 -0400 Subject: [PATCH] feat: offer a merge that keeps the first book, without the workbench --- .../forms/merge-books/merge-books.svelte | 24 ++--- .../lib/components/forms/merge-books/merge.ts | 44 +++++++++ .../view/batch-operations-toolbar.svelte | 88 +++++++++++++++--- .../[libraryId]/duplicates/+page.svelte | 89 +++++++++++++++---- 4 files changed, 201 insertions(+), 44 deletions(-) create mode 100644 frontend/src/lib/components/forms/merge-books/merge.ts diff --git a/frontend/src/lib/components/forms/merge-books/merge-books.svelte b/frontend/src/lib/components/forms/merge-books/merge-books.svelte index 31558bb..b2b245b 100644 --- a/frontend/src/lib/components/forms/merge-books/merge-books.svelte +++ b/frontend/src/lib/components/forms/merge-books/merge-books.svelte @@ -1,7 +1,5 @@ diff --git a/frontend/src/lib/components/forms/merge-books/merge.ts b/frontend/src/lib/components/forms/merge-books/merge.ts new file mode 100644 index 0000000..a2ebfaf --- /dev/null +++ b/frontend/src/lib/components/forms/merge-books/merge.ts @@ -0,0 +1,44 @@ +import { invalidate } from '$app/navigation'; +import { toast } from 'svelte-sonner'; + +import { mergeBooks } from '$lib/api/book.remote'; +import type { Book } from '$lib/schema'; +import type { FieldValue } from './field-spec'; + +/** + * Fold `folded` into `survivor`, and tell the reader how it went. + * + * Shared because a merge is reachable two ways — the workbench, where the reader + * resolved the metadata field by field, and the quick action beside it, which + * takes the survivor's metadata as it stands. Only the `metadata` argument + * differs, and the invalidation and the wording should not. + * + * @returns whether the merge went through; the caller decides what to close or + * clear, and has no toast of its own to write either way. + */ +export async function mergeInto( + libraryId: number | string, + survivor: Book, + folded: Book[], + metadata?: Record +): Promise { + try { + await mergeBooks({ + library_id: libraryId, + survivor_id: survivor.id, + merged_ids: folded.map((book) => book.id), + metadata + }); + + // Both, because a merge is started from the duplicates review and from the + // library's selection toolbar, and each page depends on a different one. + await Promise.all([invalidate('app:books'), invalidate('app:duplicate-books')]); + + toast.success(`Merged into “${survivor.title}”`); + return true; + } catch (error) { + console.error('Failed to merge books', error); + toast.error(error instanceof Error ? error.message : 'Could not merge these books'); + return false; + } +} diff --git a/frontend/src/lib/components/view/batch-operations-toolbar.svelte b/frontend/src/lib/components/view/batch-operations-toolbar.svelte index b74760e..de172cb 100644 --- a/frontend/src/lib/components/view/batch-operations-toolbar.svelte +++ b/frontend/src/lib/components/view/batch-operations-toolbar.svelte @@ -7,6 +7,7 @@ GitMerge, Trash2, SquareCheckBig, + SlidersHorizontal, X, Album, PlusIcon @@ -19,6 +20,7 @@ import { getBookshelfState } from '$lib/state/bookshelf.svelte'; import ShelfCreateDialog from '../forms/shelf-create-dialog.svelte'; import MergeBooks from '../forms/merge-books/merge-books.svelte'; + import { mergeInto } from '../forms/merge-books/merge'; import type { Book } from '$lib/schema'; const libraryState = getLibraryState(); @@ -35,6 +37,42 @@ // the selection, so clearing the selection on success cannot empty the dialog // underneath itself. let mergingBooks = $state(null); + + // A quick merge has no dialog to disable, so the flag is what stops a slow + // round trip being started twice. + let quickMerging = $state(false); + + /** Put the library back in step with a merge that went through. */ + function afterMerge(folded: Book[]) { + // Only the folded records are gone; the survivor is still in the library. + libraryState.activeLibrary!.total! -= folded.length; + bookshelfState.deletedBooks(folded); + selectionState.deselectAll(); + } + + /** + * Merge without opening the workbench, keeping the book selected first. + * + * Selection is held in insertion order, so the first record is the one the + * reader started from — the one they were looking at when they decided the + * rest were copies of it. + */ + async function quickMerge() { + if (quickMerging) return; + + // Read once: the merge clears the selection, and the bookkeeping afterwards + // still needs the records that went away. + const [survivor, ...folded] = selectedBooks; + if (!survivor || folded.length === 0) return; + + quickMerging = true; + + const merged = await mergeInto(libraryState.activeLibrary!.id, survivor, folded); + + quickMerging = false; + + if (merged) afterMerge(folded); + } @@ -148,11 +186,44 @@ {#if selectedBooks.length > 1} - (mergingBooks = selectedBooks)} - class={buttonVariants({ variant: 'ghost', size: 'icon' })} - > - + + {#snippet child({ props })} + + + + + + + + + + Keep “{selectedBooks[0].title}” + + + Files move onto the book you selected first, which keeps its own metadata. + + + (mergingBooks = selectedBooks)} + > + + + Choose what to keep… + + + Pick the record that survives and resolve the metadata field by field. + + + + + {/snippet}

Merge {selectedBooks.length} books

@@ -247,16 +318,13 @@ (value) => { // Bound, not passed as a bare `true`: the dialog closes itself on an // outside click or Escape, and if that never reaches `mergingBooks` - // the snapshot stays set — the button then re-assigns the same + // the snapshot stays set — the menu item then re-assigns the same // selection, nothing changes, and the dialog never reopens. if (!value) mergingBooks = null; } } 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(); + afterMerge(folded); mergingBooks = null; }} /> diff --git a/frontend/src/routes/(root)/settings/libraries/[libraryId]/duplicates/+page.svelte b/frontend/src/routes/(root)/settings/libraries/[libraryId]/duplicates/+page.svelte index 7e0f2b7..de35cdc 100644 --- a/frontend/src/routes/(root)/settings/libraries/[libraryId]/duplicates/+page.svelte +++ b/frontend/src/routes/(root)/settings/libraries/[libraryId]/duplicates/+page.svelte @@ -3,21 +3,23 @@ import { resolve } from '$app/paths'; import { invalidate } from '$app/navigation'; import { toast } from 'svelte-sonner'; - import { CopyCheck, Fingerprint, Type } from '@lucide/svelte'; + import { CopyCheck, Fingerprint, GitMerge, SlidersHorizontal, 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 * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; import { Badge } from '$lib/components/ui/badge/index.js'; - import { Button } from '$lib/components/ui/button/index.js'; + import { Button, buttonVariants } from '$lib/components/ui/button/index.js'; import BookImage from '$lib/components/view/book-image.svelte'; import GeneratedCover from '$lib/components/view/generated-cover.svelte'; import { dismissDuplicateBooks } from '$lib/api/book.remote'; import MergeBooks from '$lib/components/forms/merge-books/merge-books.svelte'; + import { mergeInto } from '$lib/components/forms/merge-books/merge'; import type { Book, DuplicateBookGroup } from '$lib/schema'; let { data }: { data: { groups: DuplicateBookGroup[]; books: Book[] } } = $props(); - // The group being merged, or null when the dialog is closed. + // The group open in the workbench, or null when it is closed. let merging = $state(null); /** The full records behind a group, in the order the group lists them. */ @@ -27,8 +29,33 @@ .filter((book): book is Book => book !== undefined); } - // Which groups are being dismissed, so a slow round trip cannot be started twice. + // Which groups have a request in flight — a dismissal or a quick merge — so a + // slow round trip cannot be started twice. let dismissing = $state([]); + let quickMerging = $state([]); + + /** + * Merge without opening the workbench, keeping the first book as it stands. + * + * The common case by far: the group is the same book twice, one record is as + * good as the other, and the point is to end up with one. Resolving metadata + * field by field is the other button. + */ + async function quickMerge(group: DuplicateBookGroup) { + const key = keyOf(group); + if (quickMerging.includes(key)) return; + + const [survivor, ...folded] = recordsFor(group); + if (!survivor || folded.length === 0) return; + + quickMerging = [...quickMerging, key]; + + try { + await mergeInto(page.params.libraryId!, survivor, folded); + } finally { + quickMerging = quickMerging.filter((id) => id !== key); + } + } /** A group is named by its lowest book id, which the backend orders it by. */ function keyOf(group: DuplicateBookGroup) { @@ -95,7 +122,8 @@ {:else} {#each data.groups as group (keyOf(group))} - {@const busy = dismissing.includes(keyOf(group))} + {@const busy = dismissing.includes(keyOf(group)) || quickMerging.includes(keyOf(group))} + {@const records = recordsFor(group)} {group.books.length} books look like the same book @@ -108,15 +136,46 @@ > Not duplicates - - + + + + Merge… + + + quickMerge(group)} + > + + + Keep “{records[0]?.title}” + + + Files move onto the first book, which keeps its own metadata. + + + (merging = group)} + > + + + Choose what to keep… + + + Pick the record that survives and resolve the metadata field by field. + + + + @@ -195,7 +254,7 @@ (value) => { // Bound, not passed as a bare `true`: the dialog closes itself on an // outside click or Escape, and if that never reaches `merging` the - // group stays set — the button then re-assigns the same group, + // group stays set — the menu item then re-assigns the same group, // nothing changes, and the workbench never reopens. if (!value) merging = null; }