import { z } from 'zod'; import categoriesJson from '../../../data/taxonomy/categories.json' with { type: 'json' }; import conditionsJson from '../../../data/taxonomy/conditions.json' with { type: 'json' }; import gradersJson from '../../../data/taxonomy/graders.json' with { type: 'json' }; import indicesJson from '../../../data/taxonomy/indices.json' with { type: 'json' }; /** * Taxonomy loader. The JSON files under data/taxonomy are the seed; the database `categories` * table is the runtime source of truth (it may contain approved proposals). This module offers * pure helpers usable from connectors, workers and the web layer without a DB round-trip. */ const NodeSchema = z.object({ slug: z.string(), parent: z.string().optional(), name: z.string(), short: z.string().optional(), phase: z.number().int().min(1).max(3), index: z.string().nullable().optional(), conditionScale: z.string().optional(), graders: z.array(z.string()).optional(), compliance: z.array(z.string()).optional(), description: z.string().optional(), icon: z.string().optional(), }); export type TaxonomyNode = z.infer; export interface CategoryNode extends TaxonomyNode { level: number; familySlug: string; children: string[]; sortOrder: number; } const parsedNodes = z.array(NodeSchema).parse(categoriesJson.nodes); const bySlug = new Map(); for (const [i, n] of parsedNodes.entries()) { bySlug.set(n.slug, { ...n, level: 0, familySlug: n.slug, children: [], sortOrder: i }); } for (const n of bySlug.values()) { if (n.parent) { const p = bySlug.get(n.parent); if (!p) throw new Error(`taxonomy: parent ${n.parent} of ${n.slug} not found`); p.children.push(n.slug); } } function resolve(n: CategoryNode): void { if (!n.parent) return; const p = bySlug.get(n.parent)!; resolve(p); n.level = p.level + 1; n.familySlug = p.familySlug; n.index = n.index ?? p.index ?? null; n.conditionScale = n.conditionScale ?? p.conditionScale; n.graders = n.graders ?? p.graders; } for (const n of bySlug.values()) resolve(n); export const CATEGORIES: readonly CategoryNode[] = [...bySlug.values()]; export const FAMILIES: readonly CategoryNode[] = CATEGORIES.filter((c) => c.level === 0); export function getCategory(slug: string): CategoryNode | undefined { return bySlug.get(slug); } export function requireCategory(slug: string): CategoryNode { const c = bySlug.get(slug); if (!c) throw new Error(`Unknown category slug: ${slug}`); return c; } export function categoryFamily(slug: string): string { return bySlug.get(slug)?.familySlug ?? slug; } export function categoryPath(slug: string): CategoryNode[] { const out: CategoryNode[] = []; let cur = bySlug.get(slug); while (cur) { out.unshift(cur); cur = cur.parent ? bySlug.get(cur.parent) : undefined; } return out; } export function descendants(slug: string): string[] { const out: string[] = []; const stack = [slug]; while (stack.length) { const s = stack.pop()!; const n = bySlug.get(s); if (!n) continue; for (const c of n.children) { out.push(c); stack.push(c); } } return out; } export function phaseCategories(phase: 1 | 2 | 3): CategoryNode[] { return CATEGORIES.filter((c) => c.phase <= phase); } // ---------- Condition scales ---------- export interface ConditionScale { values: string[]; completeness: string[]; aliases: Record; } export const CONDITION_SCALES: Record = conditionsJson.scales as Record; export function conditionScaleFor(categorySlug: string): ConditionScale { const scale = bySlug.get(categorySlug)?.conditionScale ?? 'general'; return CONDITION_SCALES[scale] ?? CONDITION_SCALES.general!; } /** Map a raw condition string to the category's normalised slug; null when unknown (never guess). */ export function normalizeCondition(categorySlug: string, raw: string | null | undefined): string | null { if (!raw) return null; const scale = conditionScaleFor(categorySlug); const key = raw.trim().toLowerCase().replace(/\s+/g, ' '); if (key in scale.aliases) return scale.aliases[key] ?? null; const asSlug = key.replace(/[\s-]+/g, '_'); if (scale.values.includes(asSlug)) return asSlug; // Try contained alias (e.g. "Condition: Near Mint or Better") for (const [alias, slug] of Object.entries(scale.aliases)) { if (alias.length >= 4 && key.includes(alias)) return slug; } return null; } export function conditionRank(categorySlug: string, condition: string | null): number | null { if (!condition) return null; const i = conditionScaleFor(categorySlug).values.indexOf(condition); return i >= 0 ? i : null; } // ---------- Graders ---------- export interface GraderDef { slug: string; name: string; categories: string[]; scale: { type: 'numeric' | 'sheldon' | 'descriptive'; values: string[]; top: string }; populationUrl?: string; verifyUrl?: string; aliases: string[]; } export const GRADERS: readonly GraderDef[] = gradersJson.graders as GraderDef[]; const graderByAlias = new Map(); for (const g of GRADERS) { graderByAlias.set(g.slug, g); for (const a of g.aliases) graderByAlias.set(a.toLowerCase(), g); } export function getGrader(slugOrAlias: string | null | undefined): GraderDef | undefined { if (!slugOrAlias) return undefined; return graderByAlias.get(slugOrAlias.trim().toLowerCase()); } // Grader token, optional "/DNA" (PSA/DNA autograph slabs), optional descriptive word, then either a // numeric grade ("10", "9.5", "(8.5)") or an authentication-only marker ("Authentic", "AUTH", "A"). const GRADE_RE = /\b(PSA|BGS|BECKETT|CGC|SGC|TAG|ACE|CBCS|PGX|WATA|VGA|PCGS|NGC|ANACS|ICCS|PMG)\b(?:\s*\/?\s*DNA\b)?[\s:\-\[(]*(?:GEM\s*MT|GEM\s*MINT|PRISTINE|BLACK\s*LABEL|NM[-\/]?MT\+?|EX[-\/]?MT\+?|VG[-\/]?EX\+?|MINT\+?|NM\+?|EX\+?|VG\+?|FN\+?|GD\+?|MT\+?|PR|FR)?[\s:\-\[(]*(?:(\d{1,3}(?:\.\d)?)(?![\d.])|\b(AUTHENTIC|AUTH|CERTIFIED\s+AUTHENTIC)\b)?/gi; /** Grader tokens that are also ordinary words ("Tag Team", "Ace Attorney") — only trusted when followed by a grade. */ const AMBIGUOUS_GRADERS = new Set(['tag', 'ace']); /** Authentication-only slabs (no numeric grade). Kept apart from raw and from graded variants. */ export const AUTHENTIC_GRADE = 'authentic'; /** * Extract grader + grade from a free-text title. Returns nulls when absent. * A grader token WITHOUT a readable grade yields `{ grader, grade: null }` — callers must treat that as * "graded, grade unknown" (never as raw): mixing such records into the raw variant polluted RIVs. */ export function parseGradeFromTitle(title: string): { grader: string | null; grade: string | null; qualifier: string | null } { const matches = [...title.matchAll(GRADE_RE)]; // Prefer the first match that carries a numeric grade; ambiguous tokens without a grade are ignored. const m = matches.find((x) => x[2]) ?? matches.find((x) => x[3]) ?? matches.find((x) => !AMBIGUOUS_GRADERS.has(x[1]!.toLowerCase())); if (!m) { if (/\b(raw|ungraded)\b/i.test(title)) return { grader: 'raw', grade: null, qualifier: null }; return { grader: null, grade: null, qualifier: null }; } const grader = getGrader(m[1]!)?.slug ?? m[1]!.toLowerCase(); let grade = m[2] ?? (m[3] ? AUTHENTIC_GRADE : null); const g = getGrader(grader); if (grade && grade !== AUTHENTIC_GRADE && g && g.scale.values.length && !g.scale.values.includes(grade)) { // e.g. "PSA 10" where 10 is fine; "PSA 2021" is a year → reject if (Number(grade) > Number(g.scale.top)) grade = null; } let qualifier: string | null = null; if (/black\s*label/i.test(title)) qualifier = 'Black Label'; else if (/pristine/i.test(title) && grader === 'bgs') qualifier = 'Pristine'; else if (/signature\s*series/i.test(title)) qualifier = 'Signature Series'; else if (/\b(OC|MC|ST|PD|MK)\b/.test(title) && grader === 'psa') qualifier = title.match(/\b(OC|MC|ST|PD|MK)\b/)![1]!; return { grader, grade, qualifier }; } /** Variant key used by asset_variants (deterministic). */ export function variantKey(input: { grader?: string | null; grade?: string | null; qualifier?: string | null; condition?: string | null; completeness?: string | null; size?: string | null }): { key: string; label: string } { const parts: string[] = []; const labelParts: string[] = []; if (input.grader && input.grader !== 'raw' && input.grade) { parts.push(input.grader, input.grade); labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} ${input.grade === AUTHENTIC_GRADE ? 'Authentic' : input.grade}`); if (input.qualifier) { parts.push(input.qualifier.toLowerCase().replace(/\s+/g, '_')); labelParts.push(input.qualifier); } } else if (input.grader && input.grader !== 'raw') { // Slabbed by a known grader but the grade could not be read: its own variant, never "raw". parts.push(input.grader, 'unknown'); labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} · grade unknown`); } else { parts.push('raw'); if (input.completeness) { parts.push(input.completeness); labelParts.push(humanize(input.completeness)); } else if (input.condition) { parts.push(input.condition); labelParts.push(humanize(input.condition)); } else { labelParts.push('Raw / ungraded'); } } if (input.size) { parts.push(`size_${input.size.toLowerCase().replace(/\s+/g, '')}`); labelParts.push(`Size ${input.size}`); } return { key: parts.join('|'), label: labelParts.join(' · ') }; } export function humanize(slug: string): string { return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bCib\b/, 'CIB'); } // ---------- Indices ---------- export interface IndexDef { ticker: string; name: string; flagship?: boolean; description?: string; families: string[]; color: string; minConstituents: number; } export const INDEX_BASE_DATE: string = indicesJson.baseDate; export const INDEX_BASE_VALUE: number = indicesJson.baseValue; export const INDICES: readonly IndexDef[] = indicesJson.indices as IndexDef[]; export function indexForCategory(categorySlug: string): IndexDef | undefined { const ticker = bySlug.get(categorySlug)?.index; return ticker ? INDICES.find((i) => i.ticker === ticker) : undefined; }