diff --git a/client/src/services/__tests__/scryfall.test.ts b/client/src/services/__tests__/scryfall.test.ts index 58f460ebbc..46b4a28841 100644 --- a/client/src/services/__tests__/scryfall.test.ts +++ b/client/src/services/__tests__/scryfall.test.ts @@ -11,12 +11,14 @@ const REPO_ROOT = path.resolve( ); function makeLocalDataMap( - cards: Record, + cards: Record, ): Response { const map: Record = {}; for (const [key, card] of Object.entries(cards)) { map[key.toLowerCase()] = { name: card.name, + oracle_id: card.oracle_id ?? key, + face_names: [card.name.toLowerCase()], mana_cost: card.mana_cost ?? "{1}", cmc: card.cmc ?? 1, type_line: card.type_line ?? "Instant", @@ -157,6 +159,19 @@ describe("fetchCardData", () => { expect(card.name).toBe("Abrade"); }); + + it("resolves ASCII names to diacritic local data keys (issue #1497)", async () => { + global.fetch = vi.fn().mockResolvedValueOnce( + makeLocalDataMap({ + "éomer of the riddermark": { name: "Éomer of the Riddermark", oracle_id: "eomer-oracle" }, + }), + ); + + const { resolveOracleIdSync, fetchCardImageUrl, loadScryfallData } = await loadScryfallModule(); + await loadScryfallData(); + expect(resolveOracleIdSync("Eomer of the Riddermark")).toBe("eomer-oracle"); + await expect(fetchCardImageUrl("Eomer of the Riddermark", 0)).resolves.toMatch(/^https?:\/\//); + }); }); describe("fetchCardImageUrl", () => { diff --git a/client/src/services/scryfall.ts b/client/src/services/scryfall.ts index 2b1f5bc12a..e7ec11a919 100644 --- a/client/src/services/scryfall.ts +++ b/client/src/services/scryfall.ts @@ -50,6 +50,8 @@ type TokenImagesDataMap = Record | null = null; let scryfallDataResolved: ScryfallDataMap | null = null; +/** Maps diacritic-folded lowercase names to canonical scryfall-data keys. */ +let scryfallFoldedNameIndex: Map | null = null; let printingsDataPromise: Promise | null = null; let tokenImagesDataPromise: Promise | null = null; let scryfallQueue: Promise = Promise.resolve(); @@ -60,6 +62,7 @@ export function loadScryfallData(): Promise { .then((r) => r.json() as Promise) .then((data) => { scryfallDataResolved = data; + scryfallFoldedNameIndex = buildFoldedNameIndex(data); return data; }) .catch(() => null); @@ -106,8 +109,8 @@ export async function getCardPrintings(oracleId: string): Promise { - const data = await loadScryfallData(); - const entry = data?.[cardName.toLowerCase()]; + await loadScryfallData(); + const entry = lookupEntryByName(cardName); if (!entry) return []; return getCardPrintings(entry.oracle_id); } @@ -130,7 +133,7 @@ export function findPrintingById( export function resolveOracleIdSync(cardName: string): string | null { if (!scryfallDataResolved) return null; - return scryfallDataResolved[cardName.toLowerCase()]?.oracle_id ?? null; + return lookupEntryByName(cardName)?.oracle_id ?? null; } /** @@ -159,7 +162,7 @@ export function resolveFaceIndexSync( export function isCardImageRotatedSync(oracleId: string, cardName: string): boolean { if (!scryfallDataResolved) return false; const entry = scryfallDataResolved[oracleId.toLowerCase()] - ?? scryfallDataResolved[normalizeCardName(cardName).toLowerCase()]; + ?? lookupEntryByName(cardName); return isSidewaysLayout(entry?.layout); } @@ -169,7 +172,7 @@ export function isCardImageRotatedSync(oracleId: string, cardName: string): bool export function isCardImageFlipLayoutSync(oracleId: string, cardName: string): boolean { if (!scryfallDataResolved) return false; const entry = scryfallDataResolved[oracleId.toLowerCase()] - ?? scryfallDataResolved[normalizeCardName(cardName).toLowerCase()]; + ?? lookupEntryByName(cardName); return isFlipLayout(entry?.layout); } @@ -340,12 +343,40 @@ export function normalizeCardName(name: string): string { .trim(); } +/** Strip combining marks so "Eomer" matches "Éomer" in local image data. */ +function foldDiacritics(value: string): string { + return value.normalize("NFD").replace(/\p{M}/gu, ""); +} + +function buildFoldedNameIndex(data: ScryfallDataMap): Map { + const index = new Map(); + for (const key of Object.keys(data)) { + const folded = foldDiacritics(key); + if (!index.has(folded)) { + index.set(folded, key); + } + } + return index; +} + +function resolveNameLookupKey(name: string): string { + const normalized = normalizeCardName(name).toLowerCase(); + if (!scryfallDataResolved) return normalized; + if (scryfallDataResolved[normalized]) return normalized; + const folded = foldDiacritics(normalized); + return scryfallFoldedNameIndex?.get(folded) ?? normalized; +} + +function lookupEntryByName(name: string): ScryfallDataEntry | undefined { + if (!scryfallDataResolved) return undefined; + return scryfallDataResolved[resolveNameLookupKey(name)]; +} + export async function fetchCardData(cardName: string): Promise { - const name = normalizeCardName(cardName); - const localMap = await loadScryfallData(); - const entry = localMap?.[name.toLowerCase()]; + await loadScryfallData(); + const entry = lookupEntryByName(cardName); if (!entry) { - throw new Error(`Card not in local data: "${name}"`); + throw new Error(`Card not in local data: "${normalizeCardName(cardName)}"`); } return { name: entry.name, @@ -426,12 +457,12 @@ export async function fetchCardImageAsset( faceIndex: number, size: ImageSize = "normal", ): Promise { - const data = await loadScryfallData(); - const name = normalizeCardName(cardName).toLowerCase(); - const entry = data?.[name]; + await loadScryfallData(); + const entry = lookupEntryByName(cardName); if (!entry) { - throw new Error(`Card image not in local data: "${name}"`); + throw new Error(`Card image not in local data: "${normalizeCardName(cardName)}"`); } + const name = resolveNameLookupKey(cardName); return resolveImageAsset(entry, faceIndex, size, name); }