From 699a1a7fa25c5ed560e237b8110ab93a48f9b1dc Mon Sep 17 00:00:00 2001 From: patrick Date: Sun, 16 Aug 2026 20:21:19 -0400 Subject: [PATCH] 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. --- frontend/src/lib/api/book.remote.ts | 19 + .../forms/merge-books/field-spec.ts | 149 +++++++ .../forms/merge-books/merge-books.svelte | 403 ++++++++++++++++++ .../view/batch-operations-toolbar.svelte | 96 ++++- frontend/src/lib/schema/book.ts | 14 + frontend/src/lib/schema/openapi/schema.d.ts | 72 +++- .../[libraryId]/duplicates/+page.server.ts | 18 +- .../[libraryId]/duplicates/+page.svelte | 42 +- 8 files changed, 781 insertions(+), 32 deletions(-) create mode 100644 frontend/src/lib/components/forms/merge-books/field-spec.ts create mode 100644 frontend/src/lib/components/forms/merge-books/merge-books.svelte diff --git a/frontend/src/lib/api/book.remote.ts b/frontend/src/lib/api/book.remote.ts index fd31d61..841a335 100644 --- a/frontend/src/lib/api/book.remote.ts +++ b/frontend/src/lib/api/book.remote.ts @@ -9,6 +9,7 @@ import { editBookMetadataSchema, updateBookProgressSchema, duplicateDismissalSchema, + bookMergeSchema, type Book, type BooksUploadResult, 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 => { + 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. */ export const dismissDuplicateBooks = command(duplicateDismissalSchema, async (data) => { const { locals } = getRequestEvent(); diff --git a/frontend/src/lib/components/forms/merge-books/field-spec.ts b/frontend/src/lib/components/forms/merge-books/field-spec.ts new file mode 100644 index 0000000..2714370 --- /dev/null +++ b/frontend/src/lib/components/forms/merge-books/field-spec.ts @@ -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 | 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)[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), ...(target as Record) }; + } + + 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) + .map(([name, id]) => `${name}: ${id}`) + .join(' · '); + } + return String(value); +} diff --git a/frontend/src/lib/components/forms/merge-books/merge-books.svelte b/frontend/src/lib/components/forms/merge-books/merge-books.svelte new file mode 100644 index 0000000..a3022e7 --- /dev/null +++ b/frontend/src/lib/components/forms/merge-books/merge-books.svelte @@ -0,0 +1,403 @@ + + +{#snippet cover(book: Book, size: string)} + + {#if book.cover_image} + + {/if} + +{/snippet} + + +{#snippet reference(field: FieldSpec, book: Book)} + {@const value = readField(book, field.key)} +
+ {field.label} + {#if isEmpty(value)} + empty + {:else if field.kind === 'list'} + + {#each value as string[] as item (item)} + {item} + {/each} + + {:else} + {displayValue(value)} + {/if} +
+{/snippet} + + + + + + Merge {books.length} books + + + {candidates.length} + {candidates.length === 1 ? 'record is' : 'records are'} deleted. Their files move onto the book + you keep — nothing is removed from disk. + + + +
+ + + +
+ +
+ Keeping + {#each books as book (book.id)} + + {/each} + +
+ +
+
+ +
+ {#if !candidate} +

Nothing left to fold in.

+ {:else if shown.length === 0} +

+ These records agree on every field. Merging keeps + #{survivor.id} and moves the others' files + onto it. +

+ {:else} + {#each groups as group (group)} +

+ {group} +

+ + {#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} +
+ {@render reference(field, candidate)} + + +
+ {#if edited} + + {/if} + + {#if !isSame(value, incoming)} + {#if isEmpty(value)} + + + {:else} + + {#each field.extra as action (action)} + + {/each} + {/if} + {/if} +
+ + +
+ + {field.label} + {#if edited} + taken + {/if} + + + {#if field.kind === 'longtext'} +