const DEFAULT_TCGDEX_BASE_URL = 'https://api.tcgdex.net/v2/en'; const delay = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); export function getTcgdexBaseUrl() { return process.env.TCGDEX_BASE_URL ?? DEFAULT_TCGDEX_BASE_URL; } function tcgplayerVariant(tcgplayer) { if (!tcgplayer || typeof tcgplayer !== 'object') return null; const preferredKeys = ['normal', 'holofoil', 'reverse', 'reverse-holofoil', 'unlimited', '1st-edition']; for (const key of preferredKeys) { const variant = tcgplayer[key]; if (variant && typeof variant === 'object') { return variant; } } return Object.values(tcgplayer).find( (value) => value && typeof value === 'object' && ('marketPrice' in value || 'lowPrice' in value) ) ?? null; } /** * Parse TCGdex pricing into Deck Hearth card price fields. * USD from TCGPlayer; Cardmarket EUR fills market_price only when USD is missing. */ export function parseTcgdexPricing(card) { let currentPrice = null; let marketPrice = null; let currency = 'USD'; const variant = tcgplayerVariant(card.pricing?.tcgplayer); if (variant) { const low = variant.lowPrice ?? variant.directLowPrice; const market = variant.marketPrice ?? variant.midPrice; currentPrice = low != null ? Number(low) : (market != null ? Number(market) : null); marketPrice = market != null ? Number(market) : currentPrice; } // Cardmarket (EUR) — use for market_price only when TCGPlayer USD is unavailable. if (currentPrice == null && marketPrice == null) { const cardmarket = card.pricing?.cardmarket; const eur = cardmarket?.trend ?? cardmarket?.avg ?? cardmarket?.low; if (eur != null) { marketPrice = Number(eur); currency = 'EUR'; } } if (currentPrice != null && Number.isNaN(currentPrice)) currentPrice = null; if (marketPrice != null && Number.isNaN(marketPrice)) marketPrice = null; return { currentPrice, marketPrice, currency }; } export function normalizeTcgdexName(name) { return String(name ?? '').trim().toLowerCase(); } function cardNumberKeys(card) { const keys = new Set(); const localId = card.localId ?? card.id?.split('-').pop(); if (localId != null && localId !== '') { keys.add(String(localId).trim().toLowerCase()); const official = card.set?.cardCount?.official; if (official) { keys.add(`${String(parseInt(localId, 10))}/${official}`.toLowerCase()); } } return keys; } /** * Build lookup indexes for matching DB rows by set_code + card_number or name. */ export function buildTcgdexPriceIndex(cards) { const byNumber = new Map(); const byName = new Map(); for (const card of cards) { for (const key of cardNumberKeys(card)) { if (!byNumber.has(key)) { byNumber.set(key, card); } } const nameKey = normalizeTcgdexName(card.name); if (!byName.has(nameKey)) { byName.set(nameKey, []); } byName.get(nameKey).push(card); } return { byNumber, byName }; } /** * Match a catalog row to a TCGdex card within a set. */ export function matchTcgdexCard(index, row) { const cardNumber = String(row.cardNumber ?? '').trim().toLowerCase(); if (cardNumber) { const direct = index.byNumber.get(cardNumber); if (direct) return direct; const slashIndex = cardNumber.indexOf('/'); if (slashIndex !== -1) { const numerator = String(parseInt(cardNumber.slice(0, slashIndex), 10)); if (numerator !== 'NaN') { const byNumerator = index.byNumber.get(numerator); if (byNumerator) return byNumerator; } } } const nameKey = normalizeTcgdexName(row.name); const candidates = index.byName.get(nameKey) ?? []; return candidates.length === 1 ? candidates[0] : null; } async function fetchTcgdexJson(path) { const response = await fetch(`${getTcgdexBaseUrl()}${path}`, { headers: { Accept: 'application/json', 'User-Agent': 'DeckHearth/1.0' }, }); if (!response.ok) { throw new Error(`TCGdex API error: ${response.status} for ${path}`); } return response.json(); } /** * Fetch all cards for a Pokémon set, including pricing when available. */ export async function fetchSetCards(setId) { const setData = await fetchTcgdexJson(`/sets/${encodeURIComponent(setId)}`); const summaries = Array.isArray(setData?.cards) ? setData.cards : []; const cards = []; for (const summary of summaries) { if (!summary?.id) continue; if (summary.pricing) { cards.push(summary); continue; } try { const fullCard = await fetchTcgdexJson(`/cards/${encodeURIComponent(summary.id)}`); cards.push(fullCard); } catch { cards.push(summary); } } return cards; } export { delay };