feat: offer a merge that keeps the first book, without the workbench
This commit is contained in:
@@ -1,7 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { untrack } from 'svelte';
|
import { untrack } from 'svelte';
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import { invalidate } from '$app/navigation';
|
|
||||||
import { ArrowRight, GitMerge, Plus, Undo2 } from '@lucide/svelte';
|
import { ArrowRight, GitMerge, Plus, Undo2 } from '@lucide/svelte';
|
||||||
|
|
||||||
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
import * as Dialog from '$lib/components/ui/dialog/index.js';
|
||||||
@@ -11,9 +9,10 @@
|
|||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||||
import BookImage from '$lib/components/view/book-image.svelte';
|
import BookImage from '$lib/components/view/book-image.svelte';
|
||||||
import GeneratedCover from '$lib/components/view/generated-cover.svelte';
|
import GeneratedCover from '$lib/components/view/generated-cover.svelte';
|
||||||
import { mergeBooks } from '$lib/api/book.remote';
|
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
|
|
||||||
|
import { mergeInto } from './merge';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MERGE_FIELDS,
|
MERGE_FIELDS,
|
||||||
applyAction,
|
applyAction,
|
||||||
@@ -105,26 +104,13 @@
|
|||||||
if (busy) return;
|
if (busy) return;
|
||||||
busy = true;
|
busy = true;
|
||||||
|
|
||||||
try {
|
const merged = await mergeInto(libraryId, survivor, folded, changed.length ? draft : undefined);
|
||||||
await mergeBooks({
|
|
||||||
library_id: libraryId,
|
|
||||||
survivor_id: survivor.id,
|
|
||||||
merged_ids: candidates.map((book) => book.id),
|
|
||||||
metadata: changed.length ? draft : undefined
|
|
||||||
});
|
|
||||||
|
|
||||||
// Both, because this dialog is opened from the duplicates review and from
|
busy = false;
|
||||||
// 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}”`);
|
if (merged) {
|
||||||
open = false;
|
open = false;
|
||||||
onmerged?.(survivor, folded);
|
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>
|
</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,
|
GitMerge,
|
||||||
Trash2,
|
Trash2,
|
||||||
SquareCheckBig,
|
SquareCheckBig,
|
||||||
|
SlidersHorizontal,
|
||||||
X,
|
X,
|
||||||
Album,
|
Album,
|
||||||
PlusIcon
|
PlusIcon
|
||||||
@@ -19,6 +20,7 @@
|
|||||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||||
import ShelfCreateDialog from '../forms/shelf-create-dialog.svelte';
|
import ShelfCreateDialog from '../forms/shelf-create-dialog.svelte';
|
||||||
import MergeBooks from '../forms/merge-books/merge-books.svelte';
|
import MergeBooks from '../forms/merge-books/merge-books.svelte';
|
||||||
|
import { mergeInto } from '../forms/merge-books/merge';
|
||||||
import type { Book } from '$lib/schema';
|
import type { Book } from '$lib/schema';
|
||||||
|
|
||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
@@ -35,6 +37,42 @@
|
|||||||
// the selection, so clearing the selection on success cannot empty the dialog
|
// the selection, so clearing the selection on success cannot empty the dialog
|
||||||
// underneath itself.
|
// underneath itself.
|
||||||
let mergingBooks = $state<Book[] | null>(null);
|
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>
|
</script>
|
||||||
|
|
||||||
<!-- Mark selected as finished button -->
|
<!-- Mark selected as finished button -->
|
||||||
@@ -148,11 +186,44 @@
|
|||||||
{#if selectedBooks.length > 1}
|
{#if selectedBooks.length > 1}
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
<Tooltip.Trigger
|
<Tooltip.Trigger>
|
||||||
onclick={() => (mergingBooks = selectedBooks)}
|
{#snippet child({ props })}
|
||||||
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
|
<!-- A choice rather than a jump straight into the workbench: two
|
||||||
>
|
copies of one book have nothing to resolve, and the dialog is a
|
||||||
<GitMerge />
|
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.Trigger>
|
||||||
<Tooltip.Content>
|
<Tooltip.Content>
|
||||||
<p>Merge {selectedBooks.length} books</p>
|
<p>Merge {selectedBooks.length} books</p>
|
||||||
@@ -247,16 +318,13 @@
|
|||||||
(value) => {
|
(value) => {
|
||||||
// Bound, not passed as a bare `true`: the dialog closes itself on an
|
// Bound, not passed as a bare `true`: the dialog closes itself on an
|
||||||
// outside click or Escape, and if that never reaches `mergingBooks`
|
// 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.
|
// selection, nothing changes, and the dialog never reopens.
|
||||||
if (!value) mergingBooks = null;
|
if (!value) mergingBooks = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
onmerged={(_survivor, folded) => {
|
onmerged={(_survivor, folded) => {
|
||||||
// Only the folded records are gone; the survivor is still in the library.
|
afterMerge(folded);
|
||||||
libraryState.activeLibrary!.total! -= folded.length;
|
|
||||||
bookshelfState.deletedBooks(folded);
|
|
||||||
selectionState.deselectAll();
|
|
||||||
mergingBooks = null;
|
mergingBooks = null;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -3,21 +3,23 @@
|
|||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
import { invalidate } from '$app/navigation';
|
import { invalidate } from '$app/navigation';
|
||||||
import { toast } from 'svelte-sonner';
|
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 Card from '$lib/components/ui/card/index.js';
|
||||||
import * as Empty from '$lib/components/ui/empty/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 { 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 BookImage from '$lib/components/view/book-image.svelte';
|
||||||
import GeneratedCover from '$lib/components/view/generated-cover.svelte';
|
import GeneratedCover from '$lib/components/view/generated-cover.svelte';
|
||||||
import { dismissDuplicateBooks } from '$lib/api/book.remote';
|
import { dismissDuplicateBooks } from '$lib/api/book.remote';
|
||||||
import MergeBooks from '$lib/components/forms/merge-books/merge-books.svelte';
|
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';
|
import type { Book, DuplicateBookGroup } from '$lib/schema';
|
||||||
|
|
||||||
let { data }: { data: { groups: DuplicateBookGroup[]; books: Book[] } } = $props();
|
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);
|
let merging = $state<DuplicateBookGroup | null>(null);
|
||||||
|
|
||||||
/** The full records behind a group, in the order the group lists them. */
|
/** The full records behind a group, in the order the group lists them. */
|
||||||
@@ -27,8 +29,33 @@
|
|||||||
.filter((book): book is Book => book !== undefined);
|
.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 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. */
|
/** A group is named by its lowest book id, which the backend orders it by. */
|
||||||
function keyOf(group: DuplicateBookGroup) {
|
function keyOf(group: DuplicateBookGroup) {
|
||||||
@@ -95,7 +122,8 @@
|
|||||||
</Empty.Root>
|
</Empty.Root>
|
||||||
{:else}
|
{:else}
|
||||||
{#each data.groups as group (keyOf(group))}
|
{#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.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title>{group.books.length} books look like the same book</Card.Title>
|
<Card.Title>{group.books.length} books look like the same book</Card.Title>
|
||||||
@@ -108,15 +136,46 @@
|
|||||||
>
|
>
|
||||||
Not duplicates
|
Not duplicates
|
||||||
</Button>
|
</Button>
|
||||||
<!-- Disabled until every record in the group came back, so the
|
<!-- A choice rather than a jump straight into the workbench:
|
||||||
dialog can never open on a partial group. -->
|
most groups are one book twice, where there is nothing to
|
||||||
<Button
|
resolve and the dialog is a step in the way.
|
||||||
size="sm"
|
|
||||||
disabled={busy || recordsFor(group).length !== group.books.length}
|
Disabled until every record in the group came back, so
|
||||||
onclick={() => (merging = group)}
|
neither action can run on a partial group. -->
|
||||||
>
|
<DropdownMenu.Root>
|
||||||
Merge…
|
<DropdownMenu.Trigger
|
||||||
</Button>
|
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.Action>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
@@ -195,7 +254,7 @@
|
|||||||
(value) => {
|
(value) => {
|
||||||
// Bound, not passed as a bare `true`: the dialog closes itself on an
|
// Bound, not passed as a bare `true`: the dialog closes itself on an
|
||||||
// outside click or Escape, and if that never reaches `merging` the
|
// 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.
|
// nothing changes, and the workbench never reopens.
|
||||||
if (!value) merging = null;
|
if (!value) merging = null;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user