Compare commits
2 Commits
main
...
733a453352
| Author | SHA1 | Date | |
|---|---|---|---|
| 733a453352 | |||
| 3d88920f39 |
19434
package-lock.json
generated
19434
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
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 React, { ReactElement, useMemo, useState } from "react";
|
||||||
import { isEmpty, isNil } from "lodash";
|
import { isEmpty, isNil } from "lodash";
|
||||||
import { Drawer } from "vaul";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import ComicVineDetails from "../ComicVineDetails";
|
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 {
|
interface ComicVineMetadata {
|
||||||
volumeInformation?: Record<string, unknown>;
|
volumeInformation?: Record<string, unknown>;
|
||||||
@@ -21,6 +25,7 @@ interface SourcedMetadata {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface VolumeInformationData {
|
interface VolumeInformationData {
|
||||||
|
id?: string;
|
||||||
sourcedMetadata?: SourcedMetadata;
|
sourcedMetadata?: SourcedMetadata;
|
||||||
inferredMetadata?: { issue?: unknown };
|
inferredMetadata?: { issue?: unknown };
|
||||||
updatedAt?: string;
|
updatedAt?: string;
|
||||||
@@ -31,6 +36,14 @@ interface VolumeInformationProps {
|
|||||||
onReconcile?: () => void;
|
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. */
|
/** Sources stored under `sourcedMetadata` — excludes `inferredMetadata`, which is checked separately. */
|
||||||
const SOURCED_METADATA_KEYS = [
|
const SOURCED_METADATA_KEYS = [
|
||||||
"comicvine",
|
"comicvine",
|
||||||
@@ -60,55 +73,40 @@ const SOURCE_ICONS: Record<string, string> = {
|
|||||||
|
|
||||||
const MetadataSourceChips = ({
|
const MetadataSourceChips = ({
|
||||||
sources,
|
sources,
|
||||||
|
onOpenReconciler,
|
||||||
}: {
|
}: {
|
||||||
sources: string[];
|
sources: string[];
|
||||||
|
onOpenReconciler: () => void;
|
||||||
}): ReactElement => {
|
}): ReactElement => {
|
||||||
const [isSheetOpen, setSheetOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
||||||
<div className="flex flex-col gap-2 mb-5 p-3 w-fit">
|
<div className="flex flex-row items-center justify-between">
|
||||||
<div className="flex flex-row items-center justify-between">
|
<span className="text-md text-slate-500 dark:text-slate-400">
|
||||||
<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" />
|
||||||
<i className="icon-[solar--database-outline] w-4 h-4 inline-block align-middle mr-1" />
|
{sources.length} metadata sources detected
|
||||||
{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>
|
</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>
|
</div>
|
||||||
<button
|
<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"
|
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" />
|
<i className="icon-[solar--refresh-outline] w-4 h-4 px-3" />
|
||||||
Reconcile sources
|
Reconcile sources
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
<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>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -127,6 +125,35 @@ export const VolumeInformation = (
|
|||||||
props: VolumeInformationProps,
|
props: VolumeInformationProps,
|
||||||
): ReactElement => {
|
): ReactElement => {
|
||||||
const { data } = props;
|
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 presentSources = useMemo(() => {
|
||||||
const sources = SOURCED_METADATA_KEYS.filter((key) => {
|
const sources = SOURCED_METADATA_KEYS.filter((key) => {
|
||||||
@@ -151,7 +178,10 @@ export const VolumeInformation = (
|
|||||||
return (
|
return (
|
||||||
<div key={1}>
|
<div key={1}>
|
||||||
{presentSources.length > 1 && (
|
{presentSources.length > 1 && (
|
||||||
<MetadataSourceChips sources={presentSources} />
|
<MetadataSourceChips
|
||||||
|
sources={presentSources}
|
||||||
|
onOpenReconciler={() => setReconcilerOpen(true)}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{presentSources.length === 1 &&
|
{presentSources.length === 1 &&
|
||||||
data.sourcedMetadata?.comicvine?.volumeInformation && (
|
data.sourcedMetadata?.comicvine?.volumeInformation && (
|
||||||
@@ -160,6 +190,13 @@ export const VolumeInformation = (
|
|||||||
updatedAt={data.updatedAt}
|
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>
|
</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 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -93,23 +93,37 @@ export type CanonicalMetadata = {
|
|||||||
__typename?: 'CanonicalMetadata';
|
__typename?: 'CanonicalMetadata';
|
||||||
ageRating?: Maybe<MetadataField>;
|
ageRating?: Maybe<MetadataField>;
|
||||||
characters?: Maybe<Array<MetadataField>>;
|
characters?: Maybe<Array<MetadataField>>;
|
||||||
|
collectionTitle?: Maybe<MetadataField>;
|
||||||
communityRating?: Maybe<MetadataField>;
|
communityRating?: Maybe<MetadataField>;
|
||||||
coverDate?: Maybe<MetadataField>;
|
coverDate?: Maybe<MetadataField>;
|
||||||
coverImage?: Maybe<MetadataField>;
|
coverImage?: Maybe<MetadataField>;
|
||||||
creators?: Maybe<Array<Creator>>;
|
creators?: Maybe<Array<Creator>>;
|
||||||
description?: Maybe<MetadataField>;
|
description?: Maybe<MetadataField>;
|
||||||
|
externalIDs?: Maybe<Array<ExternalId>>;
|
||||||
format?: Maybe<MetadataField>;
|
format?: Maybe<MetadataField>;
|
||||||
genres?: Maybe<Array<MetadataField>>;
|
genres?: Maybe<Array<MetadataField>>;
|
||||||
|
gtin?: Maybe<GtinField>;
|
||||||
|
imprint?: Maybe<MetadataField>;
|
||||||
issueNumber?: Maybe<MetadataField>;
|
issueNumber?: Maybe<MetadataField>;
|
||||||
|
language?: Maybe<MetadataField>;
|
||||||
|
lastModified?: Maybe<MetadataField>;
|
||||||
locations?: Maybe<Array<MetadataField>>;
|
locations?: Maybe<Array<MetadataField>>;
|
||||||
|
notes?: Maybe<MetadataField>;
|
||||||
pageCount?: Maybe<MetadataField>;
|
pageCount?: Maybe<MetadataField>;
|
||||||
|
prices?: Maybe<Array<PriceField>>;
|
||||||
publicationDate?: Maybe<MetadataField>;
|
publicationDate?: Maybe<MetadataField>;
|
||||||
publisher?: Maybe<MetadataField>;
|
publisher?: Maybe<MetadataField>;
|
||||||
|
reprints?: Maybe<Array<ReprintField>>;
|
||||||
series?: Maybe<MetadataField>;
|
series?: Maybe<MetadataField>;
|
||||||
storyArcs?: Maybe<Array<MetadataField>>;
|
seriesInfo?: Maybe<SeriesInfo>;
|
||||||
|
storeDate?: Maybe<MetadataField>;
|
||||||
|
stories?: Maybe<Array<MetadataField>>;
|
||||||
|
storyArcs?: Maybe<Array<StoryArcField>>;
|
||||||
tags?: Maybe<Array<MetadataField>>;
|
tags?: Maybe<Array<MetadataField>>;
|
||||||
teams?: Maybe<Array<MetadataField>>;
|
teams?: Maybe<Array<MetadataField>>;
|
||||||
title?: Maybe<MetadataField>;
|
title?: Maybe<MetadataField>;
|
||||||
|
universes?: Maybe<Array<UniverseField>>;
|
||||||
|
urls?: Maybe<Array<UrlField>>;
|
||||||
volume?: Maybe<MetadataField>;
|
volume?: Maybe<MetadataField>;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -226,6 +240,7 @@ export type CoverInput = {
|
|||||||
|
|
||||||
export type Creator = {
|
export type Creator = {
|
||||||
__typename?: 'Creator';
|
__typename?: 'Creator';
|
||||||
|
id?: Maybe<Scalars['String']['output']>;
|
||||||
name: Scalars['String']['output'];
|
name: Scalars['String']['output'];
|
||||||
provenance: Provenance;
|
provenance: Provenance;
|
||||||
role: Scalars['String']['output'];
|
role: Scalars['String']['output'];
|
||||||
@@ -261,6 +276,14 @@ export type DirectorySize = {
|
|||||||
totalSizeInMB: Scalars['Float']['output'];
|
totalSizeInMB: Scalars['Float']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ExternalId = {
|
||||||
|
__typename?: 'ExternalID';
|
||||||
|
externalId: Scalars['String']['output'];
|
||||||
|
primary?: Maybe<Scalars['Boolean']['output']>;
|
||||||
|
provenance: Provenance;
|
||||||
|
source: MetadataSource;
|
||||||
|
};
|
||||||
|
|
||||||
export type FieldOverride = {
|
export type FieldOverride = {
|
||||||
__typename?: 'FieldOverride';
|
__typename?: 'FieldOverride';
|
||||||
field: Scalars['String']['output'];
|
field: Scalars['String']['output'];
|
||||||
@@ -295,6 +318,14 @@ export type ForceCompleteResult = {
|
|||||||
success: Scalars['Boolean']['output'];
|
success: Scalars['Boolean']['output'];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type GtinField = {
|
||||||
|
__typename?: 'GTINField';
|
||||||
|
isbn?: Maybe<Scalars['String']['output']>;
|
||||||
|
provenance: Provenance;
|
||||||
|
upc?: Maybe<Scalars['String']['output']>;
|
||||||
|
userOverride?: Maybe<Scalars['Boolean']['output']>;
|
||||||
|
};
|
||||||
|
|
||||||
export type GetResourceInput = {
|
export type GetResourceInput = {
|
||||||
fieldList?: InputMaybe<Scalars['String']['input']>;
|
fieldList?: InputMaybe<Scalars['String']['input']>;
|
||||||
filter?: InputMaybe<Scalars['String']['input']>;
|
filter?: InputMaybe<Scalars['String']['input']>;
|
||||||
@@ -544,13 +575,6 @@ export type MatchedResult = {
|
|||||||
score?: Maybe<Scalars['String']['output']>;
|
score?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MetadataArrayField = {
|
|
||||||
__typename?: 'MetadataArrayField';
|
|
||||||
provenance: Provenance;
|
|
||||||
userOverride?: Maybe<Scalars['Boolean']['output']>;
|
|
||||||
values: Array<Scalars['String']['output']>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type MetadataConflict = {
|
export type MetadataConflict = {
|
||||||
__typename?: 'MetadataConflict';
|
__typename?: 'MetadataConflict';
|
||||||
candidates: Array<MetadataField>;
|
candidates: Array<MetadataField>;
|
||||||
@@ -752,6 +776,14 @@ export type PersonCredit = {
|
|||||||
site_detail_url?: Maybe<Scalars['String']['output']>;
|
site_detail_url?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type PriceField = {
|
||||||
|
__typename?: 'PriceField';
|
||||||
|
amount: Scalars['Float']['output'];
|
||||||
|
country: Scalars['String']['output'];
|
||||||
|
currency?: Maybe<Scalars['String']['output']>;
|
||||||
|
provenance: Provenance;
|
||||||
|
};
|
||||||
|
|
||||||
export type Provenance = {
|
export type Provenance = {
|
||||||
__typename?: 'Provenance';
|
__typename?: 'Provenance';
|
||||||
confidence: Scalars['Float']['output'];
|
confidence: Scalars['Float']['output'];
|
||||||
@@ -969,6 +1001,13 @@ export type RawFileDetailsInput = {
|
|||||||
pageCount?: InputMaybe<Scalars['Int']['input']>;
|
pageCount?: InputMaybe<Scalars['Int']['input']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ReprintField = {
|
||||||
|
__typename?: 'ReprintField';
|
||||||
|
description: Scalars['String']['output'];
|
||||||
|
id?: Maybe<Scalars['String']['output']>;
|
||||||
|
provenance: Provenance;
|
||||||
|
};
|
||||||
|
|
||||||
export type ScorerConfigurationInput = {
|
export type ScorerConfigurationInput = {
|
||||||
searchParams?: InputMaybe<SearchParamsInput>;
|
searchParams?: InputMaybe<SearchParamsInput>;
|
||||||
};
|
};
|
||||||
@@ -1052,6 +1091,17 @@ export enum SearchType {
|
|||||||
Wanted = 'wanted'
|
Wanted = 'wanted'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SeriesInfo = {
|
||||||
|
__typename?: 'SeriesInfo';
|
||||||
|
alternativeNames?: Maybe<Array<MetadataField>>;
|
||||||
|
issueCount?: Maybe<Scalars['Int']['output']>;
|
||||||
|
language?: Maybe<Scalars['String']['output']>;
|
||||||
|
provenance: Provenance;
|
||||||
|
sortName?: Maybe<Scalars['String']['output']>;
|
||||||
|
startYear?: Maybe<Scalars['Int']['output']>;
|
||||||
|
volumeCount?: Maybe<Scalars['Int']['output']>;
|
||||||
|
};
|
||||||
|
|
||||||
export type SourcePriority = {
|
export type SourcePriority = {
|
||||||
__typename?: 'SourcePriority';
|
__typename?: 'SourcePriority';
|
||||||
enabled: Scalars['Boolean']['output'];
|
enabled: Scalars['Boolean']['output'];
|
||||||
@@ -1114,6 +1164,14 @@ export type StoryArcCredit = {
|
|||||||
site_detail_url?: Maybe<Scalars['String']['output']>;
|
site_detail_url?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type StoryArcField = {
|
||||||
|
__typename?: 'StoryArcField';
|
||||||
|
id?: Maybe<Scalars['String']['output']>;
|
||||||
|
name: Scalars['String']['output'];
|
||||||
|
number?: Maybe<Scalars['Int']['output']>;
|
||||||
|
provenance: Provenance;
|
||||||
|
};
|
||||||
|
|
||||||
export type TeamCredit = {
|
export type TeamCredit = {
|
||||||
__typename?: 'TeamCredit';
|
__typename?: 'TeamCredit';
|
||||||
api_detail_url?: Maybe<Scalars['String']['output']>;
|
api_detail_url?: Maybe<Scalars['String']['output']>;
|
||||||
@@ -1140,6 +1198,21 @@ export type TorrentSearchResult = {
|
|||||||
title?: Maybe<Scalars['String']['output']>;
|
title?: Maybe<Scalars['String']['output']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UrlField = {
|
||||||
|
__typename?: 'URLField';
|
||||||
|
primary?: Maybe<Scalars['Boolean']['output']>;
|
||||||
|
provenance: Provenance;
|
||||||
|
url: Scalars['String']['output'];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UniverseField = {
|
||||||
|
__typename?: 'UniverseField';
|
||||||
|
designation?: Maybe<Scalars['String']['output']>;
|
||||||
|
id?: Maybe<Scalars['String']['output']>;
|
||||||
|
name: Scalars['String']['output'];
|
||||||
|
provenance: Provenance;
|
||||||
|
};
|
||||||
|
|
||||||
export type UserPreferences = {
|
export type UserPreferences = {
|
||||||
__typename?: 'UserPreferences';
|
__typename?: 'UserPreferences';
|
||||||
autoMerge: AutoMergeSettings;
|
autoMerge: AutoMergeSettings;
|
||||||
|
|||||||
@@ -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!) {
|
query GetComicById($id: ID!) {
|
||||||
comic(id: $id) {
|
comic(id: $id) {
|
||||||
id
|
id
|
||||||
|
|
||||||
# Inferred metadata
|
# Inferred metadata
|
||||||
inferredMetadata {
|
inferredMetadata {
|
||||||
issue {
|
issue {
|
||||||
@@ -11,132 +25,106 @@ query GetComicById($id: ID!) {
|
|||||||
subtitle
|
subtitle
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Canonical metadata
|
# Canonical metadata
|
||||||
canonicalMetadata {
|
canonicalMetadata {
|
||||||
title {
|
# ── Identity ──────────────────────────────────────────────────────────
|
||||||
value
|
title { ...MetadataFieldFull }
|
||||||
provenance {
|
series { ...MetadataFieldFull }
|
||||||
source
|
volume { ...MetadataFieldFull }
|
||||||
sourceId
|
issueNumber { ...MetadataFieldFull }
|
||||||
confidence
|
collectionTitle { ...MetadataFieldFull }
|
||||||
fetchedAt
|
|
||||||
url
|
# ── Series ────────────────────────────────────────────────────────────
|
||||||
}
|
seriesInfo {
|
||||||
userOverride
|
issueCount
|
||||||
|
startYear
|
||||||
|
volumeCount
|
||||||
|
sortName
|
||||||
|
language
|
||||||
|
alternativeNames { ...MetadataFieldFull }
|
||||||
|
provenance { ...ProvenanceFull }
|
||||||
}
|
}
|
||||||
series {
|
|
||||||
value
|
# ── Publication ───────────────────────────────────────────────────────
|
||||||
provenance {
|
publisher { ...MetadataFieldFull }
|
||||||
source
|
imprint { ...MetadataFieldFull }
|
||||||
sourceId
|
coverDate { ...MetadataFieldFull }
|
||||||
confidence
|
storeDate { ...MetadataFieldFull }
|
||||||
fetchedAt
|
publicationDate { ...MetadataFieldFull }
|
||||||
url
|
language { ...MetadataFieldFull }
|
||||||
}
|
|
||||||
userOverride
|
# ── Content ───────────────────────────────────────────────────────────
|
||||||
|
description { ...MetadataFieldFull }
|
||||||
|
notes { ...MetadataFieldFull }
|
||||||
|
stories { ...MetadataFieldFull }
|
||||||
|
storyArcs {
|
||||||
|
name
|
||||||
|
number
|
||||||
|
id
|
||||||
|
provenance { ...ProvenanceFull }
|
||||||
}
|
}
|
||||||
volume {
|
characters { ...MetadataFieldFull }
|
||||||
value
|
teams { ...MetadataFieldFull }
|
||||||
provenance {
|
locations { ...MetadataFieldFull }
|
||||||
source
|
universes {
|
||||||
sourceId
|
name
|
||||||
confidence
|
designation
|
||||||
fetchedAt
|
id
|
||||||
url
|
provenance { ...ProvenanceFull }
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
coverImage { ...MetadataFieldFull }
|
||||||
|
|
||||||
|
# ── Credits ───────────────────────────────────────────────────────────
|
||||||
creators {
|
creators {
|
||||||
name
|
name
|
||||||
role
|
role
|
||||||
provenance {
|
provenance { ...ProvenanceFull }
|
||||||
source
|
|
||||||
sourceId
|
|
||||||
confidence
|
|
||||||
fetchedAt
|
|
||||||
url
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
pageCount {
|
|
||||||
value
|
# ── Classification ────────────────────────────────────────────────────
|
||||||
provenance {
|
genres { ...MetadataFieldFull }
|
||||||
source
|
tags { ...MetadataFieldFull }
|
||||||
sourceId
|
ageRating { ...MetadataFieldFull }
|
||||||
confidence
|
|
||||||
fetchedAt
|
# ── Physical ──────────────────────────────────────────────────────────
|
||||||
url
|
pageCount { ...MetadataFieldFull }
|
||||||
}
|
format { ...MetadataFieldFull }
|
||||||
userOverride
|
|
||||||
|
# ── Commercial ────────────────────────────────────────────────────────
|
||||||
|
prices {
|
||||||
|
amount
|
||||||
|
currency
|
||||||
|
country
|
||||||
|
provenance { ...ProvenanceFull }
|
||||||
}
|
}
|
||||||
coverImage {
|
gtin {
|
||||||
value
|
isbn
|
||||||
provenance {
|
upc
|
||||||
source
|
|
||||||
sourceId
|
|
||||||
confidence
|
|
||||||
fetchedAt
|
|
||||||
url
|
|
||||||
}
|
|
||||||
userOverride
|
userOverride
|
||||||
|
provenance { ...ProvenanceFull }
|
||||||
|
}
|
||||||
|
reprints {
|
||||||
|
description
|
||||||
|
id
|
||||||
|
provenance { ...ProvenanceFull }
|
||||||
|
}
|
||||||
|
communityRating { ...MetadataFieldFull }
|
||||||
|
|
||||||
|
# ── External ──────────────────────────────────────────────────────────
|
||||||
|
externalIDs {
|
||||||
|
source
|
||||||
|
externalId
|
||||||
|
primary
|
||||||
|
provenance { ...ProvenanceFull }
|
||||||
|
}
|
||||||
|
urls {
|
||||||
|
url
|
||||||
|
primary
|
||||||
|
provenance { ...ProvenanceFull }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Sourced metadata
|
# Sourced metadata
|
||||||
sourcedMetadata {
|
sourcedMetadata {
|
||||||
comicInfo
|
comicInfo
|
||||||
@@ -155,7 +143,7 @@ query GetComicById($id: ID!) {
|
|||||||
potw
|
potw
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Raw file details
|
# Raw file details
|
||||||
rawFileDetails {
|
rawFileDetails {
|
||||||
name
|
name
|
||||||
@@ -174,7 +162,7 @@ query GetComicById($id: ID!) {
|
|||||||
stats
|
stats
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Import status
|
# Import status
|
||||||
importStatus {
|
importStatus {
|
||||||
isImported
|
isImported
|
||||||
@@ -183,7 +171,7 @@ query GetComicById($id: ID!) {
|
|||||||
score
|
score
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Timestamps
|
# Timestamps
|
||||||
createdAt
|
createdAt
|
||||||
updatedAt
|
updatedAt
|
||||||
|
|||||||
Reference in New Issue
Block a user