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