(function () { const DEFAULT_DECK_ID = "ceremonial-magick"; const trumpNumberByCanonicalName = { fool: 0, magus: 1, magician: 1, "high priestess": 2, empress: 3, emperor: 4, hierophant: 5, lovers: 6, chariot: 7, lust: 8, strength: 8, hermit: 9, fortune: 10, "wheel of fortune": 10, justice: 11, "hanged man": 12, death: 13, art: 14, temperance: 14, devil: 15, tower: 16, star: 17, moon: 18, sun: 19, aeon: 20, judgement: 20, judgment: 20, universe: 21, world: 21 }; const pipValueByToken = { ace: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9, "10": 10 }; const rankWordByPipValue = { 1: "Ace", 2: "Two", 3: "Three", 4: "Four", 5: "Five", 6: "Six", 7: "Seven", 8: "Eight", 9: "Nine", 10: "Ten" }; const trumpRomanToNumber = { I: 1, II: 2, III: 3, IV: 4, V: 5, VI: 6, VII: 7, VIII: 8, IX: 9, X: 10, XI: 11, XII: 12, XIII: 13, XIV: 14, XV: 15, XVI: 16, XVII: 17, XVIII: 18, XIX: 19, XX: 20, XXI: 21 }; const suitSearchAliasesById = { wands: ["wands"], cups: ["cups"], swords: ["swords"], disks: ["disks", "pentacles", "coins"] }; const DECK_REGISTRY_PATH = "asset/tarot deck/decks.json"; const deckManifestSources = buildDeckManifestSources(); const manifestCache = new Map(); let activeDeckId = DEFAULT_DECK_ID; function canonicalMajorName(cardName) { return String(cardName || "") .trim() .toLowerCase() .replace(/^the\s+/, "") .replace(/\s+/g, " "); } function canonicalMinorName(cardName) { const parsedMinor = parseMinorCard(cardName); if (!parsedMinor) { return ""; } return `${String(parsedMinor.rankKey || "").trim().toLowerCase()} of ${parsedMinor.suitId}`; } function toTitleCase(value) { const normalized = String(value || "").trim().toLowerCase(); if (!normalized) { return ""; } return normalized.charAt(0).toUpperCase() + normalized.slice(1); } function normalizeDeckId(deckId) { const normalized = String(deckId || "").trim().toLowerCase(); if (deckManifestSources[normalized]) { return normalized; } if (deckManifestSources[DEFAULT_DECK_ID]) { return DEFAULT_DECK_ID; } const fallbackId = Object.keys(deckManifestSources)[0]; return fallbackId || DEFAULT_DECK_ID; } function normalizeTrumpNumber(value) { const parsed = Number(value); if (!Number.isInteger(parsed) || parsed < 0 || parsed > 21) { return null; } return parsed; } function parseTrumpNumberKey(value) { const normalized = String(value || "").trim().toUpperCase(); if (!normalized) { return null; } if (/^\d+$/.test(normalized)) { return normalizeTrumpNumber(Number(normalized)); } if (Object.prototype.hasOwnProperty.call(trumpRomanToNumber, normalized)) { return normalizeTrumpNumber(trumpRomanToNumber[normalized]); } return null; } function normalizeSuitId(suitInput) { const suit = String(suitInput || "").trim().toLowerCase(); if (suit === "pentacles") { return "disks"; } return suit; } function resolveDeckOptions(optionsOrDeckId) { let resolvedDeckId = activeDeckId; let trumpNumber = null; if (typeof optionsOrDeckId === "string") { resolvedDeckId = normalizeDeckId(optionsOrDeckId); } else if (optionsOrDeckId && typeof optionsOrDeckId === "object") { if (optionsOrDeckId.deckId) { resolvedDeckId = normalizeDeckId(optionsOrDeckId.deckId); } trumpNumber = normalizeTrumpNumber(optionsOrDeckId.trumpNumber); } return { resolvedDeckId, trumpNumber }; } function parseMinorCard(cardName) { const match = String(cardName || "") .trim() .match(/^(ace|two|three|four|five|six|seven|eight|nine|ten|knight|queen|prince|princess|king|page|[2-9]|10)\s+of\s+(cups|wands|swords|pentacles|disks)$/i); if (!match) { return null; } const rankToken = String(match[1] || "").toLowerCase(); const suitId = normalizeSuitId(match[2]); const pipValue = pipValueByToken[rankToken] ?? null; if (Number.isFinite(pipValue)) { const rankWord = rankWordByPipValue[pipValue] || ""; return { suitId, pipValue, court: "", rankWord, rankKey: rankWord.toLowerCase() }; } const courtWord = toTitleCase(rankToken); if (!courtWord) { return null; } return { suitId, pipValue: null, court: rankToken, rankWord: courtWord, rankKey: rankToken }; } function applyTemplate(template, variables) { return String(template || "") .replace(/\{([a-zA-Z0-9_]+)\}/g, (_, token) => { const value = variables[token]; return value == null ? "" : String(value); }); } function readManifestJsonSync(path) { try { const request = new XMLHttpRequest(); request.open("GET", encodeURI(path), false); request.send(null); const okStatus = (request.status >= 200 && request.status < 300) || request.status === 0; if (!okStatus || !request.responseText) { return null; } return JSON.parse(request.responseText); } catch { return null; } } function toDeckSourceMap(sourceList) { const sourceMap = {}; if (!Array.isArray(sourceList)) { return sourceMap; } sourceList.forEach((entry) => { const id = String(entry?.id || "").trim().toLowerCase(); const basePath = String(entry?.basePath || "").trim().replace(/\/$/, ""); const manifestPath = String(entry?.manifestPath || "").trim(); if (!id || !basePath || !manifestPath) { return; } sourceMap[id] = { id, label: String(entry?.label || id), basePath, manifestPath }; }); return sourceMap; } function buildDeckManifestSources() { const registry = readManifestJsonSync(DECK_REGISTRY_PATH); const registryDecks = Array.isArray(registry) ? registry : (Array.isArray(registry?.decks) ? registry.decks : null); return toDeckSourceMap(registryDecks); } function normalizeDeckManifest(source, rawManifest) { if (!rawManifest || typeof rawManifest !== "object") { return null; } const rawNameOverrides = rawManifest.nameOverrides; const nameOverrides = {}; if (rawNameOverrides && typeof rawNameOverrides === "object") { Object.entries(rawNameOverrides).forEach(([rawKey, rawValue]) => { const key = canonicalMajorName(rawKey); const value = String(rawValue || "").trim(); if (key && value) { nameOverrides[key] = value; } }); } const rawMajorNameOverridesByTrump = rawManifest.majorNameOverridesByTrump; const majorNameOverridesByTrump = {}; if (rawMajorNameOverridesByTrump && typeof rawMajorNameOverridesByTrump === "object") { Object.entries(rawMajorNameOverridesByTrump).forEach(([rawKey, rawValue]) => { const trumpNumber = parseTrumpNumberKey(rawKey); const value = String(rawValue || "").trim(); if (Number.isInteger(trumpNumber) && value) { majorNameOverridesByTrump[trumpNumber] = value; } }); } const rawMinorNameOverrides = rawManifest.minorNameOverrides; const minorNameOverrides = {}; if (rawMinorNameOverrides && typeof rawMinorNameOverrides === "object") { Object.entries(rawMinorNameOverrides).forEach(([rawKey, rawValue]) => { const key = canonicalMinorName(rawKey); const value = String(rawValue || "").trim(); if (key && value) { minorNameOverrides[key] = value; } }); } return { id: source.id, label: String(rawManifest.label || source.label || source.id), basePath: String(source.basePath || "").replace(/\/$/, ""), majors: rawManifest.majors || {}, minors: rawManifest.minors || {}, nameOverrides, minorNameOverrides, majorNameOverridesByTrump }; } function getDeckManifest(deckId) { const normalizedDeckId = normalizeDeckId(deckId); if (manifestCache.has(normalizedDeckId)) { return manifestCache.get(normalizedDeckId); } const source = deckManifestSources[normalizedDeckId]; if (!source) { manifestCache.set(normalizedDeckId, null); return null; } const rawManifest = readManifestJsonSync(source.manifestPath); const normalizedManifest = normalizeDeckManifest(source, rawManifest); manifestCache.set(normalizedDeckId, normalizedManifest); return normalizedManifest; } function getRankIndex(minorRule, parsedMinor) { if (!minorRule || !parsedMinor) { return null; } const lowerRankWord = String(parsedMinor.rankWord || "").toLowerCase(); const lowerRankKey = String(parsedMinor.rankKey || "").toLowerCase(); const indexByKey = minorRule.rankIndexByKey; if (indexByKey && typeof indexByKey === "object") { const mapped = Number(indexByKey[lowerRankKey]); if (Number.isInteger(mapped) && mapped >= 0) { return mapped; } } const rankOrder = Array.isArray(minorRule.rankOrder) ? minorRule.rankOrder : []; for (let i = 0; i < rankOrder.length; i += 1) { const candidate = String(rankOrder[i] || "").toLowerCase(); if (candidate && (candidate === lowerRankWord || candidate === lowerRankKey)) { return i; } } return null; } function resolveMajorFile(manifest, canonicalName) { const majorRule = manifest?.majors; if (!majorRule || typeof majorRule !== "object") { return null; } if (majorRule.mode === "canonical-map") { const cards = majorRule.cards || {}; const fileName = cards[canonicalName]; return typeof fileName === "string" && fileName ? fileName : null; } const trumpNo = trumpNumberByCanonicalName[canonicalName]; if (!Number.isInteger(trumpNo) || trumpNo < 0 || trumpNo > 21) { return null; } if (majorRule.mode === "trump-map") { const cards = majorRule.cards || {}; const fileName = cards[String(trumpNo)] ?? cards[trumpNo]; return typeof fileName === "string" && fileName ? fileName : null; } if (majorRule.mode === "trump-template") { const numberPad = Number.isInteger(majorRule.numberPad) ? majorRule.numberPad : 2; const template = String(majorRule.template || "{number}.png"); const number = String(trumpNo).padStart(numberPad, "0"); return applyTemplate(template, { trump: trumpNo, number }); } return null; } function resolveMinorFile(manifest, parsedMinor) { const minorRule = manifest?.minors; if (!minorRule || typeof minorRule !== "object") { return null; } const rankIndex = getRankIndex(minorRule, parsedMinor); if (!Number.isInteger(rankIndex) || rankIndex < 0) { return null; } if (minorRule.mode === "suit-base-and-rank-order") { const suitBaseRaw = Number(minorRule?.suitBase?.[parsedMinor.suitId]); if (!Number.isFinite(suitBaseRaw)) { return null; } const numberPad = Number.isInteger(minorRule.numberPad) ? minorRule.numberPad : 2; const cardNumber = String(suitBaseRaw + rankIndex).padStart(numberPad, "0"); const suitWord = String(minorRule?.suitLabel?.[parsedMinor.suitId] || toTitleCase(parsedMinor.suitId)); const template = String(minorRule.template || "{number}_{rank} {suit}.webp"); return applyTemplate(template, { number: cardNumber, rank: parsedMinor.rankWord, rankKey: parsedMinor.rankKey, suit: suitWord, suitId: parsedMinor.suitId, index: rankIndex + 1 }); } if (minorRule.mode === "suit-prefix-and-rank-order") { const suitPrefix = minorRule?.suitPrefix?.[parsedMinor.suitId]; if (!suitPrefix) { return null; } const indexStart = Number.isInteger(minorRule.indexStart) ? minorRule.indexStart : 1; const indexPad = Number.isInteger(minorRule.indexPad) ? minorRule.indexPad : 2; const suitIndex = String(indexStart + rankIndex).padStart(indexPad, "0"); const template = String(minorRule.template || "{suit}{index}.png"); return applyTemplate(template, { suit: suitPrefix, suitId: parsedMinor.suitId, index: suitIndex, rank: parsedMinor.rankWord, rankKey: parsedMinor.rankKey }); } if (minorRule.mode === "suit-base-number-template") { const suitBaseRaw = Number(minorRule?.suitBase?.[parsedMinor.suitId]); if (!Number.isFinite(suitBaseRaw)) { return null; } const numberPad = Number.isInteger(minorRule.numberPad) ? minorRule.numberPad : 2; const cardNumber = String(suitBaseRaw + rankIndex).padStart(numberPad, "0"); const template = String(minorRule.template || "{number}.png"); return applyTemplate(template, { number: cardNumber, suitId: parsedMinor.suitId, rank: parsedMinor.rankWord, rankKey: parsedMinor.rankKey, index: rankIndex }); } return null; } function resolveWithDeck(deckId, cardName) { const manifest = getDeckManifest(deckId); if (!manifest) { return null; } const canonical = canonicalMajorName(cardName); const majorFile = resolveMajorFile(manifest, canonical); if (majorFile) { return `${manifest.basePath}/${majorFile}`; } const parsedMinor = parseMinorCard(cardName); if (!parsedMinor) { return null; } const minorFile = resolveMinorFile(manifest, parsedMinor); if (!minorFile) { return null; } return `${manifest.basePath}/${minorFile}`; } function resolveTarotCardImage(cardName) { const activePath = resolveWithDeck(activeDeckId, cardName); if (activePath) { return encodeURI(activePath); } if (activeDeckId !== DEFAULT_DECK_ID) { const fallbackPath = resolveWithDeck(DEFAULT_DECK_ID, cardName); if (fallbackPath) { return encodeURI(fallbackPath); } } return null; } function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) { const manifest = getDeckManifest(deckId); const fallbackName = String(cardName || "").trim(); if (!manifest) { return fallbackName; } let resolvedTrumpNumber = normalizeTrumpNumber(trumpNumber); if (!Number.isInteger(resolvedTrumpNumber)) { const canonical = canonicalMajorName(cardName); resolvedTrumpNumber = normalizeTrumpNumber(trumpNumberByCanonicalName[canonical]); } if (Number.isInteger(resolvedTrumpNumber)) { const byTrump = manifest?.majorNameOverridesByTrump?.[resolvedTrumpNumber]; if (byTrump) { return byTrump; } } const canonical = canonicalMajorName(cardName); const override = manifest?.nameOverrides?.[canonical]; if (override) { return override; } const minorKey = canonicalMinorName(cardName); const minorOverride = manifest?.minorNameOverrides?.[minorKey]; if (minorOverride) { return minorOverride; } return fallbackName; } function getTarotCardSearchAliases(cardName, optionsOrDeckId) { const fallbackName = String(cardName || "").trim(); if (!fallbackName) { return []; } const { resolvedDeckId, trumpNumber } = resolveDeckOptions(optionsOrDeckId); const aliases = new Set(); aliases.add(fallbackName); const displayName = String(resolveDisplayNameWithDeck(resolvedDeckId, fallbackName, trumpNumber) || "").trim(); if (displayName) { aliases.add(displayName); } const canonicalMajor = canonicalMajorName(fallbackName); const resolvedTrumpNumber = Number.isInteger(normalizeTrumpNumber(trumpNumber)) ? normalizeTrumpNumber(trumpNumber) : normalizeTrumpNumber(trumpNumberByCanonicalName[canonicalMajor]); if (Number.isInteger(resolvedTrumpNumber)) { aliases.add(canonicalMajor); aliases.add(`the ${canonicalMajor}`); aliases.add(`trump ${resolvedTrumpNumber}`); } const parsedMinor = parseMinorCard(fallbackName); if (parsedMinor) { const suitAliases = suitSearchAliasesById[parsedMinor.suitId] || [parsedMinor.suitId]; suitAliases.forEach((suitAlias) => { aliases.add(`${parsedMinor.rankKey} of ${suitAlias}`); if (Number.isInteger(parsedMinor.pipValue)) { aliases.add(`${parsedMinor.pipValue} of ${suitAlias}`); } }); } return Array.from(aliases); } function getTarotCardDisplayName(cardName, optionsOrDeckId) { const { resolvedDeckId, trumpNumber } = resolveDeckOptions(optionsOrDeckId); return resolveDisplayNameWithDeck(resolvedDeckId, cardName, trumpNumber); } function setActiveDeck(deckId) { activeDeckId = normalizeDeckId(deckId); getDeckManifest(activeDeckId); return activeDeckId; } function getDeckOptions() { return Object.values(deckManifestSources).map((source) => { const manifest = getDeckManifest(source.id); return { id: source.id, label: manifest?.label || source.label }; }); } document.addEventListener("settings:updated", (event) => { const nextDeck = event?.detail?.settings?.tarotDeck; setActiveDeck(nextDeck); }); window.TarotCardImages = { resolveTarotCardImage, getTarotCardDisplayName, getTarotCardSearchAliases, setActiveDeck, getDeckOptions, getActiveDeck: () => activeDeckId }; })();