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:
@@ -9,6 +9,7 @@ import {
|
|||||||
editBookMetadataSchema,
|
editBookMetadataSchema,
|
||||||
updateBookProgressSchema,
|
updateBookProgressSchema,
|
||||||
duplicateDismissalSchema,
|
duplicateDismissalSchema,
|
||||||
|
bookMergeSchema,
|
||||||
type Book,
|
type Book,
|
||||||
type BooksUploadResult,
|
type BooksUploadResult,
|
||||||
type DuplicateBookGroup,
|
type DuplicateBookGroup,
|
||||||
@@ -174,6 +175,24 @@ export const listDuplicateBooks = query(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fold several books into one and delete the records folded in.
|
||||||
|
*
|
||||||
|
* Irreversible, so the caller is expected to have shown what is about to happen.
|
||||||
|
*/
|
||||||
|
export const mergeBooks = command(
|
||||||
|
bookMergeSchema,
|
||||||
|
async ({ library_id, ...data }): Promise<Book> => {
|
||||||
|
const { locals } = getRequestEvent();
|
||||||
|
|
||||||
|
const response = await locals.api.post(`/books/merge?library_id=${library_id}`, data);
|
||||||
|
|
||||||
|
if (!response.ok) error(response.status, detailOf(await response.text()));
|
||||||
|
|
||||||
|
return await response.json();
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
/** Record that two books are not the same book, so the pair stops being proposed. */
|
/** Record that two books are not the same book, so the pair stops being proposed. */
|
||||||
export const dismissDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
|
export const dismissDuplicateBooks = command(duplicateDismissalSchema, async (data) => {
|
||||||
const { locals } = getRequestEvent();
|
const { locals } = getRequestEvent();
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,15 +1,25 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
import { buttonVariants } from '$lib/components/ui/button/button.svelte';
|
||||||
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
import * as Tooltip from '$lib/components/ui/tooltip/index';
|
||||||
import { BookOpenCheck, Download, Trash2, SquareCheckBig, X, Album, PlusIcon } from '@lucide/svelte';
|
import {
|
||||||
|
BookOpenCheck,
|
||||||
|
Download,
|
||||||
|
GitMerge,
|
||||||
|
Trash2,
|
||||||
|
SquareCheckBig,
|
||||||
|
X,
|
||||||
|
Album,
|
||||||
|
PlusIcon
|
||||||
|
} from '@lucide/svelte';
|
||||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index';
|
||||||
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
import { getBookOperationsState } from '$lib/state/bookOperations.svelte';
|
||||||
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
import { getBookSelectionState } from '$lib/state/bookSelection.svelte';
|
||||||
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
|
import { getBookCollectionState } from '$lib/state/bookCollection.svelte';
|
||||||
import { getLibraryState } from '$lib/state/library.svelte';
|
import { getLibraryState } from '$lib/state/library.svelte';
|
||||||
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
import { getBookshelfState } from '$lib/state/bookshelf.svelte';
|
||||||
import { invalidate } from '$app/navigation';
|
|
||||||
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 type { Book } from '$lib/schema';
|
||||||
|
|
||||||
const libraryState = getLibraryState();
|
const libraryState = getLibraryState();
|
||||||
const bookshelfState = getBookshelfState();
|
const bookshelfState = getBookshelfState();
|
||||||
@@ -17,10 +27,14 @@
|
|||||||
const bookOps = getBookOperationsState();
|
const bookOps = getBookOperationsState();
|
||||||
const collectionState = getBookCollectionState();
|
const collectionState = getBookCollectionState();
|
||||||
|
|
||||||
let selectedBooks = $derived(selectionState.getSelectedBooks())
|
let selectedBooks = $derived(selectionState.getSelectedBooks());
|
||||||
|
|
||||||
let createShelfDialogOpen = $state(false)
|
let createShelfDialogOpen = $state(false);
|
||||||
|
|
||||||
|
// The books the merge dialog opened on. Snapshotted rather than read live from
|
||||||
|
// the selection, so clearing the selection on success cannot empty the dialog
|
||||||
|
// underneath itself.
|
||||||
|
let mergingBooks = $state<Book[] | null>(null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Mark selected as finished button -->
|
<!-- Mark selected as finished button -->
|
||||||
@@ -96,8 +110,9 @@
|
|||||||
</DropdownMenu.Group>
|
</DropdownMenu.Group>
|
||||||
<DropdownMenu.Separator />
|
<DropdownMenu.Separator />
|
||||||
<DropdownMenu.Item
|
<DropdownMenu.Item
|
||||||
onclick={() => createShelfDialogOpen = true}
|
onclick={() => (createShelfDialogOpen = true)}
|
||||||
class="text-muted-foreground ">
|
class="text-muted-foreground "
|
||||||
|
>
|
||||||
<PlusIcon class="size-4" />
|
<PlusIcon class="size-4" />
|
||||||
New Shelf
|
New Shelf
|
||||||
</DropdownMenu.Item>
|
</DropdownMenu.Item>
|
||||||
@@ -128,6 +143,24 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
|
||||||
|
<!-- Merge selected button. Two books is the smallest thing a merge can mean, so
|
||||||
|
it appears only once there are two. -->
|
||||||
|
{#if selectedBooks.length > 1}
|
||||||
|
<Tooltip.Provider>
|
||||||
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
|
<Tooltip.Trigger
|
||||||
|
onclick={() => (mergingBooks = selectedBooks)}
|
||||||
|
class={buttonVariants({ variant: 'ghost', size: 'icon' })}
|
||||||
|
>
|
||||||
|
<GitMerge />
|
||||||
|
</Tooltip.Trigger>
|
||||||
|
<Tooltip.Content>
|
||||||
|
<p>Merge {selectedBooks.length} books</p>
|
||||||
|
</Tooltip.Content>
|
||||||
|
</Tooltip.Root>
|
||||||
|
</Tooltip.Provider>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Delete selected button -->
|
<!-- Delete selected button -->
|
||||||
<Tooltip.Provider>
|
<Tooltip.Provider>
|
||||||
<Tooltip.Root ignoreNonKeyboardFocus>
|
<Tooltip.Root ignoreNonKeyboardFocus>
|
||||||
@@ -136,8 +169,8 @@
|
|||||||
bookOps.deleteDialogTitle = `Delete ${selectionState.getSelectedIds().length} books?`;
|
bookOps.deleteDialogTitle = `Delete ${selectionState.getSelectedIds().length} books?`;
|
||||||
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
bookOps.deleteFn = async (deleteFiles: boolean) => {
|
||||||
await bookOps.deleteBooks(selectionState.getSelectedIds(), deleteFiles);
|
await bookOps.deleteBooks(selectionState.getSelectedIds(), deleteFiles);
|
||||||
libraryState.activeLibrary!.total! -= selectedBooks.length
|
libraryState.activeLibrary!.total! -= selectedBooks.length;
|
||||||
bookshelfState.deletedBooks(selectedBooks)
|
bookshelfState.deletedBooks(selectedBooks);
|
||||||
selectionState.deselectAll();
|
selectionState.deselectAll();
|
||||||
};
|
};
|
||||||
bookOps.deleteDialogOpen = true;
|
bookOps.deleteDialogOpen = true;
|
||||||
@@ -186,14 +219,37 @@
|
|||||||
</Tooltip.Root>
|
</Tooltip.Root>
|
||||||
</Tooltip.Provider>
|
</Tooltip.Provider>
|
||||||
|
|
||||||
|
|
||||||
<ShelfCreateDialog
|
<ShelfCreateDialog
|
||||||
bind:open={createShelfDialogOpen}
|
bind:open={createShelfDialogOpen}
|
||||||
onSubmit={async (name: string) => {
|
onSubmit={async (name: string) => {
|
||||||
|
const bookshelf = await bookshelfState.addBookshelf(
|
||||||
const bookshelf = await bookshelfState.addBookshelf(name, libraryState.activeLibrary!.id, selectionState.getSelectedIds())
|
name,
|
||||||
selectedBooks.forEach(book => book.lists.push(bookshelf))
|
libraryState.activeLibrary!.id,
|
||||||
selectionState.deselectAll()
|
selectionState.getSelectedIds()
|
||||||
createShelfDialogOpen = false
|
);
|
||||||
|
selectedBooks.forEach((book) => book.lists.push(bookshelf));
|
||||||
|
selectionState.deselectAll();
|
||||||
|
createShelfDialogOpen = false;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Keyed on the selection so opening merge on a different pair starts from those
|
||||||
|
records rather than the previous dialog's draft.
|
||||||
|
-->
|
||||||
|
{#if mergingBooks}
|
||||||
|
{#key mergingBooks.map((book) => book.id).join()}
|
||||||
|
<MergeBooks
|
||||||
|
books={mergingBooks}
|
||||||
|
libraryId={libraryState.activeLibrary!.id}
|
||||||
|
open={true}
|
||||||
|
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();
|
||||||
|
mergingBooks = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|||||||
@@ -30,6 +30,19 @@ export type PossibleDuplicate = components['schemas']['PossibleDuplicateRead'];
|
|||||||
/** Books the library holds that all look like copies of one book. */
|
/** Books the library holds that all look like copies of one book. */
|
||||||
export type DuplicateBookGroup = components['schemas']['DuplicateBookGroupRead'];
|
export type DuplicateBookGroup = components['schemas']['DuplicateBookGroupRead'];
|
||||||
|
|
||||||
|
/** The metadata a reader resolved while merging, or while reviewing a provider. */
|
||||||
|
export type BookMetadataUpdate = components['schemas']['BookMetadataUpdate'];
|
||||||
|
|
||||||
|
/** Mirrors BookMerge in backend/src/chitai/schemas/book.py */
|
||||||
|
export const bookMergeSchema = z.object({
|
||||||
|
library_id: z.coerce.number(),
|
||||||
|
survivor_id: z.coerce.number(),
|
||||||
|
merged_ids: z.array(z.coerce.number()).min(1, 'Pick at least one book to fold in'),
|
||||||
|
// Passed through untouched — the backend validates it as BookMetadataUpdate, and
|
||||||
|
// duplicating that shape here would be two places to keep in step for no gain.
|
||||||
|
metadata: z.record(z.string(), z.unknown()).optional()
|
||||||
|
});
|
||||||
|
|
||||||
/** Mirrors DuplicateDismissal in backend/src/chitai/schemas/book.py */
|
/** Mirrors DuplicateDismissal in backend/src/chitai/schemas/book.py */
|
||||||
export const duplicateDismissalSchema = z.object({
|
export const duplicateDismissalSchema = z.object({
|
||||||
book_a_id: z.coerce.number(),
|
book_a_id: z.coerce.number(),
|
||||||
@@ -128,4 +141,5 @@ export type BookEditMetadata = z.infer<typeof editBookMetadataSchema>;
|
|||||||
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
export type DeleteBook = z.infer<typeof deleteBooksSchema>;
|
||||||
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
export type DeleteBookFiles = z.infer<typeof deleteBookFilesSchema>;
|
||||||
export type DuplicateDismissal = z.infer<typeof duplicateDismissalSchema>;
|
export type DuplicateDismissal = z.infer<typeof duplicateDismissalSchema>;
|
||||||
|
export type BookMerge = z.infer<typeof bookMergeSchema>;
|
||||||
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
export type UpdateBookProgress = z.infer<typeof updateBookProgressSchema>;
|
||||||
|
|||||||
+68
-4
@@ -162,6 +162,23 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/books/merge": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** MergeBooks */
|
||||||
|
post: operations["BooksMergeMergeBooks"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/books/progress": {
|
"/books/progress": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -689,6 +706,12 @@ export interface components {
|
|||||||
cover_image?: string | null;
|
cover_image?: string | null;
|
||||||
files?: string[];
|
files?: string[];
|
||||||
};
|
};
|
||||||
|
/** BookMerge */
|
||||||
|
BookMerge: {
|
||||||
|
survivor_id: number;
|
||||||
|
merged_ids: number[];
|
||||||
|
metadata?: components["schemas"]["BookMetadataUpdate"] | null;
|
||||||
|
};
|
||||||
/** BookMetadataUpdate */
|
/** BookMetadataUpdate */
|
||||||
BookMetadataUpdate: {
|
BookMetadataUpdate: {
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
@@ -1057,7 +1080,7 @@ export interface operations {
|
|||||||
tags?: number[] | null;
|
tags?: number[] | null;
|
||||||
shelves?: number[] | null;
|
shelves?: number[] | null;
|
||||||
progress?: string[] | null;
|
progress?: string[] | null;
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
@@ -1498,6 +1521,47 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
BooksMergeMergeBooks: {
|
||||||
|
parameters: {
|
||||||
|
query?: {
|
||||||
|
library_id?: number | null;
|
||||||
|
};
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BookMerge"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
/** @description Document created, URL follows */
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["BookRead"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Bad request syntax or unsupported method */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
status_code: number;
|
||||||
|
detail: string;
|
||||||
|
extra?: null | {
|
||||||
|
[key: string]: unknown;
|
||||||
|
} | unknown[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
BooksProgressSetBookProgressBatch: {
|
BooksProgressSetBookProgressBatch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query: {
|
query: {
|
||||||
@@ -2175,7 +2239,7 @@ export interface operations {
|
|||||||
tags?: number[] | null;
|
tags?: number[] | null;
|
||||||
shelves?: number[] | null;
|
shelves?: number[] | null;
|
||||||
progress?: string[] | null;
|
progress?: string[] | null;
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
@@ -2262,7 +2326,7 @@ export interface operations {
|
|||||||
OpdsLibraryLibraryIdCollectionTypeGetLibraryCollectionFeed: {
|
OpdsLibraryLibraryIdCollectionTypeGetLibraryCollectionFeed: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
@@ -2391,7 +2455,7 @@ export interface operations {
|
|||||||
tags?: number[] | null;
|
tags?: number[] | null;
|
||||||
shelves?: number[] | null;
|
shelves?: number[] | null;
|
||||||
progress?: string[] | null;
|
progress?: string[] | null;
|
||||||
ids?: string[] | null;
|
ids?: number[] | null;
|
||||||
searchString?: string | null;
|
searchString?: string | null;
|
||||||
searchIgnoreCase?: boolean | null;
|
searchIgnoreCase?: boolean | null;
|
||||||
currentPage?: number;
|
currentPage?: number;
|
||||||
|
|||||||
+13
-5
@@ -1,10 +1,18 @@
|
|||||||
import { listDuplicateBooks } from '$lib/api/book.remote.js';
|
import { listBooks, listDuplicateBooks } from '$lib/api/book.remote.js';
|
||||||
|
|
||||||
export async function load({ params, depends }) {
|
export async function load({ params, depends }) {
|
||||||
// Dismissing a group re-runs this, so the card leaves the screen.
|
// Dismissing or merging a group re-runs this, so the card leaves the screen.
|
||||||
depends('app:duplicate-books');
|
depends('app:duplicate-books');
|
||||||
|
|
||||||
return {
|
const groups = await listDuplicateBooks(params.libraryId);
|
||||||
groups: await listDuplicateBooks(params.libraryId)
|
|
||||||
};
|
// The groups carry only enough to render a card. Merging needs the whole record —
|
||||||
|
// identifiers, description, publisher — so fetch them in one go rather than per
|
||||||
|
// card, and let the dialog pick out the books for its own group.
|
||||||
|
const ids = [...new Set(groups.flatMap((group) => group.books.map((book) => book.book_id)))];
|
||||||
|
const books = ids.length
|
||||||
|
? await listBooks({ ids, pageSize: ids.length })
|
||||||
|
: { items: [] };
|
||||||
|
|
||||||
|
return { groups, books: books.items };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { page } from '$app/state';
|
||||||
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';
|
||||||
@@ -10,9 +11,20 @@
|
|||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } 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 { dismissDuplicateBooks } from '$lib/api/book.remote';
|
import { dismissDuplicateBooks } from '$lib/api/book.remote';
|
||||||
import type { DuplicateBookGroup } from '$lib/schema';
|
import MergeBooks from '$lib/components/forms/merge-books/merge-books.svelte';
|
||||||
|
import type { Book, DuplicateBookGroup } from '$lib/schema';
|
||||||
|
|
||||||
let { data }: { data: { groups: DuplicateBookGroup[] } } = $props();
|
let { data }: { data: { groups: DuplicateBookGroup[]; books: Book[] } } = $props();
|
||||||
|
|
||||||
|
// The group being merged, or null when the dialog is closed.
|
||||||
|
let merging = $state<DuplicateBookGroup | null>(null);
|
||||||
|
|
||||||
|
/** The full records behind a group, in the order the group lists them. */
|
||||||
|
function recordsFor(group: DuplicateBookGroup): Book[] {
|
||||||
|
return group.books
|
||||||
|
.map(({ book_id }) => data.books.find((book) => book.id === book_id))
|
||||||
|
.filter((book): book is Book => book !== undefined);
|
||||||
|
}
|
||||||
|
|
||||||
// Which groups are being dismissed, so a slow round trip cannot be started twice.
|
// Which groups are being dismissed, so a slow round trip cannot be started twice.
|
||||||
let dismissing = $state<number[]>([]);
|
let dismissing = $state<number[]>([]);
|
||||||
@@ -86,7 +98,7 @@
|
|||||||
<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>
|
||||||
<Card.Action>
|
<Card.Action class="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -95,6 +107,15 @@
|
|||||||
>
|
>
|
||||||
Not duplicates
|
Not duplicates
|
||||||
</Button>
|
</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>
|
||||||
</Card.Action>
|
</Card.Action>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
|
|
||||||
@@ -147,3 +168,18 @@
|
|||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Keyed on the group so a second merge starts from that group's records rather
|
||||||
|
than the previous one's draft, the same way the edit dialog keys on the book.
|
||||||
|
-->
|
||||||
|
{#if merging}
|
||||||
|
{#key merging.books[0].book_id}
|
||||||
|
<MergeBooks
|
||||||
|
books={recordsFor(merging)}
|
||||||
|
libraryId={page.params.libraryId!}
|
||||||
|
open={true}
|
||||||
|
onmerged={() => (merging = null)}
|
||||||
|
/>
|
||||||
|
{/key}
|
||||||
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user