feat: offer a merge that keeps the first book, without the workbench
This commit is contained in:
@@ -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<DuplicateBookGroup | null>(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<number[]>([]);
|
||||
let quickMerging = $state<number[]>([]);
|
||||
|
||||
/**
|
||||
* 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 @@
|
||||
</Empty.Root>
|
||||
{: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)}
|
||||
<Card.Root>
|
||||
<Card.Header>
|
||||
<Card.Title>{group.books.length} books look like the same book</Card.Title>
|
||||
@@ -108,15 +136,46 @@
|
||||
>
|
||||
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>
|
||||
<!-- A choice rather than a jump straight into the workbench:
|
||||
most groups are one book twice, where there is nothing to
|
||||
resolve and the dialog is a step in the way.
|
||||
|
||||
Disabled until every record in the group came back, so
|
||||
neither action can run on a partial group. -->
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger
|
||||
class={buttonVariants({ size: 'sm' })}
|
||||
disabled={busy || records.length !== group.books.length}
|
||||
>
|
||||
Merge…
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-72">
|
||||
<DropdownMenu.Item
|
||||
class="flex-col items-start gap-0.5"
|
||||
onclick={() => quickMerge(group)}
|
||||
>
|
||||
<span class="flex w-full min-w-0 items-center gap-2">
|
||||
<GitMerge class="size-4 shrink-0" />
|
||||
<span class="truncate">Keep “{records[0]?.title}”</span>
|
||||
</span>
|
||||
<span class="pl-6 text-xs text-wrap text-muted-foreground">
|
||||
Files move onto the first book, which keeps its own metadata.
|
||||
</span>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
class="flex-col items-start gap-0.5"
|
||||
onclick={() => (merging = group)}
|
||||
>
|
||||
<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>
|
||||
</Card.Action>
|
||||
</Card.Header>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user