diff --git a/src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx b/src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx new file mode 100644 index 0000000..50d2fd4 --- /dev/null +++ b/src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx @@ -0,0 +1,522 @@ +import React, { ReactElement, useMemo, useState } from "react" +import { Drawer } from "vaul" +import { FIELD_CONFIG, FIELD_GROUPS } from "./reconciler.fieldConfig" +import { + useReconciler, + SourceKey, + SOURCE_LABELS, + RawSourcedMetadata, + RawInferredMetadata, + CanonicalRecord, +} from "./useReconciler" + +// ── Source styling ───────────────────────────────────────────────────────────── + +const SOURCE_BADGE: Record = { + comicvine: "bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300", + metron: "bg-purple-100 text-purple-800 dark:bg-purple-900/40 dark:text-purple-300", + gcd: "bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300", + locg: "bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300", + comicInfo: "bg-slate-100 text-slate-700 dark:bg-slate-700/60 dark:text-slate-300", + inferredMetadata: "bg-gray-100 text-gray-700 dark:bg-gray-700/60 dark:text-gray-300", +} + +const SOURCE_SELECTED: Record = { + comicvine: "ring-2 ring-blue-400 bg-blue-50 dark:bg-blue-900/20", + metron: "ring-2 ring-purple-400 bg-purple-50 dark:bg-purple-900/20", + gcd: "ring-2 ring-orange-400 bg-orange-50 dark:bg-orange-900/20", + locg: "ring-2 ring-teal-400 bg-teal-50 dark:bg-teal-900/20", + comicInfo: "ring-2 ring-slate-400 bg-slate-50 dark:bg-slate-700/40", + inferredMetadata: "ring-2 ring-gray-400 bg-gray-50 dark:bg-gray-700/40", +} + +/** Abbreviated source names for compact badge display. */ +const SOURCE_SHORT: Record = { + comicvine: "CV", + metron: "Metron", + gcd: "GCD", + locg: "LoCG", + comicInfo: "XML", + inferredMetadata: "Local", +} + +const SOURCE_ORDER: SourceKey[] = [ + "comicvine", "metron", "gcd", "locg", "comicInfo", "inferredMetadata", +] + +type FilterMode = "all" | "conflicts" | "unresolved" + +// ── Props ────────────────────────────────────────────────────────────────────── + +export interface ReconcilerDrawerProps { + open: boolean + onOpenChange: (open: boolean) => void + sourcedMetadata: RawSourcedMetadata + inferredMetadata?: RawInferredMetadata + onSave: (record: CanonicalRecord) => void +} + +// ── Scalar cell ──────────────────────────────────────────────────────────────── + +interface ScalarCellProps { + value: string | null + isSelected: boolean + isImage: boolean + isLongtext: boolean + onClick: () => void +} + +function ScalarCell({ value, isSelected, isImage, isLongtext, onClick }: ScalarCellProps): ReactElement { + if (!value) { + return + } + + return ( + + ) +} + +// ── Main component ───────────────────────────────────────────────────────────── + +export function ReconcilerDrawer({ + open, + onOpenChange, + sourcedMetadata, + inferredMetadata, + onSave, +}: ReconcilerDrawerProps): ReactElement { + const [filter, setFilter] = useState("all") + + const { + state, + unresolvedCount, + canonicalRecord, + selectScalar, + toggleItem, + setBaseSource, + reset, + } = useReconciler(sourcedMetadata, inferredMetadata) + + // Derive which sources actually contributed data + const activeSources = useMemo(() => { + const seen = new Set() + for (const fieldState of Object.values(state)) { + if (fieldState.kind === "scalar") { + for (const c of fieldState.candidates) seen.add(c.source) + } else if (fieldState.kind === "array" || fieldState.kind === "credits") { + for (const item of fieldState.items) seen.add((item as { source: SourceKey }).source) + } + } + return SOURCE_ORDER.filter((s) => seen.has(s)) + }, [state]) + + // Grid: 180px label + one equal column per active source + const gridCols = `180px repeat(${Math.max(activeSources.length, 1)}, minmax(0, 1fr))` + + function shouldShow(fieldKey: string): boolean { + const fs = state[fieldKey] + if (!fs) return false + if (filter === "all") return true + if (filter === "conflicts") { + if (fs.kind === "scalar") return fs.candidates.length > 1 + if (fs.kind === "array" || fs.kind === "credits") { + const srcs = new Set((fs.items as Array<{ source: SourceKey }>).map((i) => i.source)) + return srcs.size > 1 + } + return false + } + // unresolved + return ( + fs.kind === "scalar" && + fs.candidates.length > 1 && + fs.selectedSource === null && + fs.userValue === undefined + ) + } + + const allResolved = unresolvedCount === 0 + + return ( + + + + + Reconcile metadata sources + + {/* ── Header ── */} +
+ {/* Title + controls */} +
+
+ + + Reconcile Metadata + + {unresolvedCount > 0 && ( + + {unresolvedCount} unresolved + + )} +
+ +
+ {/* Filter pill */} +
+ {(["all", "conflicts", "unresolved"] as FilterMode[]).map((mode) => ( + + ))} +
+ + + + +
+
+ + {/* Source column headers */} +
+
+ Field +
+ {activeSources.map((src) => ( +
+ + {SOURCE_LABELS[src]} + + +
+ ))} +
+
+ + {/* ── Scrollable body ── */} +
+ {FIELD_GROUPS.map((group) => { + const fieldsInGroup = Object.entries(FIELD_CONFIG) + .filter(([, cfg]) => cfg.group === group) + .filter(([key]) => shouldShow(key)) + + if (fieldsInGroup.length === 0) return null + + return ( +
+ {/* Group sticky header */} +
+ + {group} + +
+ + {/* Field rows */} + {fieldsInGroup.map(([fieldKey, fieldCfg]) => { + const fs = state[fieldKey] + if (!fs) return null + + const isUnresolved = + fs.kind === "scalar" && + fs.candidates.length > 1 && + fs.selectedSource === null && + fs.userValue === undefined + + return ( +
+ {/* Label column */} +
+ + {fieldCfg.label} + + {fieldCfg.comicInfoKey && ( + + {fieldCfg.comicInfoKey} + + )} + {isUnresolved && ( + + + conflict + + )} +
+ + {/* Content — varies by kind */} + {fs.kind === "scalar" ? ( + // One cell per active source + activeSources.map((src) => { + const candidate = fs.candidates.find((c) => c.source === src) + const isSelected = fs.selectedSource === src + + // For selected state we need the source-specific color + const selectedClass = isSelected ? SOURCE_SELECTED[src] : "" + + if (!candidate) { + return ( + + — + + ) + } + + return ( + + ) + }) + ) : fs.kind === "array" ? ( + // Merged list spanning all source columns +
+ {fs.items.length === 0 ? ( + No data + ) : ( + fs.items.map((item) => ( + + )) + )} +
+ ) : fs.kind === "credits" ? ( + // Credits spanning all source columns +
+ {fs.items.length === 0 ? ( + No data + ) : ( + fs.items.map((item) => ( + + )) + )} +
+ ) : ( + // GTIN and other complex types +
+ + Structured field — editor coming soon + +
+ )} +
+ ) + })} +
+ ) + })} + + {/* Empty state when filter hides everything */} + {FIELD_GROUPS.every((group) => + Object.entries(FIELD_CONFIG) + .filter(([, cfg]) => cfg.group === group) + .every(([key]) => !shouldShow(key)), + ) && ( +
+ + + {filter === "unresolved" ? "No unresolved conflicts" : "No fields match the current filter"} + +
+ )} +
+ + {/* ── Footer ── */} +
+
+ {allResolved ? ( + + + All conflicts resolved + + ) : ( + + + {unresolvedCount} field{unresolvedCount !== 1 ? "s" : ""} still need a value + + )} +
+ +
+ + +
+
+
+
+
+ ) +} diff --git a/src/client/components/ComicDetail/Tabs/VolumeInformation.tsx b/src/client/components/ComicDetail/Tabs/VolumeInformation.tsx index 385c2fa..d4c912f 100644 --- a/src/client/components/ComicDetail/Tabs/VolumeInformation.tsx +++ b/src/client/components/ComicDetail/Tabs/VolumeInformation.tsx @@ -1,7 +1,11 @@ import React, { ReactElement, useMemo, useState } from "react"; import { isEmpty, isNil } from "lodash"; -import { Drawer } from "vaul"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; import ComicVineDetails from "../ComicVineDetails"; +import { ReconcilerDrawer } from "./ReconcilerDrawer"; +import { fetcher } from "../../../graphql/fetcher"; +import { useGetComicByIdQuery } from "../../../graphql/generated"; +import type { CanonicalRecord } from "./useReconciler"; interface ComicVineMetadata { volumeInformation?: Record; @@ -21,6 +25,7 @@ interface SourcedMetadata { } interface VolumeInformationData { + id?: string; sourcedMetadata?: SourcedMetadata; inferredMetadata?: { issue?: unknown }; updatedAt?: string; @@ -31,6 +36,14 @@ interface VolumeInformationProps { onReconcile?: () => void; } +const SET_METADATA_FIELD = ` + mutation SetMetadataField($comicId: ID!, $field: String!, $value: String!) { + setMetadataField(comicId: $comicId, field: $field, value: $value) { + id + } + } +`; + /** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */ const SOURCED_METADATA_KEYS = [ "comicvine", @@ -60,55 +73,40 @@ const SOURCE_ICONS: Record = { const MetadataSourceChips = ({ sources, + onOpenReconciler, }: { sources: string[]; + onOpenReconciler: () => void; }): ReactElement => { - const [isSheetOpen, setSheetOpen] = useState(false); - return ( - <> -
-
- - - {sources.length} metadata sources detected +
+
+ + + {sources.length} metadata sources detected + +
+
+ {sources.map((source) => ( + + + {SOURCE_LABELS[source] ?? source} -
-
- {sources.map((source) => ( - - - {SOURCE_LABELS[source] ?? source} - - ))} -
+ ))}
- - - - - - Reconcile metadata sources -
-
- {/* Reconciliation UI goes here */} -
- - - - +
); }; @@ -127,6 +125,35 @@ export const VolumeInformation = ( props: VolumeInformationProps, ): ReactElement => { const { data } = props; + const [isReconcilerOpen, setReconcilerOpen] = useState(false); + const queryClient = useQueryClient(); + + const { mutate: saveCanonical } = useMutation({ + mutationFn: async (record: CanonicalRecord) => { + const saves = Object.entries(record) + .filter(([, fv]) => fv != null) + .map(([field, fv]) => ({ + field, + value: + typeof fv!.value === "string" + ? fv!.value + : JSON.stringify(fv!.value), + })); + await Promise.all( + saves.map(({ field, value }) => + fetcher( + SET_METADATA_FIELD, + { comicId: data.id ?? "", field, value }, + )(), + ), + ); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: useGetComicByIdQuery.getKey({ id: data.id ?? "" }), + }); + }, + }); const presentSources = useMemo(() => { const sources = SOURCED_METADATA_KEYS.filter((key) => { @@ -151,7 +178,10 @@ export const VolumeInformation = ( return (
{presentSources.length > 1 && ( - + setReconcilerOpen(true)} + /> )} {presentSources.length === 1 && data.sourcedMetadata?.comicvine?.volumeInformation && ( @@ -160,6 +190,13 @@ export const VolumeInformation = ( updatedAt={data.updatedAt} /> )} +
); }; diff --git a/src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts b/src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts new file mode 100644 index 0000000..cdeddb8 --- /dev/null +++ b/src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts @@ -0,0 +1,285 @@ +/** + * UI field configuration for the metadata reconciler. + * + * Each entry maps a CanonicalMetadata field key to: + * - label Display name shown in the reconciler table + * - group Which section the field belongs to + * - renderAs How the field's cell is rendered (drives component selection) + * - comicInfoKey The ComicInfo.xml v1 key this field exports to, or null if + * the field has no v1 equivalent (shown with a badge in the UI) + * + * The order of entries within each group controls row order in the table. + */ + +export type RenderType = + | "scalar" // Single string/number — click to select + | "date" // ISO date string — click to select + | "longtext" // Multi-line text — click to select, expandable preview + | "image" // Cover image — thumbnail grid picker + | "array" // Flat list of strings with source badges + | "arcs" // [{name, number}] — arc name + position number + | "universes" // [{name, designation}] — universe name + designation + | "credits" // [{name, role}] — role-grouped, toggleable list + | "seriesInfo" // Structured series object — rendered as sub-fields + | "prices" // [{country, amount, currency}] + | "gtin" // {isbn, upc} + | "reprints" // [{description}] + | "urls" // [{url, primary}] + | "externalIDs" // [{source, externalId, primary}] + +export type FieldGroup = + | "Identity" + | "Series" + | "Publication" + | "Content" + | "Credits" + | "Classification" + | "Physical" + | "Commercial" + | "External" + +/** Ordered list of groups — controls section order in the reconciler table. */ +export const FIELD_GROUPS: FieldGroup[] = [ + "Identity", + "Series", + "Publication", + "Content", + "Credits", + "Classification", + "Physical", + "Commercial", + "External", +] + +export interface FieldConfig { + label: string + group: FieldGroup + renderAs: RenderType + /** + * ComicInfo.xml v1 key this field maps to on export. + * null means the field is not exported to ComicInfo v1. + */ + comicInfoKey: string | null +} + +/** + * Master field registry for the reconciler. + * Keys match CanonicalMetadata field names from the core-service GraphQL schema. + */ +export const FIELD_CONFIG: Record = { + // ── Identity ────────────────────────────────────────────────────────────── + title: { + label: "Title", + group: "Identity", + renderAs: "scalar", + comicInfoKey: null, + }, + series: { + label: "Series", + group: "Identity", + renderAs: "scalar", + comicInfoKey: "series", + }, + issueNumber: { + label: "Issue Number", + group: "Identity", + renderAs: "scalar", + comicInfoKey: "number", + }, + volume: { + label: "Volume", + group: "Identity", + renderAs: "scalar", + comicInfoKey: null, + }, + collectionTitle: { + label: "Collection Title", + group: "Identity", + renderAs: "scalar", + comicInfoKey: null, + }, + + // ── Series ──────────────────────────────────────────────────────────────── + seriesInfo: { + label: "Series Info", + group: "Series", + renderAs: "seriesInfo", + comicInfoKey: null, + }, + + // ── Publication ─────────────────────────────────────────────────────────── + publisher: { + label: "Publisher", + group: "Publication", + renderAs: "scalar", + comicInfoKey: "publisher", + }, + imprint: { + label: "Imprint", + group: "Publication", + renderAs: "scalar", + comicInfoKey: null, + }, + coverDate: { + label: "Cover Date", + group: "Publication", + renderAs: "date", + comicInfoKey: null, + }, + storeDate: { + label: "Store Date", + group: "Publication", + renderAs: "date", + comicInfoKey: null, + }, + publicationDate: { + label: "Publication Date", + group: "Publication", + renderAs: "date", + comicInfoKey: null, + }, + language: { + label: "Language", + group: "Publication", + renderAs: "scalar", + comicInfoKey: "languageiso", + }, + + // ── Content ─────────────────────────────────────────────────────────────── + description: { + label: "Description", + group: "Content", + renderAs: "longtext", + comicInfoKey: "summary", + }, + notes: { + label: "Notes", + group: "Content", + renderAs: "longtext", + comicInfoKey: "notes", + }, + stories: { + label: "Stories", + group: "Content", + renderAs: "array", + comicInfoKey: null, + }, + storyArcs: { + label: "Story Arcs", + group: "Content", + renderAs: "arcs", + comicInfoKey: null, + }, + characters: { + label: "Characters", + group: "Content", + renderAs: "array", + comicInfoKey: null, + }, + teams: { + label: "Teams", + group: "Content", + renderAs: "array", + comicInfoKey: null, + }, + locations: { + label: "Locations", + group: "Content", + renderAs: "array", + comicInfoKey: null, + }, + universes: { + label: "Universes", + group: "Content", + renderAs: "universes", + comicInfoKey: null, + }, + coverImage: { + label: "Cover Image", + group: "Content", + renderAs: "image", + comicInfoKey: null, + }, + + // ── Credits ─────────────────────────────────────────────────────────────── + creators: { + label: "Credits", + group: "Credits", + renderAs: "credits", + comicInfoKey: null, + }, + + // ── Classification ──────────────────────────────────────────────────────── + genres: { + label: "Genres", + group: "Classification", + renderAs: "array", + comicInfoKey: "genre", + }, + tags: { + label: "Tags", + group: "Classification", + renderAs: "array", + comicInfoKey: null, + }, + ageRating: { + label: "Age Rating", + group: "Classification", + renderAs: "scalar", + comicInfoKey: null, + }, + + // ── Physical ────────────────────────────────────────────────────────────── + pageCount: { + label: "Page Count", + group: "Physical", + renderAs: "scalar", + comicInfoKey: "pagecount", + }, + format: { + label: "Format", + group: "Physical", + renderAs: "scalar", + comicInfoKey: null, + }, + + // ── Commercial ──────────────────────────────────────────────────────────── + prices: { + label: "Prices", + group: "Commercial", + renderAs: "prices", + comicInfoKey: null, + }, + gtin: { + label: "ISBN / UPC", + group: "Commercial", + renderAs: "gtin", + comicInfoKey: null, + }, + reprints: { + label: "Reprints", + group: "Commercial", + renderAs: "reprints", + comicInfoKey: null, + }, + communityRating: { + label: "Community Rating", + group: "Commercial", + renderAs: "scalar", + comicInfoKey: null, + }, + + // ── External ────────────────────────────────────────────────────────────── + externalIDs: { + label: "Source IDs", + group: "External", + renderAs: "externalIDs", + comicInfoKey: null, + }, + urls: { + label: "URLs", + group: "External", + renderAs: "urls", + comicInfoKey: "web", + }, +} as const diff --git a/src/client/components/ComicDetail/Tabs/useReconciler.ts b/src/client/components/ComicDetail/Tabs/useReconciler.ts new file mode 100644 index 0000000..e3c71fc --- /dev/null +++ b/src/client/components/ComicDetail/Tabs/useReconciler.ts @@ -0,0 +1,745 @@ +import { useReducer, useMemo } from "react"; +import { isNil, isEmpty } from "lodash"; + +// ── Source keys ──────────────────────────────────────────────────────────────── + +export type SourceKey = + | "comicvine" + | "metron" + | "gcd" + | "locg" + | "comicInfo" + | "inferredMetadata"; + +export const SOURCE_LABELS: Record = { + comicvine: "ComicVine", + metron: "Metron", + gcd: "Grand Comics Database", + locg: "League of Comic Geeks", + comicInfo: "ComicInfo.xml", + inferredMetadata: "Local File", +}; + +// ── Candidate types ──────────────────────────────────────────────────────────── + +/** One source's value for a scalar field. Multiple candidates for the same field = conflict. */ +export interface ScalarCandidate { + source: SourceKey; + value: string; +} + +/** One item in an array field (characters, genres, arcs…). Pre-selected; user may deselect. */ +export interface ArrayItem { + /** Lowercase dedup key. */ + itemKey: string; + displayValue: string; + /** Raw value passed through to the canonical record. */ + rawValue: unknown; + source: SourceKey; + selected: boolean; +} + +/** One person credit. Dedup key is `"${name}:${role}"` (lowercased). */ +export interface CreditItem { + itemKey: string; + id?: string; + name: string; + role: string; + source: SourceKey; + selected: boolean; +} + +// ── Per-field state ──────────────────────────────────────────────────────────── + +/** Unresolved when `selectedSource === null` and `userValue` is absent. */ +interface ScalarFieldState { + kind: "scalar"; + candidates: ScalarCandidate[]; + selectedSource: SourceKey | null; + /** User-typed override; takes precedence over any source value. */ + userValue?: string; +} + +interface ArrayFieldState { + kind: "array"; + items: ArrayItem[]; +} + +interface CreditsFieldState { + kind: "credits"; + items: CreditItem[]; +} + +interface GTINFieldState { + kind: "gtin"; + candidates: Array<{ source: SourceKey; isbn?: string; upc?: string }>; + selectedIsbnSource: SourceKey | null; + selectedUpcSource: SourceKey | null; +} + +type FieldState = ScalarFieldState | ArrayFieldState | CreditsFieldState | GTINFieldState; + +/** Full reconciler state — one entry per field that has data from at least one source. */ +export type ReconcilerState = Record; + +// ── Raw source data ──────────────────────────────────────────────────────────── + +/** Raw metadata payloads keyed by source, as stored on the comic document. */ +export interface RawSourcedMetadata { + comicvine?: Record; + /** May arrive as a JSON string; normalised by `ensureParsed`. */ + metron?: unknown; + /** May arrive as a JSON string; normalised by `ensureParsed`. */ + gcd?: unknown; + locg?: Record; + /** May arrive as a JSON string; normalised by `ensureParsed`. */ + comicInfo?: Record; +} + +/** Metadata inferred from the local file name / path. */ +export interface RawInferredMetadata { + issue?: { + name?: string; + number?: number; + year?: string; + subtitle?: string; + }; +} + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function safeString(v: unknown): string | null { + if (isNil(v) || v === "") return null; + return String(v); +} + +/** xml2js with `normalizeTags` wraps every value in a single-element array. */ +function xmlVal(obj: Record, key: string): string | null { + const arr = obj[key]; + if (!Array.isArray(arr) || arr.length === 0) return null; + return safeString(arr[0]); +} + +/** Parse a JSON string if it hasn't been parsed yet. */ +function ensureParsed(v: unknown): Record | null { + if (isNil(v)) return null; + if (typeof v === "string") { + try { + return JSON.parse(v); + } catch { + return null; + } + } + if (typeof v === "object") return v as Record; + return null; +} + +function makeScalarCandidate( + source: SourceKey, + value: unknown, +): ScalarCandidate | undefined { + const val = safeString(value); + return val ? { source, value: val } : undefined; +} + +function makeArrayItem( + source: SourceKey, + rawValue: unknown, + displayValue: string, +): ArrayItem { + return { + itemKey: displayValue.toLowerCase().trim(), + displayValue, + rawValue, + source, + selected: true, + }; +} + +function makeCreditItem( + source: SourceKey, + name: string, + role: string, + id?: string, +): CreditItem { + return { + itemKey: `${name.toLowerCase().trim()}:${role.toLowerCase().trim()}`, + id, + name, + role, + source, + selected: true, + }; +} + +// ── Source adapters ──────────────────────────────────────────────────────────── + +type AdapterResult = Partial>; + +/** + * Extract canonical fields from a ComicVine issue payload. + * Volume info lives under `volumeInformation`; credits under `person_credits` etc. + */ +function fromComicVine(cv: Record): AdapterResult { + const s: SourceKey = "comicvine"; + const vi = cv.volumeInformation as Record | undefined; + const img = cv.image as Record | undefined; + const publisher = vi?.publisher as Record | undefined; + + return { + title: makeScalarCandidate(s, cv.name), + series: makeScalarCandidate(s, vi?.name), + issueNumber: makeScalarCandidate(s, cv.issue_number), + volume: makeScalarCandidate(s, vi?.id), + description: makeScalarCandidate(s, cv.description), + publisher: makeScalarCandidate(s, publisher?.name), + coverDate: makeScalarCandidate(s, cv.cover_date), + storeDate: makeScalarCandidate(s, cv.store_date), + coverImage: makeScalarCandidate(s, img?.super_url ?? img?.small_url), + characters: ((cv.character_credits as unknown[]) ?? []) + .filter((c): c is Record => !isNil(c)) + .map((c) => makeArrayItem(s, c, safeString(c.name) ?? "")), + teams: ((cv.team_credits as unknown[]) ?? []) + .filter((t): t is Record => !isNil(t)) + .map((t) => makeArrayItem(s, t, safeString(t.name) ?? "")), + locations: ((cv.location_credits as unknown[]) ?? []) + .filter((l): l is Record => !isNil(l)) + .map((l) => makeArrayItem(s, l, safeString(l.name) ?? "")), + storyArcs: ((cv.story_arc_credits as unknown[]) ?? []) + .filter((a): a is Record => !isNil(a)) + .map((a) => makeArrayItem(s, a, safeString(a.name) ?? "")), + creators: ((cv.person_credits as unknown[]) ?? []) + .filter((p): p is Record => !isNil(p)) + .map((p) => + makeCreditItem(s, safeString(p.name) ?? "", safeString(p.role) ?? ""), + ), + }; +} + +/** + * Extract canonical fields from a Metron / MetronInfo payload. + * Keys are PascalCase mirroring the MetronInfo XSD schema. + */ +function fromMetron(raw: Record): AdapterResult { + const s: SourceKey = "metron"; + const series = raw.Series as Record | undefined; + const pub = raw.Publisher as Record | undefined; + + const nameList = (arr: unknown[]): ArrayItem[] => + arr + .filter((x): x is Record => !isNil(x)) + .map((x) => makeArrayItem(s, x, safeString(x.name) ?? "")); + + return { + title: makeScalarCandidate(s, (raw.Stories as unknown[])?.[0]), + series: makeScalarCandidate(s, series?.Name), + issueNumber: makeScalarCandidate(s, raw.Number), + collectionTitle: makeScalarCandidate(s, raw.CollectionTitle), + publisher: makeScalarCandidate(s, pub?.Name), + imprint: makeScalarCandidate(s, pub?.Imprint), + coverDate: makeScalarCandidate(s, raw.CoverDate), + storeDate: makeScalarCandidate(s, raw.StoreDate), + description: makeScalarCandidate(s, raw.Summary), + notes: makeScalarCandidate(s, raw.Notes), + ageRating: makeScalarCandidate(s, raw.AgeRating), + pageCount: makeScalarCandidate(s, raw.PageCount), + format: makeScalarCandidate(s, series?.Format), + language: makeScalarCandidate(s, series?.lang), + genres: nameList((raw.Genres as unknown[]) ?? []), + tags: ((raw.Tags as unknown[]) ?? []) + .filter((t) => !isNil(t)) + .map((t) => makeArrayItem(s, t, safeString(t) ?? "")), + characters: nameList((raw.Characters as unknown[]) ?? []), + teams: nameList((raw.Teams as unknown[]) ?? []), + locations: nameList((raw.Locations as unknown[]) ?? []), + universes: ((raw.Universes as unknown[]) ?? []) + .filter((u): u is Record => !isNil(u)) + .map((u) => + makeArrayItem( + s, + u, + [u.Name, u.Designation].filter(Boolean).join(" — "), + ), + ), + storyArcs: ((raw.Arcs as unknown[]) ?? []) + .filter((a): a is Record => !isNil(a)) + .map((a) => + makeArrayItem( + s, + a, + [a.Name, a.Number ? `#${a.Number}` : null].filter(Boolean).join(" "), + ), + ), + stories: ((raw.Stories as unknown[]) ?? []) + .filter((t) => !isNil(t)) + .map((t) => makeArrayItem(s, t, safeString(t) ?? "")), + creators: ((raw.Credits as unknown[]) ?? []) + .filter((c): c is Record => !isNil(c)) + .flatMap((c) => { + const creator = c.Creator as Record | undefined; + const roles = (c.Roles as unknown[]) ?? []; + return roles + .filter((r): r is Record => !isNil(r)) + .map((r) => + makeCreditItem( + s, + safeString(creator?.name) ?? "", + safeString(r.name ?? r) ?? "", + safeString(creator?.id) ?? undefined, + ), + ); + }), + reprints: ((raw.Reprints as unknown[]) ?? []) + .filter((r) => !isNil(r)) + .map((r) => makeArrayItem(s, r, safeString(r) ?? "")), + urls: ((raw.URLs as unknown[]) ?? []) + .filter((u) => !isNil(u)) + .map((u) => makeArrayItem(s, u, safeString(u) ?? "")), + }; +} + +/** + * Extract canonical fields from a ComicInfo.xml payload. + * Values are xml2js-parsed with `normalizeTags` (each key wraps its value in a single-element array). + * Genre is a comma-separated string; the web URL maps to `urls`. + */ +function fromComicInfo(ci: Record): AdapterResult { + const s: SourceKey = "comicInfo"; + const webUrl = xmlVal(ci, "web"); + const genreItems: ArrayItem[] = (xmlVal(ci, "genre") ?? "") + .split(",") + .map((g) => g.trim()) + .filter(Boolean) + .map((g) => makeArrayItem(s, g, g)); + + return { + series: makeScalarCandidate(s, xmlVal(ci, "series")), + issueNumber: makeScalarCandidate(s, xmlVal(ci, "number")), + publisher: makeScalarCandidate(s, xmlVal(ci, "publisher")), + description: makeScalarCandidate(s, xmlVal(ci, "summary")), + notes: makeScalarCandidate(s, xmlVal(ci, "notes")), + pageCount: makeScalarCandidate(s, xmlVal(ci, "pagecount")), + language: makeScalarCandidate(s, xmlVal(ci, "languageiso")), + urls: webUrl ? [makeArrayItem(s, webUrl, webUrl)] : [], + genres: genreItems, + }; +} + +/** GCD free-text credit fields: field key → role name. */ +const GCD_CREDIT_FIELDS: Array<{ key: string; role: string }> = [ + { key: "script", role: "Writer" }, + { key: "pencils", role: "Penciller" }, + { key: "inks", role: "Inker" }, + { key: "colors", role: "Colorist" }, + { key: "letters", role: "Letterer" }, + { key: "editing", role: "Editor" }, +]; + +/** Split a GCD free-text credit string (semicolon-separated; strips bracketed annotations). */ +function splitGCDCreditString(raw: string): string[] { + return raw + .split(/;/) + .map((name) => name.replace(/\[.*?\]/g, "").trim()) + .filter(Boolean); +} + +/** Parse a GCD price string like "0.10 USD" or "10p". Returns null on failure. */ +function parseGCDPrice( + raw: string, +): { amount: number; currency: string } | null { + const match = raw.trim().match(/^([\d.,]+)\s*([A-Z]{2,3}|p|¢|€|£|\$)?/); + if (!match) return null; + const amount = parseFloat(match[1].replace(",", ".")); + const currency = match[2] ?? "USD"; + if (isNaN(amount)) return null; + return { amount, currency }; +} + +function fromGCD(raw: Record): AdapterResult { + const s: SourceKey = "gcd"; + const series = raw.series as Record | undefined; + const language = series?.language as Record | undefined; + const publisher = series?.publisher as Record | undefined; + const indiciaPublisher = raw.indicia_publisher as + | Record + | undefined; + const stories = (raw.stories as Record[]) ?? []; + const primaryStory = stories[0] ?? {}; + + const creditItems: CreditItem[] = []; + if (raw.editing) { + splitGCDCreditString(String(raw.editing)).forEach((name) => + creditItems.push(makeCreditItem(s, name, "Editor")), + ); + } + GCD_CREDIT_FIELDS.forEach(({ key, role }) => { + const val = safeString(primaryStory[key]); + if (!val) return; + splitGCDCreditString(val).forEach((name) => + creditItems.push(makeCreditItem(s, name, role)), + ); + }); + + const genreItems: ArrayItem[] = (safeString(primaryStory.genre) ?? "") + .split(",") + .map((g) => g.trim()) + .filter(Boolean) + .map((g) => makeArrayItem(s, g, g)); + + const characterItems: ArrayItem[] = ( + safeString(primaryStory.characters) ?? "" + ) + .split(/[;,]/) + .map((c) => c.trim()) + .filter(Boolean) + .map((c) => makeArrayItem(s, c, c)); + + const storyTitles: ArrayItem[] = stories + .map((st) => safeString(st.title)) + .filter((t): t is string => Boolean(t)) + .map((t) => makeArrayItem(s, t, t)); + + const priceItems: ArrayItem[] = []; + const priceStr = safeString(raw.price); + if (priceStr) { + const parsed = parseGCDPrice(priceStr); + if (parsed) { + priceItems.push(makeArrayItem(s, { ...parsed, country: "US" }, priceStr)); + } + } + + return { + series: makeScalarCandidate(s, series?.name), + issueNumber: makeScalarCandidate(s, raw.number), + title: makeScalarCandidate(s, raw.title ?? primaryStory.title), + volume: makeScalarCandidate(s, raw.volume), + // Prefer indicia publisher (as-printed) over series publisher + publisher: makeScalarCandidate(s, indiciaPublisher?.name ?? publisher?.name), + coverDate: makeScalarCandidate(s, raw.publication_date), + storeDate: makeScalarCandidate(s, raw.on_sale_date ?? raw.key_date), + pageCount: makeScalarCandidate(s, raw.page_count), + notes: makeScalarCandidate(s, raw.notes), + language: makeScalarCandidate(s, language?.code), + ageRating: makeScalarCandidate(s, raw.rating), + genres: genreItems, + characters: characterItems, + stories: storyTitles, + creators: creditItems, + prices: priceItems, + }; +} + +function fromLocg(locg: Record): AdapterResult { + const s: SourceKey = "locg"; + return { + title: makeScalarCandidate(s, locg.name), + publisher: makeScalarCandidate(s, locg.publisher), + description: makeScalarCandidate(s, locg.description), + coverImage: makeScalarCandidate(s, locg.cover), + communityRating: makeScalarCandidate(s, locg.rating), + publicationDate: makeScalarCandidate(s, locg.publicationDate), + }; +} + +function fromInferred(inf: RawInferredMetadata["issue"]): AdapterResult { + if (!inf) return {}; + const s: SourceKey = "inferredMetadata"; + return { + title: makeScalarCandidate(s, inf.name), + issueNumber: makeScalarCandidate(s, inf.number), + volume: makeScalarCandidate(s, inf.year), + }; +} + +// ── State building ───────────────────────────────────────────────────────────── + +/** + * Merge all adapter results directly into a `ReconcilerState`. + * Array and credit items are deduplicated by `itemKey` using a Set (O(n)). + * Scalar conflicts are auto-resolved when all sources agree on the same value. + */ +function buildState( + sources: Partial>, +): ReconcilerState { + const state: ReconcilerState = {}; + const scalarMap: Record = {}; + + for (const adapterResult of Object.values(sources)) { + if (!adapterResult) continue; + for (const [field, value] of Object.entries(adapterResult)) { + if (!value) continue; + + if (Array.isArray(value)) { + // Presence of `role` distinguishes CreditItem[] from ArrayItem[]. + const isCredits = value.length > 0 && "role" in value[0]; + if (isCredits) { + const prev = state[field]; + const existing: CreditItem[] = + prev?.kind === "credits" ? prev.items : []; + const seen = new Set(existing.map((i) => i.itemKey)); + const merged = [...existing]; + for (const item of value as CreditItem[]) { + if (!seen.has(item.itemKey)) { + seen.add(item.itemKey); + merged.push(item); + } + } + state[field] = { kind: "credits", items: merged }; + } else { + const prev = state[field]; + const existing: ArrayItem[] = + prev?.kind === "array" ? prev.items : []; + const seen = new Set(existing.map((i) => i.itemKey)); + const merged = [...existing]; + for (const item of value as ArrayItem[]) { + if (!seen.has(item.itemKey)) { + seen.add(item.itemKey); + merged.push(item); + } + } + state[field] = { kind: "array", items: merged }; + } + } else { + (scalarMap[field] ??= []).push(value as ScalarCandidate); + } + } + } + + for (const [field, candidates] of Object.entries(scalarMap)) { + const allAgree = + candidates.length === 1 || + candidates.every((c) => c.value === candidates[0].value); + state[field] = { + kind: "scalar", + candidates, + selectedSource: allAgree ? candidates[0].source : null, + }; + } + + return state; +} + +// ── Reducer ──────────────────────────────────────────────────────────────────── + +type Action = + | { type: "SELECT_SCALAR"; field: string; source: SourceKey } + | { type: "SET_USER_VALUE"; field: string; value: string } + | { type: "TOGGLE_ITEM"; field: string; itemKey: string; selected: boolean } + | { type: "SET_BASE_SOURCE"; source: SourceKey } + | { type: "RESET"; initial: ReconcilerState }; + +function reducer(state: ReconcilerState, action: Action): ReconcilerState { + switch (action.type) { + case "SELECT_SCALAR": { + const field = state[action.field]; + if (field?.kind !== "scalar") return state; + return { + ...state, + [action.field]: { + ...field, + selectedSource: action.source, + userValue: undefined, + }, + }; + } + + case "SET_USER_VALUE": { + const field = state[action.field]; + if (field?.kind !== "scalar") return state; + return { + ...state, + [action.field]: { + ...field, + selectedSource: null, + userValue: action.value, + }, + }; + } + + case "TOGGLE_ITEM": { + const field = state[action.field]; + if (field?.kind === "array" || field?.kind === "credits") { + return { + ...state, + [action.field]: { + ...field, + items: field.items.map((item) => + item.itemKey === action.itemKey + ? { ...item, selected: action.selected } + : item, + ), + } as FieldState, + }; + } + return state; + } + + case "SET_BASE_SOURCE": { + const next = { ...state }; + for (const [field, fieldState] of Object.entries(next)) { + if (fieldState.kind !== "scalar") continue; + if (fieldState.candidates.some((c) => c.source === action.source)) { + next[field] = { + ...fieldState, + selectedSource: action.source, + userValue: undefined, + }; + } + } + return next; + } + + case "RESET": + return action.initial; + + default: + return state; + } +} + +// ── Canonical record ─────────────────────────────────────────────────────────── + +export interface CanonicalFieldValue { + value: unknown; + source: SourceKey | "user"; +} + +export type CanonicalRecord = Partial>; + +function deriveCanonicalRecord(state: ReconcilerState): CanonicalRecord { + const record: CanonicalRecord = {}; + + for (const [field, fieldState] of Object.entries(state)) { + if (fieldState.kind === "scalar") { + if (fieldState.userValue !== undefined) { + record[field] = { value: fieldState.userValue, source: "user" }; + } else if (fieldState.selectedSource !== null) { + const candidate = fieldState.candidates.find( + (c) => c.source === fieldState.selectedSource, + ); + if (candidate) { + record[field] = { value: candidate.value, source: candidate.source }; + } + } + } else if (fieldState.kind === "array") { + const selected = fieldState.items.filter((i) => i.selected); + if (selected.length > 0) { + const counts = selected.reduce>((acc, i) => { + acc[i.source] = (acc[i.source] ?? 0) + 1; + return acc; + }, {}); + const dominant = Object.entries(counts).sort( + ([, a], [, b]) => b - a, + )[0][0] as SourceKey; + record[field] = { + value: selected.map((i) => i.rawValue), + source: dominant, + }; + } + } else if (fieldState.kind === "credits") { + const selected = fieldState.items.filter((i) => i.selected); + if (selected.length > 0) { + record[field] = { value: selected, source: selected[0].source }; + } + } + } + + return record; +} + +// ── Hook ─────────────────────────────────────────────────────────────────────── + +export interface UseReconcilerResult { + state: ReconcilerState; + /** Number of scalar fields with a conflict that has no selection yet. */ + unresolvedCount: number; + /** True if any field has candidates from more than one source. */ + hasConflicts: boolean; + canonicalRecord: CanonicalRecord; + selectScalar: (field: string, source: SourceKey) => void; + /** Override a scalar field with a user-typed value. */ + setUserValue: (field: string, value: string) => void; + toggleItem: (field: string, itemKey: string, selected: boolean) => void; + /** Adopt all available fields from a single source. */ + setBaseSource: (source: SourceKey) => void; + reset: () => void; +} + +export function useReconciler( + sourcedMetadata: RawSourcedMetadata, + inferredMetadata?: RawInferredMetadata, +): UseReconcilerResult { + const initial = useMemo(() => { + const adapters: Partial> = {}; + + if (!isEmpty(sourcedMetadata.comicvine)) { + adapters.comicvine = fromComicVine( + sourcedMetadata.comicvine as Record, + ); + } + const metron = ensureParsed(sourcedMetadata.metron); + if (metron) adapters.metron = fromMetron(metron); + + const gcd = ensureParsed(sourcedMetadata.gcd); + if (gcd) adapters.gcd = fromGCD(gcd); + + if (!isEmpty(sourcedMetadata.locg)) { + adapters.locg = fromLocg( + sourcedMetadata.locg as Record, + ); + } + const ci = ensureParsed(sourcedMetadata.comicInfo); + if (ci) adapters.comicInfo = fromComicInfo(ci); + + if (inferredMetadata?.issue) { + adapters.inferredMetadata = fromInferred(inferredMetadata.issue); + } + + return buildState(adapters); + }, [sourcedMetadata, inferredMetadata]); + + const [state, dispatch] = useReducer(reducer, initial); + + const unresolvedCount = useMemo( + () => + Object.values(state).filter( + (f) => + f.kind === "scalar" && + f.selectedSource === null && + f.userValue === undefined && + f.candidates.length > 1, + ).length, + [state], + ); + + const hasConflicts = useMemo( + () => + Object.values(state).some( + (f) => + (f.kind === "scalar" && f.candidates.length > 1) || + ((f.kind === "array" || f.kind === "credits") && + new Set( + (f.items as Array).map((i) => i.source), + ).size > 1), + ), + [state], + ); + + const canonicalRecord = useMemo(() => deriveCanonicalRecord(state), [state]); + + return { + state, + unresolvedCount, + hasConflicts, + canonicalRecord, + selectScalar: (field, source) => + dispatch({ type: "SELECT_SCALAR", field, source }), + setUserValue: (field, value) => + dispatch({ type: "SET_USER_VALUE", field, value }), + toggleItem: (field, itemKey, selected) => + dispatch({ type: "TOGGLE_ITEM", field, itemKey, selected }), + setBaseSource: (source) => + dispatch({ type: "SET_BASE_SOURCE", source }), + reset: () => dispatch({ type: "RESET", initial }), + }; +} diff --git a/src/client/graphql/queries/comicDetail.graphql b/src/client/graphql/queries/comicDetail.graphql index 2913902..c26430d 100644 --- a/src/client/graphql/queries/comicDetail.graphql +++ b/src/client/graphql/queries/comicDetail.graphql @@ -1,7 +1,21 @@ +fragment ProvenanceFull on Provenance { + source + sourceId + confidence + fetchedAt + url +} + +fragment MetadataFieldFull on MetadataField { + value + provenance { ...ProvenanceFull } + userOverride +} + query GetComicById($id: ID!) { comic(id: $id) { id - + # Inferred metadata inferredMetadata { issue { @@ -11,132 +25,106 @@ query GetComicById($id: ID!) { subtitle } } - + # Canonical metadata canonicalMetadata { - title { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride + # ── Identity ────────────────────────────────────────────────────────── + title { ...MetadataFieldFull } + series { ...MetadataFieldFull } + volume { ...MetadataFieldFull } + issueNumber { ...MetadataFieldFull } + collectionTitle { ...MetadataFieldFull } + + # ── Series ──────────────────────────────────────────────────────────── + seriesInfo { + issueCount + startYear + volumeCount + sortName + language + alternativeNames { ...MetadataFieldFull } + provenance { ...ProvenanceFull } } - series { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride + + # ── Publication ─────────────────────────────────────────────────────── + publisher { ...MetadataFieldFull } + imprint { ...MetadataFieldFull } + coverDate { ...MetadataFieldFull } + storeDate { ...MetadataFieldFull } + publicationDate { ...MetadataFieldFull } + language { ...MetadataFieldFull } + + # ── Content ─────────────────────────────────────────────────────────── + description { ...MetadataFieldFull } + notes { ...MetadataFieldFull } + stories { ...MetadataFieldFull } + storyArcs { + name + number + id + provenance { ...ProvenanceFull } } - volume { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride - } - issueNumber { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride - } - publisher { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride - } - publicationDate { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride - } - coverDate { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride - } - description { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride + characters { ...MetadataFieldFull } + teams { ...MetadataFieldFull } + locations { ...MetadataFieldFull } + universes { + name + designation + id + provenance { ...ProvenanceFull } } + coverImage { ...MetadataFieldFull } + + # ── Credits ─────────────────────────────────────────────────────────── creators { name role - provenance { - source - sourceId - confidence - fetchedAt - url - } + provenance { ...ProvenanceFull } } - pageCount { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } - userOverride + + # ── Classification ──────────────────────────────────────────────────── + genres { ...MetadataFieldFull } + tags { ...MetadataFieldFull } + ageRating { ...MetadataFieldFull } + + # ── Physical ────────────────────────────────────────────────────────── + pageCount { ...MetadataFieldFull } + format { ...MetadataFieldFull } + + # ── Commercial ──────────────────────────────────────────────────────── + prices { + amount + currency + country + provenance { ...ProvenanceFull } } - coverImage { - value - provenance { - source - sourceId - confidence - fetchedAt - url - } + gtin { + isbn + upc userOverride + provenance { ...ProvenanceFull } + } + reprints { + description + id + provenance { ...ProvenanceFull } + } + communityRating { ...MetadataFieldFull } + + # ── External ────────────────────────────────────────────────────────── + externalIDs { + source + externalId + primary + provenance { ...ProvenanceFull } + } + urls { + url + primary + provenance { ...ProvenanceFull } } } - + # Sourced metadata sourcedMetadata { comicInfo @@ -155,7 +143,7 @@ query GetComicById($id: ID!) { potw } } - + # Raw file details rawFileDetails { name @@ -174,7 +162,7 @@ query GetComicById($id: ID!) { stats } } - + # Import status importStatus { isImported @@ -183,7 +171,7 @@ query GetComicById($id: ID!) { score } } - + # Timestamps createdAt updatedAt