feat: offer a merge that keeps the first book, without the workbench

This commit is contained in:
2026-08-17 10:35:35 -04:00
parent 75fe41e266
commit a3443d36f1
4 changed files with 201 additions and 44 deletions
@@ -1,7 +1,5 @@
<script lang="ts">
import { untrack } from 'svelte';
import { toast } from 'svelte-sonner';
import { invalidate } from '$app/navigation';
import { ArrowRight, GitMerge, Plus, Undo2 } from '@lucide/svelte';
import * as Dialog from '$lib/components/ui/dialog/index.js';
@@ -11,9 +9,10 @@
import { Badge } from '$lib/components/ui/badge/index.js';
import BookImage from '$lib/components/view/book-image.svelte';
import GeneratedCover from '$lib/components/view/generated-cover.svelte';
import { mergeBooks } from '$lib/api/book.remote';
import type { Book } from '$lib/schema';
import { mergeInto } from './merge';
import {
MERGE_FIELDS,
applyAction,
@@ -105,26 +104,13 @@
if (busy) return;
busy = true;
try {
await mergeBooks({
library_id: libraryId,
survivor_id: survivor.id,
merged_ids: candidates.map((book) => book.id),
metadata: changed.length ? draft : undefined
});
const merged = await mergeInto(libraryId, survivor, folded, changed.length ? draft : undefined);
// Both, because this dialog is opened 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')]);
busy = false;
toast.success(`Merged into “${survivor.title}”`);
if (merged) {
open = false;
onmerged?.(survivor, folded);
} catch (error) {
console.error('Failed to merge books', error);
toast.error(error instanceof Error ? error.message : 'Could not merge these books');
} finally {
busy = false;
}
}
</script>
@@ -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<string, FieldValue>
): Promise<boolean> {
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;
}
}
@@ -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<Book[] | null>(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);
}
</script>
<!-- Mark selected as finished button -->
@@ -148,11 +186,44 @@
{#if selectedBooks.length > 1}
<Tooltip.Provider>
<Tooltip.Root ignoreNonKeyboardFocus>
<Tooltip.Trigger
onclick={() => (mergingBooks = selectedBooks)}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<GitMerge />
<Tooltip.Trigger>
{#snippet child({ props })}
<!-- A choice rather than a jump straight into the workbench: two
copies of one book have nothing to resolve, and the dialog is a
step in the way of saying so. -->
<DropdownMenu.Root>
<DropdownMenu.Trigger
{...props}
disabled={quickMerging}
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
>
<GitMerge />
</DropdownMenu.Trigger>
<DropdownMenu.Content class="w-72">
<DropdownMenu.Item class="flex-col items-start gap-0.5" onclick={quickMerge}>
<span class="flex w-full min-w-0 items-center gap-2">
<GitMerge class="size-4 shrink-0" />
<span class="truncate">Keep “{selectedBooks[0].title}</span>
</span>
<span class="pl-6 text-xs text-wrap text-muted-foreground">
Files move onto the book you selected first, which keeps its own metadata.
</span>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex-col items-start gap-0.5"
onclick={() => (mergingBooks = selectedBooks)}
>
<span class="flex items-center gap-2">
<SlidersHorizontal class="size-4 shrink-0" />
Choose what to keep…
</span>
<span class="pl-6 text-xs text-wrap text-muted-foreground">
Pick the record that survives and resolve the metadata field by field.
</span>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
{/snippet}
</Tooltip.Trigger>
<Tooltip.Content>
<p>Merge {selectedBooks.length} books</p>
@@ -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;
}}
/>