Files
TaroTime/app/data-service.js
2026-03-20 13:39:54 -07:00

672 lines
19 KiB
JavaScript

(function () {
let magickManifestCache = null;
let magickDataCache = null;
let deckOptionsCache = null;
const deckManifestCache = new Map();
let quizCategoriesCache = null;
const quizTemplatesCache = new Map();
let textLibraryCache = null;
const textSourceCache = new Map();
const textSectionCache = new Map();
const textLexiconCache = new Map();
const textLexiconOccurrencesCache = new Map();
const textSearchCache = new Map();
const DATA_ROOT = "data";
const MAGICK_ROOT = DATA_ROOT;
const TAROT_TRUMP_NUMBER_BY_NAME = {
"the fool": 0,
fool: 0,
"the magus": 1,
magus: 1,
magician: 1,
"the high priestess": 2,
"high priestess": 2,
"the empress": 3,
empress: 3,
"the emperor": 4,
emperor: 4,
"the hierophant": 5,
hierophant: 5,
"the lovers": 6,
lovers: 6,
"the chariot": 7,
chariot: 7,
strength: 8,
lust: 8,
"the hermit": 9,
hermit: 9,
fortune: 10,
"wheel of fortune": 10,
justice: 11,
"the hanged man": 12,
"hanged man": 12,
death: 13,
temperance: 14,
art: 14,
"the devil": 15,
devil: 15,
"the tower": 16,
tower: 16,
"the star": 17,
star: 17,
"the moon": 18,
moon: 18,
"the sun": 19,
sun: 19,
aeon: 20,
judgement: 20,
judgment: 20,
universe: 21,
world: 21,
"the world": 21
};
const HEBREW_BY_TRUMP_NUMBER = {
0: { hebrewLetterId: "alef", kabbalahPathNumber: 11 },
1: { hebrewLetterId: "bet", kabbalahPathNumber: 12 },
2: { hebrewLetterId: "gimel", kabbalahPathNumber: 13 },
3: { hebrewLetterId: "dalet", kabbalahPathNumber: 14 },
4: { hebrewLetterId: "he", kabbalahPathNumber: 15 },
5: { hebrewLetterId: "vav", kabbalahPathNumber: 16 },
6: { hebrewLetterId: "zayin", kabbalahPathNumber: 17 },
7: { hebrewLetterId: "het", kabbalahPathNumber: 18 },
8: { hebrewLetterId: "tet", kabbalahPathNumber: 19 },
9: { hebrewLetterId: "yod", kabbalahPathNumber: 20 },
10: { hebrewLetterId: "kaf", kabbalahPathNumber: 21 },
11: { hebrewLetterId: "lamed", kabbalahPathNumber: 22 },
12: { hebrewLetterId: "mem", kabbalahPathNumber: 23 },
13: { hebrewLetterId: "nun", kabbalahPathNumber: 24 },
14: { hebrewLetterId: "samekh", kabbalahPathNumber: 25 },
15: { hebrewLetterId: "ayin", kabbalahPathNumber: 26 },
16: { hebrewLetterId: "pe", kabbalahPathNumber: 27 },
17: { hebrewLetterId: "tsadi", kabbalahPathNumber: 28 },
18: { hebrewLetterId: "qof", kabbalahPathNumber: 29 },
19: { hebrewLetterId: "resh", kabbalahPathNumber: 30 },
20: { hebrewLetterId: "shin", kabbalahPathNumber: 31 },
21: { hebrewLetterId: "tav", kabbalahPathNumber: 32 }
};
const ICHING_PLANET_BY_PLANET_ID = {
sol: "Sun",
luna: "Moon",
mercury: "Mercury",
venus: "Venus",
mars: "Mars",
jupiter: "Jupiter",
saturn: "Saturn",
earth: "Earth",
uranus: "Uranus",
neptune: "Neptune",
pluto: "Pluto"
};
function buildRequestHeaders() {
const apiKey = getApiKey();
return apiKey
? {
"x-api-key": apiKey
}
: undefined;
}
async function fetchJson(path) {
if (!path) {
throw new Error("API connection is not configured.");
}
const response = await fetch(path, {
headers: buildRequestHeaders()
});
if (!response.ok) {
throw new Error(`Failed to load ${path} (${response.status})`);
}
return response.json();
}
function buildObjectPath(target, pathParts, value) {
let cursor = target;
for (let index = 0; index < pathParts.length - 1; index += 1) {
const part = pathParts[index];
if (!cursor[part] || typeof cursor[part] !== "object") {
cursor[part] = {};
}
cursor = cursor[part];
}
cursor[pathParts[pathParts.length - 1]] = value;
}
function normalizeApiBaseUrl(value) {
return String(value || "")
.trim()
.replace(/\/+$/, "");
}
function getApiBaseUrl() {
return normalizeApiBaseUrl(
window.TarotAppConfig?.getApiBaseUrl?.() || window.TarotAppConfig?.apiBaseUrl || ""
);
}
function getApiKey() {
return String(window.TarotAppConfig?.getApiKey?.() || window.TarotAppConfig?.apiKey || "")
.trim();
}
function isApiEnabled() {
return Boolean(getApiBaseUrl());
}
function encodePathSegments(pathValue) {
return String(pathValue || "")
.split("/")
.filter(Boolean)
.map((segment) => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch {
return encodeURIComponent(segment);
}
})
.join("/");
}
function buildApiUrl(path, query = {}) {
const apiBaseUrl = getApiBaseUrl();
if (!apiBaseUrl) {
return "";
}
const url = new URL(path, `${apiBaseUrl}/`);
Object.entries(query || {}).forEach(([key, value]) => {
if (value == null) {
return;
}
const normalizedValue = String(value).trim();
if (!normalizedValue) {
return;
}
url.searchParams.set(key, normalizedValue);
});
const apiKey = getApiKey();
if (apiKey && !url.searchParams.has("api_key")) {
url.searchParams.set("api_key", apiKey);
}
return url.toString();
}
function toApiAssetUrl(assetPath) {
const apiBaseUrl = getApiBaseUrl();
const normalizedAssetPath = String(assetPath || "")
.trim()
.replace(/^\/+/, "")
.replace(/^asset\//i, "");
if (!apiBaseUrl || !normalizedAssetPath) {
return "";
}
const url = new URL(`/api/v1/assets/${encodePathSegments(normalizedAssetPath)}`, `${apiBaseUrl}/`);
const apiKey = getApiKey();
if (apiKey) {
url.searchParams.set("api_key", apiKey);
}
return url.toString();
}
function resetCaches() {
magickManifestCache = null;
magickDataCache = null;
deckOptionsCache = null;
quizCategoriesCache = null;
textLibraryCache = null;
deckManifestCache.clear();
quizTemplatesCache.clear();
textSourceCache.clear();
textSectionCache.clear();
textLexiconCache.clear();
textLexiconOccurrencesCache.clear();
textSearchCache.clear();
}
function normalizeTarotName(value) {
return String(value || "")
.trim()
.toLowerCase()
.replace(/\s+/g, " ");
}
function resolveTarotTrumpNumber(cardName) {
const key = normalizeTarotName(cardName);
if (!key) {
return null;
}
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, key)) {
return TAROT_TRUMP_NUMBER_BY_NAME[key];
}
const withoutLeadingThe = key.replace(/^the\s+/, "");
if (Object.prototype.hasOwnProperty.call(TAROT_TRUMP_NUMBER_BY_NAME, withoutLeadingThe)) {
return TAROT_TRUMP_NUMBER_BY_NAME[withoutLeadingThe];
}
return null;
}
function enrichAssociation(associations) {
if (!associations || typeof associations !== "object") {
return associations;
}
const next = { ...associations };
if (next.tarotCard) {
const trumpNumber = resolveTarotTrumpNumber(next.tarotCard);
if (trumpNumber != null) {
if (!Number.isFinite(Number(next.tarotTrumpNumber))) {
next.tarotTrumpNumber = trumpNumber;
}
const hebrew = HEBREW_BY_TRUMP_NUMBER[trumpNumber];
if (hebrew) {
if (!next.hebrewLetterId) {
next.hebrewLetterId = hebrew.hebrewLetterId;
}
if (!Number.isFinite(Number(next.kabbalahPathNumber))) {
next.kabbalahPathNumber = hebrew.kabbalahPathNumber;
}
}
}
}
const planetId = String(next.planetId || "").trim().toLowerCase();
if (!next.iChingPlanetaryInfluence && planetId) {
const influence = ICHING_PLANET_BY_PLANET_ID[planetId];
if (influence) {
next.iChingPlanetaryInfluence = influence;
}
}
return next;
}
function enrichCalendarMonth(month) {
const events = Array.isArray(month?.events)
? month.events.map((event) => ({
...event,
associations: enrichAssociation(event?.associations)
}))
: [];
return {
...month,
associations: enrichAssociation(month?.associations),
events
};
}
function enrichCelestialHoliday(holiday) {
return {
...holiday,
associations: enrichAssociation(holiday?.associations)
};
}
function enrichCalendarHoliday(holiday) {
return {
...holiday,
associations: enrichAssociation(holiday?.associations)
};
}
async function loadMagickManifest() {
if (magickManifestCache) {
return magickManifestCache;
}
magickManifestCache = await fetchJson(buildApiUrl("/api/v1/bootstrap/magick-manifest"));
return magickManifestCache;
}
async function loadMagickDataset() {
if (magickDataCache) {
return magickDataCache;
}
magickDataCache = await fetchJson(buildApiUrl("/api/v1/bootstrap/magick-dataset"));
return magickDataCache;
}
async function loadReferenceData() {
return fetchJson(buildApiUrl("/api/v1/bootstrap/reference-data"));
}
async function fetchWeekEvents(geo, anchorDate = new Date()) {
return fetchJson(buildApiUrl("/api/v1/calendar/week-events", {
latitude: geo?.latitude,
longitude: geo?.longitude,
date: anchorDate instanceof Date ? anchorDate.toISOString() : anchorDate
}));
}
async function fetchNowSnapshot(geo, timestamp = new Date()) {
return fetchJson(buildApiUrl("/api/v1/now", {
latitude: geo?.latitude,
longitude: geo?.longitude,
date: timestamp instanceof Date ? timestamp.toISOString() : timestamp
}));
}
async function loadTarotCards(filters = {}) {
return fetchJson(buildApiUrl("/api/v1/tarot/cards", {
q: filters?.query,
arcana: filters?.arcana,
suit: filters?.suit
}));
}
async function pullTarotSpread(spreadId, options = {}) {
const normalizedSpreadId = String(spreadId || "").trim() || "three-card";
return fetchJson(buildApiUrl(`/api/v1/tarot/spreads/${encodeURIComponent(normalizedSpreadId)}/pull`, {
seed: options?.seed
}));
}
async function loadGematriaWordsByValue(value) {
return fetchJson(buildApiUrl("/api/v1/gematria/words", {
value
}));
}
async function loadWordAnagrams(text) {
return fetchJson(buildApiUrl("/api/v1/words/anagrams", {
text
}));
}
async function loadWordsByPrefix(prefix) {
return fetchJson(buildApiUrl("/api/v1/words/prefix", {
prefix
}));
}
async function loadTextLibrary(forceRefresh = false) {
if (!forceRefresh && textLibraryCache) {
return textLibraryCache;
}
textLibraryCache = await fetchJson(buildApiUrl("/api/v1/texts"));
return textLibraryCache;
}
async function loadTextSource(sourceId, forceRefresh = false) {
const normalizedSourceId = String(sourceId || "").trim().toLowerCase();
if (!normalizedSourceId) {
return null;
}
if (!forceRefresh && textSourceCache.has(normalizedSourceId)) {
return textSourceCache.get(normalizedSourceId);
}
const payload = await fetchJson(buildApiUrl(`/api/v1/texts/${encodeURIComponent(normalizedSourceId)}`));
textSourceCache.set(normalizedSourceId, payload);
return payload;
}
async function loadTextSection(sourceId, workId, sectionId, forceRefresh = false) {
const normalizedSourceId = String(sourceId || "").trim().toLowerCase();
const normalizedWorkId = String(workId || "").trim().toLowerCase();
const normalizedSectionId = String(sectionId || "").trim().toLowerCase();
if (!normalizedSourceId || !normalizedWorkId || !normalizedSectionId) {
return null;
}
const cacheKey = `${normalizedSourceId}::${normalizedWorkId}::${normalizedSectionId}`;
if (!forceRefresh && textSectionCache.has(cacheKey)) {
return textSectionCache.get(cacheKey);
}
const payload = await fetchJson(buildApiUrl(
`/api/v1/texts/${encodeURIComponent(normalizedSourceId)}/works/${encodeURIComponent(normalizedWorkId)}/sections/${encodeURIComponent(normalizedSectionId)}`
));
textSectionCache.set(cacheKey, payload);
return payload;
}
async function loadTextLexiconEntry(lexiconId, entryId, forceRefresh = false) {
const normalizedLexiconId = String(lexiconId || "").trim().toLowerCase();
const normalizedEntryId = String(entryId || "").trim().toUpperCase();
if (!normalizedLexiconId || !normalizedEntryId) {
return null;
}
const cacheKey = `${normalizedLexiconId}::${normalizedEntryId}`;
if (!forceRefresh && textLexiconCache.has(cacheKey)) {
return textLexiconCache.get(cacheKey);
}
const payload = await fetchJson(buildApiUrl(
`/api/v1/texts/lexicons/${encodeURIComponent(normalizedLexiconId)}/entries/${encodeURIComponent(normalizedEntryId)}`
));
textLexiconCache.set(cacheKey, payload);
return payload;
}
async function loadTextLexiconOccurrences(lexiconId, entryId, options = {}, forceRefresh = false) {
const normalizedLexiconId = String(lexiconId || "").trim().toLowerCase();
const normalizedEntryId = String(entryId || "").trim().toUpperCase();
const normalizedLimit = Number.parseInt(options?.limit, 10);
const limit = Number.isFinite(normalizedLimit) ? normalizedLimit : 100;
if (!normalizedLexiconId || !normalizedEntryId) {
return null;
}
const cacheKey = `${normalizedLexiconId}::${normalizedEntryId}::${limit}`;
if (!forceRefresh && textLexiconOccurrencesCache.has(cacheKey)) {
return textLexiconOccurrencesCache.get(cacheKey);
}
const payload = await fetchJson(buildApiUrl(
`/api/v1/texts/lexicons/${encodeURIComponent(normalizedLexiconId)}/entries/${encodeURIComponent(normalizedEntryId)}/occurrences`,
{ limit }
));
textLexiconOccurrencesCache.set(cacheKey, payload);
return payload;
}
async function searchTextLibrary(query, options = {}, forceRefresh = false) {
const normalizedQuery = String(query || "").trim();
const normalizedSourceId = String(options?.sourceId || "").trim().toLowerCase();
const normalizedLimit = Number.parseInt(options?.limit, 10);
const limit = Number.isFinite(normalizedLimit) ? normalizedLimit : 50;
if (!normalizedQuery) {
return {
query: "",
normalizedQuery: "",
scope: normalizedSourceId ? { type: "source", sourceId: normalizedSourceId } : { type: "global" },
limit,
totalMatches: 0,
resultCount: 0,
truncated: false,
results: []
};
}
const cacheKey = `${normalizedSourceId || "global"}::${limit}::${normalizedQuery.toLowerCase()}`;
if (!forceRefresh && textSearchCache.has(cacheKey)) {
return textSearchCache.get(cacheKey);
}
const path = normalizedSourceId
? `/api/v1/texts/${encodeURIComponent(normalizedSourceId)}/search`
: "/api/v1/texts/search";
const payload = await fetchJson(buildApiUrl(path, {
q: normalizedQuery,
limit
}));
textSearchCache.set(cacheKey, payload);
return payload;
}
async function loadDeckOptions(forceRefresh = false) {
if (!forceRefresh && deckOptionsCache) {
return deckOptionsCache;
}
deckOptionsCache = await fetchJson(buildApiUrl("/api/v1/decks/options"));
return deckOptionsCache;
}
async function loadDeckManifest(deckId, forceRefresh = false) {
const normalizedDeckId = String(deckId || "").trim().toLowerCase();
if (!normalizedDeckId) {
return null;
}
if (!forceRefresh && deckManifestCache.has(normalizedDeckId)) {
return deckManifestCache.get(normalizedDeckId);
}
const manifest = await fetchJson(buildApiUrl(`/api/v1/decks/${encodeURIComponent(normalizedDeckId)}/manifest`));
deckManifestCache.set(normalizedDeckId, manifest);
return manifest;
}
async function loadQuizCategories(forceRefresh = false) {
if (!forceRefresh && quizCategoriesCache) {
return quizCategoriesCache;
}
quizCategoriesCache = await fetchJson(buildApiUrl("/api/v1/quiz/categories"));
return quizCategoriesCache;
}
async function loadQuizTemplates(query = {}, forceRefresh = false) {
const categoryId = String(query?.categoryId || "").trim();
const cacheKey = categoryId || "__all__";
if (!forceRefresh && quizTemplatesCache.has(cacheKey)) {
return quizTemplatesCache.get(cacheKey);
}
const templates = await fetchJson(buildApiUrl("/api/v1/quiz/templates", {
categoryId
}));
quizTemplatesCache.set(cacheKey, templates);
return templates;
}
async function pullQuizQuestion(query = {}) {
return fetchJson(buildApiUrl("/api/v1/quiz/questions/pull", {
categoryId: query?.categoryId,
templateKey: query?.templateKey,
difficulty: query?.difficulty,
seed: query?.seed,
includeAnswer: query?.includeAnswer
}));
}
async function probeConnection() {
const apiBaseUrl = getApiBaseUrl();
if (!apiBaseUrl) {
return {
ok: false,
reason: "missing-base-url",
message: "Enter an API Base URL to load TaroTime."
};
}
const requestOptions = {
headers: buildRequestHeaders()
};
try {
const healthResponse = await fetch(buildApiUrl("/api/v1/health"), requestOptions);
if (!healthResponse.ok) {
return {
ok: false,
reason: "health-check-failed",
message: `The API responded with ${healthResponse.status} during the health check.`
};
}
const health = await healthResponse.json().catch(() => null);
const protectedResponse = await fetch(buildApiUrl("/api/v1/decks/options"), requestOptions);
if (protectedResponse.status === 401 || protectedResponse.status === 403) {
return {
ok: false,
reason: "auth-required",
message: health?.apiKeyRequired
? "The API requires a valid API key."
: "The API rejected this connection."
};
}
if (!protectedResponse.ok) {
return {
ok: false,
reason: "protected-route-failed",
message: `The API responded with ${protectedResponse.status} when loading protected data.`
};
}
const decksPayload = await protectedResponse.json().catch(() => null);
return {
ok: true,
reason: "connected",
message: "Connected.",
health,
deckCount: Array.isArray(decksPayload?.decks) ? decksPayload.decks.length : null
};
} catch (_error) {
return {
ok: false,
reason: "network-error",
message: "Unable to reach the API. Check the URL and make sure the server is running."
};
}
}
window.TarotDataService = {
buildApiUrl,
fetchNowSnapshot,
fetchWeekEvents,
getApiBaseUrl,
getApiKey,
isApiEnabled,
loadDeckManifest,
loadDeckOptions,
loadGematriaWordsByValue,
loadWordAnagrams,
loadWordsByPrefix,
loadQuizCategories,
loadQuizTemplates,
loadTarotCards,
loadReferenceData,
loadMagickManifest,
loadMagickDataset,
loadTextLibrary,
loadTextSource,
searchTextLibrary,
loadTextSection,
loadTextLexiconEntry,
loadTextLexiconOccurrences,
probeConnection,
pullQuizQuestion,
pullTarotSpread,
toApiAssetUrl
};
document.addEventListener("connection:updated", resetCaches);
})();