SPB Git

spb/rareindex

Public
5.3 KB · 125 lines typescript
1import { categoryFamily } from '@rareindex/taxonomy';2import { normalizeForMatch, type AssetAttributes } from '@rareindex/shared';34/**5 * Deterministic canonical key per category family (§112). Two descriptions of the same object6 * from different sources must produce the same key when their structured attributes agree.7 * Unknown parts are encoded as '' so partial records still collide with fuller ones only when8 * the discriminating fields match.9 */10const n = (s: string | null | undefined): string => (s ? normalizeForMatch(s).replace(/\s+/g, ' ') : '');11const num = (s: string | null | undefined): string => (s ? s.toLowerCase().replace(/^#/, '').replace(/\s+/g, '') : '');1213/** Identifier keys considered deterministic enough to match on their own. */14export const DETERMINISTIC_IDS = [15  'scryfall_id', 'oracle_id', 'tcgplayer_id', 'cardmarket_id', 'pokemontcg_id', 'ygo_id', 'ygo_set_code', 'psa_spec_id', 'psa_item_id',16  'style_code', 'sku', 'upc', 'ean', 'isbn', 'lego_set_number', 'bricklink_id', 'brickset_id', 'pricecharting_id', 'reference', 'goldin_item_id', 'stockx_id', 'chrono24_id', 'comic_id', 'gocollect_id', 'hobbydb_id', 'funko_id',17] as const;1819export function deterministicIdentifiers(ids: Record<string, string> | undefined): Record<string, string> {20  const out: Record<string, string> = {};21  if (!ids) return out;22  for (const k of DETERMINISTIC_IDS) {23    const v = ids[k];24    if (v && String(v).trim()) out[k] = String(v).trim();25  }26  return out;27}2829export function buildCanonicalKey(a: AssetAttributes): string {30  const family = categoryFamily(a.categorySlug);31  const cat = a.categorySlug;32  switch (family) {33    case 'trading_cards':34    case 'sports_cards':35    case 'non_sport_cards': {36      const set = n(a.setCode) || n(a.set);37      return [cat, set, num(a.number), n(a.name), n(a.variant), n(a.language) || 'en'].join('|');38    }39    case 'watches':40      return [cat, n(a.brand), n(a.reference) || n(a.model), n(a.variant)].join('|');41    case 'sneakers': {42      const style = a.identifiers?.style_code ?? a.identifiers?.sku;43      return style ? [cat, n(style)].join('|') : [cat, n(a.brand), n(a.model) || n(a.name), n(a.color), a.year ?? ''].join('|');44    }45    case 'lego': {46      const setNo = a.identifiers?.lego_set_number ?? a.number ?? a.model;47      return [cat, setNo ? num(setNo) : n(a.name), n(a.variant)].join('|');48    }49    case 'video_games':50    case 'gaming_hardware':51      return [cat, n(a.identifiers?.platform ?? a.series ?? a.brand), n(a.name), n(a.region) || n(a.language), n(a.variant)].join('|');52    case 'comics':53    case 'manga':54      return [cat, n(a.brand), n(a.series) || n(a.name), num(a.number), n(a.variant), a.year ?? ''].join('|');55    case 'funko':56    case 'designer_toys':57    case 'action_figures':58    case 'vintage_toys':59      return [cat, n(a.brand), n(a.series) || n(a.franchise), num(a.number), n(a.name), n(a.variant)].join('|');60    case 'coins':61    case 'banknotes':62      return [cat, n(a.country), a.year ?? '', n(a.name), n(a.variant) || n(a.identifiers?.mint_mark)].join('|');63    case 'wine':64    case 'whisky':65    case 'rum':66    case 'cognac':67      return [cat, n(a.brand), n(a.name), a.year ?? '', n(a.size)].join('|');68    default:69      return [cat, n(a.brand), n(a.name), a.year ?? '', n(a.variant), n(a.reference) || num(a.number)].join('|');70  }71}7273/** Human title used for slugs, search and display. Deterministic from attributes. */74export function composeTitle(a: AssetAttributes): string {75  const family = categoryFamily(a.categorySlug);76  const parts: string[] = [];77  switch (family) {78    case 'trading_cards':79    case 'sports_cards':80    case 'non_sport_cards': {81      let head = a.name;82      if (a.number) head += ` #${a.number.replace(/^#/, '')}`;83      const tail = [a.set, a.edition, a.variant].filter(Boolean).join(' ');84      parts.push(head);85      if (tail) parts.push(tail);86      if (a.year) parts.push(`(${a.year})`);87      break;88    }89    case 'watches':90      parts.push([a.brand, a.model ?? a.name].filter(Boolean).join(' '));91      if (a.reference) parts.push(a.reference);92      if (a.variant) parts.push(a.variant);93      break;94    case 'sneakers':95      parts.push(a.name);96      if (a.color && !a.name.toLowerCase().includes(a.color.toLowerCase())) parts.push(a.color);97      if (a.identifiers?.style_code) parts.push(a.identifiers.style_code);98      break;99    case 'lego': {100      const setNo = a.identifiers?.lego_set_number ?? a.number;101      parts.push([setNo ? `LEGO ${setNo}` : 'LEGO', a.name].filter(Boolean).join(' '));102      if (a.series) parts.push(a.series);103      if (a.year) parts.push(`(${a.year})`);104      break;105    }106    case 'video_games':107    case 'gaming_hardware':108      parts.push(a.name);109      if (a.identifiers?.platform ?? a.series) parts.push(a.identifiers?.platform ?? a.series ?? '');110      if (a.region) parts.push(a.region);111      break;112    case 'comics':113    case 'manga':114      parts.push([a.series ?? a.name, a.number ? `#${a.number}` : null].filter(Boolean).join(' '));115      if (a.variant) parts.push(a.variant);116      if (a.year) parts.push(`(${a.year})`);117      break;118    default:119      parts.push([a.brand, a.name].filter(Boolean).join(' '));120      if (a.variant) parts.push(a.variant);121      if (a.year) parts.push(`(${a.year})`);122  }123  return parts.filter(Boolean).join(' · ').replace(/\s+/g, ' ').trim();124}125
Files · d93392862678cbf30e175697fcb812bc4ff0b5a7
Menu

Theme