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
@@ -0,0 +1,149 @@
import type { Book } from '$lib/schema';
/**
* What kind of thing a metadata field holds, which is the only thing that decides
* what you can do with it when two records disagree.
*/
export type FieldKind = 'text' | 'number' | 'date' | 'list' | 'keyed' | 'longtext';
/** An action offered on a field, beyond replacing it outright. */
export type FieldAction = 'replace' | 'merge' | 'append';
export interface FieldSpec {
/** The key sent in `BookMetadataUpdate`. */
key: string;
label: string;
kind: FieldKind;
group: string;
/** Extra actions past `replace`, which every field has. */
extra: FieldAction[];
}
/**
* The fields a merge can resolve, in the order and grouping the edit form uses.
*
* Deliberately a spec rather than markup: the merge workbench and, later, the
* provider review screen both render from this, so a field cannot exist in one and
* not the other. `cover` and `files` are absent because they are not choices —
* files always come across and the cover has its own endpoint.
*/
export const MERGE_FIELDS: FieldSpec[] = [
{ key: 'title', label: 'Title', kind: 'text', group: 'Identity', extra: [] },
{ key: 'subtitle', label: 'Subtitle', kind: 'text', group: 'Identity', extra: [] },
{ key: 'edition', label: 'Edition', kind: 'number', group: 'Identity', extra: [] },
{ key: 'series', label: 'Series', kind: 'text', group: 'Identity', extra: [] },
{ key: 'series_position', label: 'No.', kind: 'text', group: 'Identity', extra: [] },
{ key: 'language', label: 'Language', kind: 'text', group: 'Identity', extra: [] },
// Order is meaningful for authors, so the second list is appended rather than
// interleaved; tags are a set, so they merge.
{
key: 'authors',
label: 'Authors',
kind: 'list',
group: 'People and subjects',
extra: ['append']
},
{ key: 'tags', label: 'Tags', kind: 'list', group: 'People and subjects', extra: ['merge'] },
{ key: 'publisher', label: 'Publisher', kind: 'text', group: 'Publication', extra: [] },
{ key: 'published_date', label: 'Published', kind: 'date', group: 'Publication', extra: [] },
{ key: 'pages', label: 'Pages', kind: 'number', group: 'Publication', extra: [] },
{
key: 'identifiers',
label: 'Identifiers',
kind: 'keyed',
group: 'Publication',
extra: ['merge']
},
{
key: 'description',
label: 'Description',
kind: 'longtext',
group: 'Description',
extra: ['append']
}
];
/** A field's value, in the shape `BookMetadataUpdate` expects to receive it. */
export type FieldValue = string | number | string[] | Record<string, string> | null;
/** Read one field off a book, flattening the relations the API returns as objects. */
export function readField(book: Book, key: string): FieldValue {
switch (key) {
case 'authors':
return book.authors.map((author) => author.name);
case 'tags':
return book.tags.map((tag) => tag.name);
case 'publisher':
return book.publisher?.name ?? null;
case 'series':
return book.series?.title ?? null;
case 'identifiers':
return book.identifiers ?? {};
default:
return (book as unknown as Record<string, FieldValue>)[key] ?? null;
}
}
/** Whether a field holds nothing, and so has no decision attached to it. */
export function isEmpty(value: FieldValue): boolean {
if (value === null || value === undefined || value === '') return true;
if (Array.isArray(value)) return value.length === 0;
if (typeof value === 'object') return Object.keys(value).length === 0;
return false;
}
/** Whether two field values say the same thing, order included for lists. */
export function isSame(left: FieldValue, right: FieldValue): boolean {
if (isEmpty(left) && isEmpty(right)) return true;
return JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
}
/**
* Apply an action to a pair of values and return what the target becomes.
*
* `merge` on a keyed collection is per name and the target wins a clash, because
* `Identifier` is unique on `(name, book_id)` — a book cannot hold both its print
* and its ebook ISBN, so the second one has nowhere to go.
*/
export function applyAction(
action: FieldAction,
kind: FieldKind,
target: FieldValue,
incoming: FieldValue
): FieldValue {
if (action === 'replace') return incoming;
if (kind === 'list') {
const current = Array.isArray(target) ? target : [];
const extra = Array.isArray(incoming) ? incoming : [];
// Order preserved, duplicates dropped — works for both append and merge.
return [...new Set([...current, ...extra])];
}
if (kind === 'keyed') {
return { ...(incoming as Record<string, string>), ...(target as Record<string, string>) };
}
if (kind === 'longtext') {
const current = typeof target === 'string' ? target.trim() : '';
const extra = typeof incoming === 'string' ? incoming.trim() : '';
return [current, extra].filter(Boolean).join('\n\n');
}
return incoming;
}
/** How a value reads in the inert reference column. */
export function displayValue(value: FieldValue): string {
if (isEmpty(value)) return '';
if (Array.isArray(value)) return value.join(', ');
if (typeof value === 'object') {
return Object.entries(value as Record<string, string>)
.map(([name, id]) => `${name}: ${id}`)
.join(' · ');
}
return String(value);
}
@@ -0,0 +1,403 @@
<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';
import { Button } from '$lib/components/ui/button/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Textarea } from '$lib/components/ui/textarea/index.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import BookImage from '$lib/components/view/book-image.svelte';
import { mergeBooks } from '$lib/api/book.remote';
import type { Book } from '$lib/schema';
import {
MERGE_FIELDS,
applyAction,
displayValue,
isEmpty,
isSame,
readField,
type FieldAction,
type FieldSpec,
type FieldValue
} from './field-spec';
let {
books,
libraryId,
open = $bindable(),
onmerged
}: {
books: Book[];
libraryId: number | string;
open: boolean;
/** Given the record that survived and the ones folded into it and deleted. */
onmerged?: (survivor: Book, folded: Book[]) => void;
} = $props();
// Seeded once per mount. The dialog is keyed on the group upstream, so a
// different group gets a fresh workbench rather than the previous one's draft.
let survivorId = $state(untrack(() => books[0]?.id));
let candidateId = $state(untrack(() => books[1]?.id));
let draft = $state<Record<string, FieldValue>>({});
let busy = $state(false);
const survivor = $derived(books.find((book) => book.id === survivorId) ?? books[0]);
const candidates = $derived(books.filter((book) => book.id !== survivorId));
/** The records that will be deleted — the same set, named for what happens to them. */
const folded = $derived(candidates);
const candidate = $derived(candidates.find((book) => book.id === candidateId) ?? candidates[0]);
/** The survivor's stored value for a field, or the draft if it has been touched. */
function current(field: FieldSpec): FieldValue {
return field.key in draft ? draft[field.key] : readField(survivor, field.key);
}
function take(field: FieldSpec, action: FieldAction) {
draft[field.key] = applyAction(
action,
field.kind,
current(field),
readField(candidate, field.key)
);
}
function undo(field: FieldSpec) {
delete draft[field.key];
draft = { ...draft };
}
/**
* Fill only the fields the survivor has nothing in.
*
* The safe bulk action, and the one worth reaching for: it cannot overwrite a
* value, so it needs no per-field protection to be pressed without reading.
*/
function fillEmpty() {
for (const field of MERGE_FIELDS) {
const incoming = readField(candidate, field.key);
if (isEmpty(current(field)) && !isEmpty(incoming)) draft[field.key] = incoming;
}
}
const changed = $derived(Object.keys(draft));
const differing = $derived(
candidate
? MERGE_FIELDS.filter((field) => !isSame(current(field), readField(candidate, field.key)))
: []
);
/** Fields shown as a row: anything the two disagree on, plus anything edited. */
const shown = $derived(
MERGE_FIELDS.filter((field) => differing.includes(field) || field.key in draft)
);
const agreed = $derived(MERGE_FIELDS.filter((field) => !shown.includes(field)));
const groups = $derived([...new Set(shown.map((field) => field.group))]);
async function submit() {
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
});
// 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')]);
toast.success(`Merged into “${survivor.title}”`);
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>
{#snippet cover(book: Book, size: string)}
<span class="{size} shrink-0 overflow-hidden rounded-sm border bg-muted">
{#if book.cover_image}
<BookImage src="/api/{book.cover_image}" class="h-full w-full object-cover" />
{/if}
</span>
{/snippet}
<!-- The inert reference column: the same shape as the control opposite it, with
nothing that invites a click. -->
{#snippet reference(field: FieldSpec, book: Book)}
{@const value = readField(book, field.key)}
<div class="flex min-w-0 flex-col gap-1">
<span class="text-[11px] text-muted-foreground">{field.label}</span>
{#if isEmpty(value)}
<span class="min-h-8 py-1 text-sm text-muted-foreground italic">empty</span>
{:else if field.kind === 'list'}
<span class="flex min-h-8 flex-wrap items-center gap-1 py-0.5">
{#each value as string[] as item (item)}
<Badge variant="secondary" class="font-normal">{item}</Badge>
{/each}
</span>
{:else}
<span class="min-h-8 py-1 text-sm break-words">{displayValue(value)}</span>
{/if}
</div>
{/snippet}
<Dialog.Root bind:open>
<Dialog.Content
class="flex h-[85vh] max-w-[min(72rem,95vw)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[min(72rem,95vw)]"
>
<Dialog.Header class="shrink-0 space-y-0 border-b px-5 py-3 text-left">
<Dialog.Title class="font-serif text-base font-normal">
Merge {books.length} books
</Dialog.Title>
<Dialog.Description class="text-xs">
{candidates.length}
{candidates.length === 1 ? 'record is' : 'records are'} deleted. Their files move onto the book
you keep — nothing is removed from disk.
</Dialog.Description>
</Dialog.Header>
<div class="grid min-h-0 flex-1 grid-cols-1 md:grid-cols-[15rem_1fr]">
<!-- Rail: the books being folded in, one open at a time -->
<aside
class="flex min-w-0 flex-col overflow-y-auto border-b bg-sidebar md:border-r md:border-b-0"
>
<p
class="px-3 pt-3 pb-1 font-mono text-[10px] tracking-widest text-muted-foreground uppercase"
>
Taking from · {candidates.length}
</p>
{#each candidates as book (book.id)}
{@const count = MERGE_FIELDS.filter(
(field) => !isSame(readField(survivor, field.key), readField(book, field.key))
).length}
<button
type="button"
onclick={() => (candidateId = book.id)}
class="flex items-start gap-2 border-l-2 px-3 py-2 text-left transition-colors hover:bg-muted/50 {book.id ===
candidate?.id
? 'border-l-primary bg-background'
: 'border-l-transparent'}"
>
{@render cover(book, 'h-10 w-7')}
<span class="min-w-0 flex-1">
<span class="line-clamp-2 font-serif text-[13px]">{book.title}</span>
<span class="block text-[10px] text-muted-foreground">
#{book.id} · {book.files.length}
{book.files.length === 1 ? 'file' : 'files'}
</span>
{#if count === 0}
<Badge variant="secondary" class="mt-1 text-[10px] font-normal">
nothing to take
</Badge>
{/if}
</span>
</button>
{/each}
</aside>
<div class="flex min-h-0 flex-col">
<!-- Which record survives -->
<div class="flex shrink-0 flex-wrap items-center gap-2 border-b px-5 py-2.5">
<span class="text-[11px] text-muted-foreground">Keeping</span>
{#each books as book (book.id)}
<button
type="button"
onclick={() => (survivorId = book.id)}
class="flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition-colors {book.id ===
survivorId
? 'border-primary bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-muted'}"
>
{@render cover(book, 'h-6 w-4')}
<span class="max-w-32 truncate">#{book.id}</span>
</button>
{/each}
<div class="ml-auto flex gap-2">
<Button variant="outline" size="sm" onclick={fillEmpty} disabled={!candidate}>
Fill empty fields
</Button>
</div>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-5 py-4">
{#if !candidate}
<p class="text-sm text-muted-foreground">Nothing left to fold in.</p>
{:else if shown.length === 0}
<p class="text-sm text-muted-foreground">
These records agree on every field. Merging keeps
<span class="font-medium text-foreground">#{survivor.id}</span> and moves the others' files
onto it.
</p>
{:else}
{#each groups as group (group)}
<h3
class="mt-5 border-b pb-1 font-mono text-[10px] tracking-widest text-primary uppercase first:mt-0"
>
{group}
</h3>
{#each shown.filter((field) => field.group === group) as field (field.key)}
{@const value = current(field)}
{@const incoming = readField(candidate, field.key)}
{@const edited = field.key in draft}
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-3 border-b py-2">
{@render reference(field, candidate)}
<!-- Actions, on the row rather than stacked beside it -->
<div class="flex items-center gap-1">
{#if edited}
<Button
variant="ghost"
size="icon"
class="size-7"
title="Undo"
onclick={() => undo(field)}
>
<Undo2 class="size-3.5" />
<span class="sr-only">Undo {field.label}</span>
</Button>
{/if}
{#if !isSame(value, incoming)}
{#if isEmpty(value)}
<!-- Nothing to weigh, so it is an offer rather than a choice -->
<Button
size="sm"
class="h-7 gap-1 px-2 text-[11px]"
onclick={() => take(field, 'replace')}
>
<ArrowRight class="size-3.5" />
Take
</Button>
{:else}
<Button
variant="outline"
size="icon"
class="size-7"
title="Replace"
onclick={() => take(field, 'replace')}
>
<ArrowRight class="size-3.5" />
<span class="sr-only">Replace {field.label}</span>
</Button>
{#each field.extra as action (action)}
<Button
variant="outline"
size="icon"
class="size-7"
title={action === 'merge' ? 'Merge' : 'Append'}
onclick={() => take(field, action)}
>
{#if action === 'merge'}
<GitMerge class="size-3.5" />
{:else}
<Plus class="size-3.5" />
{/if}
<span class="sr-only">{action} {field.label}</span>
</Button>
{/each}
{/if}
{/if}
</div>
<!-- The survivor's side: the edit form, live -->
<div class="flex min-w-0 flex-col gap-1">
<span class="flex items-center gap-1.5 text-[11px] text-muted-foreground">
{field.label}
{#if edited}
<Badge class="h-4 px-1.5 text-[9px] font-semibold">taken</Badge>
{/if}
</span>
{#if field.kind === 'longtext'}
<Textarea
rows={4}
class="text-sm"
value={(value as string) ?? ''}
oninput={(event) => (draft[field.key] = event.currentTarget.value)}
/>
{:else if field.kind === 'list' || field.kind === 'keyed'}
<!-- Edited through the take actions; typing here would need the
tags and key/value editors, which belong to the edit form. -->
<div
class="flex min-h-8 flex-wrap items-center gap-1 rounded-md border bg-background px-2 py-1"
>
{#if isEmpty(value)}
<span class="text-sm text-muted-foreground"></span>
{:else if field.kind === 'list'}
{#each value as string[] as item (item)}
<Badge variant="secondary" class="font-normal">{item}</Badge>
{/each}
{:else}
{#each Object.entries(value as Record<string, string>) as [name, id] (name)}
<Badge variant="secondary" class="font-mono text-[10px] font-normal">
{name}: {id}
</Badge>
{/each}
{/if}
</div>
{:else}
<Input
type={field.kind === 'number'
? 'number'
: field.kind === 'date'
? 'date'
: 'text'}
class="h-8 text-sm"
value={(value as string | number) ?? ''}
oninput={(event) =>
(draft[field.key] =
field.kind === 'number'
? Number(event.currentTarget.value) || null
: event.currentTarget.value)}
/>
{/if}
</div>
</div>
{/each}
{/each}
{#if agreed.length > 0}
<p class="pt-3 text-center text-[11px] text-muted-foreground italic">
{agreed.map((field) => field.label).join(', ')} — identical in both
</p>
{/if}
{/if}
</div>
</div>
</div>
<Dialog.Footer class="shrink-0 items-center gap-2 border-t px-5 py-3 sm:justify-between">
<p class="text-xs text-muted-foreground">
{#if changed.length}
{changed.length}
{changed.length === 1 ? 'change' : 'changes'} pending · this cannot be undone
{:else}
Metadata is left as #{survivor.id} has it · this cannot be undone
{/if}
</p>
<div class="flex gap-2">
<Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
<Button onclick={submit} disabled={busy || candidates.length === 0}>
Merge into “{survivor.title}
</Button>
</div>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>