Files
chitai/frontend/src/lib/components/forms/merge-books/field-spec.ts
T
patrick 699a1a7fa2 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.
2026-08-16 20:21:19 -04:00

150 lines
5.1 KiB
TypeScript

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);
}