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.
150 lines
5.1 KiB
TypeScript
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);
|
|
}
|