👹 Metadata Reconciler WIP
This commit is contained in:
522
src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx
Normal file
522
src/client/components/ComicDetail/Tabs/ReconcilerDrawer.tsx
Normal file
@@ -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<SourceKey, string> = {
|
||||
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<SourceKey, string> = {
|
||||
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<SourceKey, string> = {
|
||||
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 <span className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block">—</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
|
||||
isSelected
|
||||
? `border-transparent ${SOURCE_SELECTED[/* filled by parent */ "comicvine"]}`
|
||||
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
|
||||
}`}
|
||||
>
|
||||
{isImage ? (
|
||||
<img
|
||||
src={value}
|
||||
alt="cover"
|
||||
className="w-full h-24 object-cover rounded"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none" }}
|
||||
/>
|
||||
) : (
|
||||
<span className={`block text-slate-700 dark:text-slate-300 ${isLongtext ? "line-clamp-3 whitespace-normal" : "truncate"}`}>
|
||||
{value}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main component ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function ReconcilerDrawer({
|
||||
open,
|
||||
onOpenChange,
|
||||
sourcedMetadata,
|
||||
inferredMetadata,
|
||||
onSave,
|
||||
}: ReconcilerDrawerProps): ReactElement {
|
||||
const [filter, setFilter] = useState<FilterMode>("all")
|
||||
|
||||
const {
|
||||
state,
|
||||
unresolvedCount,
|
||||
canonicalRecord,
|
||||
selectScalar,
|
||||
toggleItem,
|
||||
setBaseSource,
|
||||
reset,
|
||||
} = useReconciler(sourcedMetadata, inferredMetadata)
|
||||
|
||||
// Derive which sources actually contributed data
|
||||
const activeSources = useMemo<SourceKey[]>(() => {
|
||||
const seen = new Set<SourceKey>()
|
||||
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 (
|
||||
<Drawer.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/50 z-40" />
|
||||
<Drawer.Content
|
||||
aria-describedby={undefined}
|
||||
className="fixed inset-0 z-50 flex flex-col bg-white dark:bg-slate-900 outline-none"
|
||||
>
|
||||
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
|
||||
|
||||
{/* ── Header ── */}
|
||||
<div className="flex-none border-b border-slate-200 dark:border-slate-700 shadow-sm">
|
||||
{/* Title + controls */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<i className="icon-[solar--refresh-circle-outline] w-5 h-5 text-slate-500 dark:text-slate-400" />
|
||||
<span className="font-semibold text-slate-800 dark:text-slate-100 text-base">
|
||||
Reconcile Metadata
|
||||
</span>
|
||||
{unresolvedCount > 0 && (
|
||||
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300">
|
||||
{unresolvedCount} unresolved
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Filter pill */}
|
||||
<div className="flex items-center bg-slate-100 dark:bg-slate-800 rounded-lg p-0.5 gap-0.5">
|
||||
{(["all", "conflicts", "unresolved"] as FilterMode[]).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => setFilter(mode)}
|
||||
className={`px-3 py-1 rounded-md text-xs font-medium transition-colors capitalize ${
|
||||
filter === mode
|
||||
? "bg-white dark:bg-slate-700 text-slate-800 dark:text-slate-100 shadow-sm"
|
||||
: "text-slate-500 hover:text-slate-700 dark:hover:text-slate-300"
|
||||
}`}
|
||||
>
|
||||
{mode}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={reset}
|
||||
title="Reset all selections"
|
||||
className="px-3 py-1.5 text-xs rounded-md border border-slate-200 dark:border-slate-600 text-slate-600 dark:text-slate-400 hover:bg-slate-50 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="Close"
|
||||
className="p-1.5 rounded-md text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors"
|
||||
>
|
||||
<i className="icon-[solar--close-square-outline] w-5 h-5 block" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source column headers */}
|
||||
<div
|
||||
className="px-4 pb-3"
|
||||
style={{ display: "grid", gridTemplateColumns: gridCols, gap: "8px" }}
|
||||
>
|
||||
<div className="text-xs font-medium text-slate-400 dark:text-slate-500 uppercase tracking-wider flex items-end pb-0.5">
|
||||
Field
|
||||
</div>
|
||||
{activeSources.map((src) => (
|
||||
<div key={src} className="flex flex-col gap-1.5">
|
||||
<span className={`text-xs font-semibold px-2 py-0.5 rounded w-fit ${SOURCE_BADGE[src]}`}>
|
||||
{SOURCE_LABELS[src]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setBaseSource(src)}
|
||||
className="text-xs text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 text-left transition-colors"
|
||||
>
|
||||
Use all ↓
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Scrollable body ── */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{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 (
|
||||
<div key={group}>
|
||||
{/* Group sticky header */}
|
||||
<div className="sticky top-0 z-10 px-4 py-2 bg-slate-50 dark:bg-slate-800/90 backdrop-blur-sm border-b border-slate-200 dark:border-slate-700">
|
||||
<span className="text-xs font-bold text-slate-400 dark:text-slate-500 uppercase tracking-widest">
|
||||
{group}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<div
|
||||
key={fieldKey}
|
||||
className={`border-b border-slate-100 dark:border-slate-800/60 transition-colors ${
|
||||
isUnresolved ? "bg-amber-50/50 dark:bg-amber-950/20" : ""
|
||||
}`}
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: gridCols,
|
||||
gap: "8px",
|
||||
padding: "10px 16px",
|
||||
alignItems: "start",
|
||||
}}
|
||||
>
|
||||
{/* Label column */}
|
||||
<div className="flex flex-col gap-0.5 pt-1.5 pr-2">
|
||||
<span className="text-sm font-medium text-slate-700 dark:text-slate-300 leading-tight">
|
||||
{fieldCfg.label}
|
||||
</span>
|
||||
{fieldCfg.comicInfoKey && (
|
||||
<span className="text-xs text-slate-400 font-mono leading-none">
|
||||
{fieldCfg.comicInfoKey}
|
||||
</span>
|
||||
)}
|
||||
{isUnresolved && (
|
||||
<span className="inline-flex items-center gap-0.5 text-xs text-amber-600 dark:text-amber-400 mt-0.5">
|
||||
<i className="icon-[solar--danger-triangle-outline] w-3 h-3" />
|
||||
conflict
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 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 (
|
||||
<span
|
||||
key={src}
|
||||
className="text-slate-300 dark:text-slate-600 text-sm px-2 pt-1.5 block"
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={src}
|
||||
onClick={() => selectScalar(fieldKey, src)}
|
||||
className={`w-full text-left text-sm px-2 py-1.5 rounded-md border transition-all ${
|
||||
isSelected
|
||||
? `border-transparent ${selectedClass}`
|
||||
: "border-slate-200 dark:border-slate-700 hover:border-slate-300 dark:hover:border-slate-600 bg-white dark:bg-slate-800 hover:bg-slate-50 dark:hover:bg-slate-750"
|
||||
}`}
|
||||
>
|
||||
{fieldCfg.renderAs === "image" ? (
|
||||
<img
|
||||
src={candidate.value}
|
||||
alt="cover"
|
||||
className="w-full h-24 object-cover rounded"
|
||||
onError={(e) => {
|
||||
;(e.target as HTMLImageElement).style.display = "none"
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`block text-slate-700 dark:text-slate-300 ${
|
||||
fieldCfg.renderAs === "longtext"
|
||||
? "line-clamp-3 whitespace-normal text-xs leading-relaxed"
|
||||
: "truncate"
|
||||
}`}
|
||||
>
|
||||
{candidate.value}
|
||||
</span>
|
||||
)}
|
||||
{isSelected && (
|
||||
<i className="icon-[solar--check-circle-bold] w-3.5 h-3.5 text-green-500 mt-0.5 block" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
) : fs.kind === "array" ? (
|
||||
// Merged list spanning all source columns
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
{fs.items.length === 0 ? (
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
|
||||
) : (
|
||||
fs.items.map((item) => (
|
||||
<label
|
||||
key={item.itemKey}
|
||||
className={`inline-flex items-center gap-1.5 px-2 py-1 rounded-md border cursor-pointer transition-all text-sm select-none ${
|
||||
item.selected
|
||||
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected}
|
||||
onChange={(e) =>
|
||||
toggleItem(fieldKey, item.itemKey, e.target.checked)
|
||||
}
|
||||
className="w-3 h-3 rounded accent-slate-600 flex-none"
|
||||
/>
|
||||
<span className="text-slate-700 dark:text-slate-300">
|
||||
{item.displayValue}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs px-1.5 py-0.5 rounded font-medium ${SOURCE_BADGE[item.source]}`}
|
||||
>
|
||||
{SOURCE_SHORT[item.source]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : fs.kind === "credits" ? (
|
||||
// Credits spanning all source columns
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
{fs.items.length === 0 ? (
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm">No data</span>
|
||||
) : (
|
||||
fs.items.map((item) => (
|
||||
<label
|
||||
key={item.itemKey}
|
||||
className={`inline-flex items-center gap-2 px-2 py-1.5 rounded-md border cursor-pointer transition-all text-sm select-none ${
|
||||
item.selected
|
||||
? "border-slate-200 dark:border-slate-600 bg-white dark:bg-slate-800"
|
||||
: "border-dashed border-slate-200 dark:border-slate-700 opacity-40"
|
||||
}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={item.selected}
|
||||
onChange={(e) =>
|
||||
toggleItem(fieldKey, item.itemKey, e.target.checked)
|
||||
}
|
||||
className="w-3 h-3 rounded accent-slate-600 flex-none"
|
||||
/>
|
||||
<span className="font-medium text-slate-700 dark:text-slate-300">
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="text-slate-400 dark:text-slate-500">·</span>
|
||||
<span className="text-slate-500 dark:text-slate-400 text-xs">
|
||||
{item.role}
|
||||
</span>
|
||||
<span
|
||||
className={`ml-auto text-xs px-1.5 py-0.5 rounded font-medium flex-none ${SOURCE_BADGE[item.source]}`}
|
||||
>
|
||||
{SOURCE_SHORT[item.source]}
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
// GTIN and other complex types
|
||||
<div
|
||||
className="pt-1.5"
|
||||
style={{ gridColumn: "2 / -1" }}
|
||||
>
|
||||
<span className="text-slate-400 dark:text-slate-500 text-sm italic">
|
||||
Structured field — editor coming soon
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Empty state when filter hides everything */}
|
||||
{FIELD_GROUPS.every((group) =>
|
||||
Object.entries(FIELD_CONFIG)
|
||||
.filter(([, cfg]) => cfg.group === group)
|
||||
.every(([key]) => !shouldShow(key)),
|
||||
) && (
|
||||
<div className="flex flex-col items-center justify-center py-24 gap-3 text-slate-400 dark:text-slate-500">
|
||||
<i className="icon-[solar--check-circle-bold] w-10 h-10 text-green-400" />
|
||||
<span className="text-sm">
|
||||
{filter === "unresolved" ? "No unresolved conflicts" : "No fields match the current filter"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Footer ── */}
|
||||
<div className="flex-none border-t border-slate-200 dark:border-slate-700 px-4 py-3 flex items-center justify-between bg-white dark:bg-slate-900">
|
||||
<div className="text-sm">
|
||||
{allResolved ? (
|
||||
<span className="flex items-center gap-1.5 text-green-600 dark:text-green-400">
|
||||
<i className="icon-[solar--check-circle-bold] w-4 h-4" />
|
||||
All conflicts resolved
|
||||
</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1.5 text-amber-600 dark:text-amber-400">
|
||||
<i className="icon-[solar--danger-triangle-outline] w-4 h-4" />
|
||||
{unresolvedCount} field{unresolvedCount !== 1 ? "s" : ""} still need a value
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="px-4 py-2 text-sm text-slate-600 dark:text-slate-400 hover:bg-slate-100 dark:hover:bg-slate-800 rounded-lg transition-colors"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSave(canonicalRecord)
|
||||
onOpenChange(false)
|
||||
}}
|
||||
disabled={!allResolved}
|
||||
className={`px-4 py-2 text-sm rounded-lg font-medium transition-colors ${
|
||||
allResolved
|
||||
? "bg-green-600 text-white hover:bg-green-700 dark:bg-green-700 dark:hover:bg-green-600"
|
||||
: "bg-slate-100 text-slate-400 dark:bg-slate-800 dark:text-slate-600 cursor-not-allowed"
|
||||
}`}
|
||||
>
|
||||
Save Canonical Record
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
)
|
||||
}
|
||||
@@ -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<string, unknown>;
|
||||
@@ -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<string, string> = {
|
||||
|
||||
const MetadataSourceChips = ({
|
||||
sources,
|
||||
onOpenReconciler,
|
||||
}: {
|
||||
sources: string[];
|
||||
onOpenReconciler: () => void;
|
||||
}): ReactElement => {
|
||||
const [isSheetOpen, setSheetOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<span className="text-md text-slate-500 dark:text-slate-400">
|
||||
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
|
||||
{sources.length} metadata sources detected
|
||||
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
||||
<div className="flex flex-row items-center justify-between">
|
||||
<span className="text-md text-slate-500 dark:text-slate-400">
|
||||
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
|
||||
{sources.length} metadata sources detected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{sources.map((source) => (
|
||||
<span
|
||||
key={source}
|
||||
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
|
||||
>
|
||||
<i
|
||||
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
|
||||
/>
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-row flex-wrap gap-2">
|
||||
{sources.map((source) => (
|
||||
<span
|
||||
key={source}
|
||||
className="inline-flex items-center gap-1 bg-white dark:bg-slate-700 text-slate-700 dark:text-slate-300 text-xs font-medium px-2 py-1 rounded-md border border-slate-200 dark:border-slate-600"
|
||||
>
|
||||
<i
|
||||
className={`${SOURCE_ICONS[source] ?? "icon-[solar--check-circle-outline]"} w-3 h-3`}
|
||||
/>
|
||||
{SOURCE_LABELS[source] ?? source}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="flex space-x-1 mb-2 sm:mt-0 sm:flex-row sm:items-center rounded-lg border border-green-400 dark:border-green-200 bg-green-200 px-2 py-1 text-gray-500 hover:bg-transparent hover:text-green-600 focus:outline-none focus:ring active:text-indigo-500"
|
||||
onClick={() => setSheetOpen(true)}
|
||||
onClick={onOpenReconciler}
|
||||
>
|
||||
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
|
||||
Reconcile sources
|
||||
</button>
|
||||
|
||||
<Drawer.Root open={isSheetOpen} onOpenChange={setSheetOpen}>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Overlay className="fixed inset-0 bg-black/40" />
|
||||
<Drawer.Content aria-describedby={undefined} className="fixed bottom-0 left-0 right-0 rounded-t-2xl bg-white dark:bg-slate-800 p-4 outline-none">
|
||||
<Drawer.Title className="sr-only">Reconcile metadata sources</Drawer.Title>
|
||||
<div className="mx-auto mb-4 h-1.5 w-12 rounded-full bg-slate-300 dark:bg-slate-600" />
|
||||
<div className="p-4">
|
||||
{/* Reconciliation UI goes here */}
|
||||
</div>
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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<unknown, { comicId: string; field: string; value: string }>(
|
||||
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 (
|
||||
<div key={1}>
|
||||
{presentSources.length > 1 && (
|
||||
<MetadataSourceChips sources={presentSources} />
|
||||
<MetadataSourceChips
|
||||
sources={presentSources}
|
||||
onOpenReconciler={() => setReconcilerOpen(true)}
|
||||
/>
|
||||
)}
|
||||
{presentSources.length === 1 &&
|
||||
data.sourcedMetadata?.comicvine?.volumeInformation && (
|
||||
@@ -160,6 +190,13 @@ export const VolumeInformation = (
|
||||
updatedAt={data.updatedAt}
|
||||
/>
|
||||
)}
|
||||
<ReconcilerDrawer
|
||||
open={isReconcilerOpen}
|
||||
onOpenChange={setReconcilerOpen}
|
||||
sourcedMetadata={(data.sourcedMetadata ?? {}) as import("./useReconciler").RawSourcedMetadata}
|
||||
inferredMetadata={data.inferredMetadata as import("./useReconciler").RawInferredMetadata | undefined}
|
||||
onSave={saveCanonical}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
285
src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts
Normal file
285
src/client/components/ComicDetail/Tabs/reconciler.fieldConfig.ts
Normal file
@@ -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<string, FieldConfig> = {
|
||||
// ── 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
|
||||
745
src/client/components/ComicDetail/Tabs/useReconciler.ts
Normal file
745
src/client/components/ComicDetail/Tabs/useReconciler.ts
Normal file
@@ -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<SourceKey, string> = {
|
||||
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<string, FieldState>;
|
||||
|
||||
// ── Raw source data ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Raw metadata payloads keyed by source, as stored on the comic document. */
|
||||
export interface RawSourcedMetadata {
|
||||
comicvine?: Record<string, unknown>;
|
||||
/** May arrive as a JSON string; normalised by `ensureParsed`. */
|
||||
metron?: unknown;
|
||||
/** May arrive as a JSON string; normalised by `ensureParsed`. */
|
||||
gcd?: unknown;
|
||||
locg?: Record<string, unknown>;
|
||||
/** May arrive as a JSON string; normalised by `ensureParsed`. */
|
||||
comicInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** 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<string, unknown>, 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<string, unknown> | 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<string, unknown>;
|
||||
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<Record<string, ScalarCandidate | ArrayItem[] | CreditItem[]>>;
|
||||
|
||||
/**
|
||||
* Extract canonical fields from a ComicVine issue payload.
|
||||
* Volume info lives under `volumeInformation`; credits under `person_credits` etc.
|
||||
*/
|
||||
function fromComicVine(cv: Record<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "comicvine";
|
||||
const vi = cv.volumeInformation as Record<string, unknown> | undefined;
|
||||
const img = cv.image as Record<string, unknown> | undefined;
|
||||
const publisher = vi?.publisher as Record<string, unknown> | 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<string, unknown> => !isNil(c))
|
||||
.map((c) => makeArrayItem(s, c, safeString(c.name) ?? "")),
|
||||
teams: ((cv.team_credits as unknown[]) ?? [])
|
||||
.filter((t): t is Record<string, unknown> => !isNil(t))
|
||||
.map((t) => makeArrayItem(s, t, safeString(t.name) ?? "")),
|
||||
locations: ((cv.location_credits as unknown[]) ?? [])
|
||||
.filter((l): l is Record<string, unknown> => !isNil(l))
|
||||
.map((l) => makeArrayItem(s, l, safeString(l.name) ?? "")),
|
||||
storyArcs: ((cv.story_arc_credits as unknown[]) ?? [])
|
||||
.filter((a): a is Record<string, unknown> => !isNil(a))
|
||||
.map((a) => makeArrayItem(s, a, safeString(a.name) ?? "")),
|
||||
creators: ((cv.person_credits as unknown[]) ?? [])
|
||||
.filter((p): p is Record<string, unknown> => !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<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "metron";
|
||||
const series = raw.Series as Record<string, unknown> | undefined;
|
||||
const pub = raw.Publisher as Record<string, unknown> | undefined;
|
||||
|
||||
const nameList = (arr: unknown[]): ArrayItem[] =>
|
||||
arr
|
||||
.filter((x): x is Record<string, unknown> => !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<string, unknown> => !isNil(u))
|
||||
.map((u) =>
|
||||
makeArrayItem(
|
||||
s,
|
||||
u,
|
||||
[u.Name, u.Designation].filter(Boolean).join(" — "),
|
||||
),
|
||||
),
|
||||
storyArcs: ((raw.Arcs as unknown[]) ?? [])
|
||||
.filter((a): a is Record<string, unknown> => !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<string, unknown> => !isNil(c))
|
||||
.flatMap((c) => {
|
||||
const creator = c.Creator as Record<string, unknown> | undefined;
|
||||
const roles = (c.Roles as unknown[]) ?? [];
|
||||
return roles
|
||||
.filter((r): r is Record<string, unknown> => !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<string, unknown>): 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<string, unknown>): AdapterResult {
|
||||
const s: SourceKey = "gcd";
|
||||
const series = raw.series as Record<string, unknown> | undefined;
|
||||
const language = series?.language as Record<string, unknown> | undefined;
|
||||
const publisher = series?.publisher as Record<string, unknown> | undefined;
|
||||
const indiciaPublisher = raw.indicia_publisher as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
const stories = (raw.stories as Record<string, unknown>[]) ?? [];
|
||||
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<string, unknown>): 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<Record<SourceKey, AdapterResult>>,
|
||||
): ReconcilerState {
|
||||
const state: ReconcilerState = {};
|
||||
const scalarMap: Record<string, ScalarCandidate[]> = {};
|
||||
|
||||
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<Record<string, CanonicalFieldValue>>;
|
||||
|
||||
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<Record<string, number>>((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<Record<SourceKey, AdapterResult>> = {};
|
||||
|
||||
if (!isEmpty(sourcedMetadata.comicvine)) {
|
||||
adapters.comicvine = fromComicVine(
|
||||
sourcedMetadata.comicvine as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
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<string, unknown>,
|
||||
);
|
||||
}
|
||||
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<ArrayItem | CreditItem>).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 }),
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user