1import { z } from 'zod';2import categoriesJson from '../../../data/taxonomy/categories.json' with { type: 'json' };3import conditionsJson from '../../../data/taxonomy/conditions.json' with { type: 'json' };4import gradersJson from '../../../data/taxonomy/graders.json' with { type: 'json' };5import indicesJson from '../../../data/taxonomy/indices.json' with { type: 'json' };67/**8 * Taxonomy loader. The JSON files under data/taxonomy are the seed; the database `categories`9 * table is the runtime source of truth (it may contain approved proposals). This module offers10 * pure helpers usable from connectors, workers and the web layer without a DB round-trip.11 */1213const NodeSchema = z.object({14 slug: z.string(),15 parent: z.string().optional(),16 name: z.string(),17 short: z.string().optional(),18 phase: z.number().int().min(1).max(3),19 index: z.string().nullable().optional(),20 conditionScale: z.string().optional(),21 graders: z.array(z.string()).optional(),22 compliance: z.array(z.string()).optional(),23 description: z.string().optional(),24 icon: z.string().optional(),25});26export type TaxonomyNode = z.infer<typeof NodeSchema>;2728export interface CategoryNode extends TaxonomyNode {29 level: number;30 familySlug: string;31 children: string[];32 sortOrder: number;33}3435const parsedNodes = z.array(NodeSchema).parse(categoriesJson.nodes);3637const bySlug = new Map<string, CategoryNode>();38for (const [i, n] of parsedNodes.entries()) {39 bySlug.set(n.slug, { ...n, level: 0, familySlug: n.slug, children: [], sortOrder: i });40}41for (const n of bySlug.values()) {42 if (n.parent) {43 const p = bySlug.get(n.parent);44 if (!p) throw new Error(`taxonomy: parent ${n.parent} of ${n.slug} not found`);45 p.children.push(n.slug);46 }47}48function resolve(n: CategoryNode): void {49 if (!n.parent) return;50 const p = bySlug.get(n.parent)!;51 resolve(p);52 n.level = p.level + 1;53 n.familySlug = p.familySlug;54 n.index = n.index ?? p.index ?? null;55 n.conditionScale = n.conditionScale ?? p.conditionScale;56 n.graders = n.graders ?? p.graders;57}58for (const n of bySlug.values()) resolve(n);5960export const CATEGORIES: readonly CategoryNode[] = [...bySlug.values()];61export const FAMILIES: readonly CategoryNode[] = CATEGORIES.filter((c) => c.level === 0);6263export function getCategory(slug: string): CategoryNode | undefined {64 return bySlug.get(slug);65}66export function requireCategory(slug: string): CategoryNode {67 const c = bySlug.get(slug);68 if (!c) throw new Error(`Unknown category slug: ${slug}`);69 return c;70}71export function categoryFamily(slug: string): string {72 return bySlug.get(slug)?.familySlug ?? slug;73}74export function categoryPath(slug: string): CategoryNode[] {75 const out: CategoryNode[] = [];76 let cur = bySlug.get(slug);77 while (cur) {78 out.unshift(cur);79 cur = cur.parent ? bySlug.get(cur.parent) : undefined;80 }81 return out;82}83export function descendants(slug: string): string[] {84 const out: string[] = [];85 const stack = [slug];86 while (stack.length) {87 const s = stack.pop()!;88 const n = bySlug.get(s);89 if (!n) continue;90 for (const c of n.children) {91 out.push(c);92 stack.push(c);93 }94 }95 return out;96}97export function phaseCategories(phase: 1 | 2 | 3): CategoryNode[] {98 return CATEGORIES.filter((c) => c.phase <= phase);99}100101// ---------- Condition scales ----------102export interface ConditionScale {103 values: string[];104 completeness: string[];105 aliases: Record<string, string | null>;106}107export const CONDITION_SCALES: Record<string, ConditionScale> = conditionsJson.scales as Record<string, ConditionScale>;108109export function conditionScaleFor(categorySlug: string): ConditionScale {110 const scale = bySlug.get(categorySlug)?.conditionScale ?? 'general';111 return CONDITION_SCALES[scale] ?? CONDITION_SCALES.general!;112}113114/** Map a raw condition string to the category's normalised slug; null when unknown (never guess). */115export function normalizeCondition(categorySlug: string, raw: string | null | undefined): string | null {116 if (!raw) return null;117 const scale = conditionScaleFor(categorySlug);118 const key = raw.trim().toLowerCase().replace(/\s+/g, ' ');119 if (key in scale.aliases) return scale.aliases[key] ?? null;120 const asSlug = key.replace(/[\s-]+/g, '_');121 if (scale.values.includes(asSlug)) return asSlug;122 // Try contained alias (e.g. "Condition: Near Mint or Better")123 for (const [alias, slug] of Object.entries(scale.aliases)) {124 if (alias.length >= 4 && key.includes(alias)) return slug;125 }126 return null;127}128129export function conditionRank(categorySlug: string, condition: string | null): number | null {130 if (!condition) return null;131 const i = conditionScaleFor(categorySlug).values.indexOf(condition);132 return i >= 0 ? i : null;133}134135// ---------- Graders ----------136export interface GraderDef {137 slug: string;138 name: string;139 categories: string[];140 scale: { type: 'numeric' | 'sheldon' | 'descriptive'; values: string[]; top: string };141 populationUrl?: string;142 verifyUrl?: string;143 aliases: string[];144}145export const GRADERS: readonly GraderDef[] = gradersJson.graders as GraderDef[];146const graderByAlias = new Map<string, GraderDef>();147for (const g of GRADERS) {148 graderByAlias.set(g.slug, g);149 for (const a of g.aliases) graderByAlias.set(a.toLowerCase(), g);150}151export function getGrader(slugOrAlias: string | null | undefined): GraderDef | undefined {152 if (!slugOrAlias) return undefined;153 return graderByAlias.get(slugOrAlias.trim().toLowerCase());154}155156const GRADE_RE = /\b(PSA|BGS|BECKETT|CGC|SGC|TAG|ACE|CBCS|PGX|WATA|VGA|PCGS|NGC|ANACS|ICCS|PMG)\b[\s:-]*(?:GEM\s*MT|GEM\s*MINT|MINT|NM-?MT|PRISTINE|BLACK\s*LABEL)?[\s:-]*(\d{1,3}(?:\.\d)?)?/i;157158/** Extract grader + grade from a free-text title. Returns nulls when absent. */159export function parseGradeFromTitle(title: string): { grader: string | null; grade: string | null; qualifier: string | null } {160 const m = title.match(GRADE_RE);161 if (!m) {162 if (/\b(raw|ungraded)\b/i.test(title)) return { grader: 'raw', grade: null, qualifier: null };163 return { grader: null, grade: null, qualifier: null };164 }165 const grader = getGrader(m[1]!)?.slug ?? m[1]!.toLowerCase();166 let grade = m[2] ?? null;167 const g = getGrader(grader);168 if (grade && g && g.scale.values.length && !g.scale.values.includes(grade)) {169 // e.g. "PSA 10" where 10 is fine; "PSA 2021" is a year → reject170 if (Number(grade) > Number(g.scale.top)) grade = null;171 }172 let qualifier: string | null = null;173 if (/black\s*label/i.test(title)) qualifier = 'Black Label';174 else if (/pristine/i.test(title) && grader === 'bgs') qualifier = 'Pristine';175 else if (/signature\s*series/i.test(title)) qualifier = 'Signature Series';176 else if (/\b(OC|MC|ST|PD|MK)\b/.test(title) && grader === 'psa') qualifier = title.match(/\b(OC|MC|ST|PD|MK)\b/)![1]!;177 return { grader, grade, qualifier };178}179180/** Variant key used by asset_variants (deterministic). */181export 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 } {182 const parts: string[] = [];183 const labelParts: string[] = [];184 if (input.grader && input.grader !== 'raw' && input.grade) {185 parts.push(input.grader, input.grade);186 labelParts.push(`${getGrader(input.grader)?.name ?? input.grader.toUpperCase()} ${input.grade}`);187 if (input.qualifier) {188 parts.push(input.qualifier.toLowerCase().replace(/\s+/g, '_'));189 labelParts.push(input.qualifier);190 }191 } else {192 parts.push('raw');193 if (input.completeness) {194 parts.push(input.completeness);195 labelParts.push(humanize(input.completeness));196 } else if (input.condition) {197 parts.push(input.condition);198 labelParts.push(humanize(input.condition));199 } else {200 labelParts.push('Raw / ungraded');201 }202 }203 if (input.size) {204 parts.push(`size_${input.size.toLowerCase().replace(/\s+/g, '')}`);205 labelParts.push(`Size ${input.size}`);206 }207 return { key: parts.join('|'), label: labelParts.join(' · ') };208}209210export function humanize(slug: string): string {211 return slug.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).replace(/\bCib\b/, 'CIB');212}213214// ---------- Indices ----------215export interface IndexDef {216 ticker: string;217 name: string;218 flagship?: boolean;219 description?: string;220 families: string[];221 color: string;222 minConstituents: number;223}224export const INDEX_BASE_DATE: string = indicesJson.baseDate;225export const INDEX_BASE_VALUE: number = indicesJson.baseValue;226export const INDICES: readonly IndexDef[] = indicesJson.indices as IndexDef[];227export function indexForCategory(categorySlug: string): IndexDef | undefined {228 const ticker = bySlug.get(categorySlug)?.index;229 return ticker ? INDICES.find((i) => i.ticker === ticker) : undefined;230}231