moved to API

This commit is contained in:
2026-03-08 22:24:34 -07:00
parent cf6b2611aa
commit 2caf566bf6
94 changed files with 1257 additions and 40930 deletions

View File

@@ -1,6 +1,10 @@
(function () {
let magickManifestCache = null;
let magickDataCache = null;
let deckOptionsCache = null;
const deckManifestCache = new Map();
let quizCategoriesCache = null;
const quizTemplatesCache = new Map();
const DATA_ROOT = "data";
const MAGICK_ROOT = DATA_ROOT;
@@ -92,8 +96,23 @@
pluto: "Pluto"
};
function buildRequestHeaders() {
const apiKey = getApiKey();
return apiKey
? {
"x-api-key": apiKey
}
: undefined;
}
async function fetchJson(path) {
const response = await fetch(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})`);
}
@@ -112,6 +131,98 @@
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;
deckManifestCache.clear();
quizTemplatesCache.clear();
}
function normalizeTarotName(value) {
return String(value || "")
.trim()
@@ -207,7 +318,7 @@
return magickManifestCache;
}
magickManifestCache = await fetchJson(`${MAGICK_ROOT}/MANIFEST.json`);
magickManifestCache = await fetchJson(buildApiUrl("/api/v1/bootstrap/magick-manifest"));
return magickManifestCache;
}
@@ -216,163 +327,185 @@
return magickDataCache;
}
const manifest = await loadMagickManifest();
const files = Array.isArray(manifest?.files) ? manifest.files : [];
const jsonFiles = files.filter((file) => file.endsWith(".json"));
const entries = await Promise.all(
jsonFiles.map(async (relativePath) => {
const data = await fetchJson(`${MAGICK_ROOT}/${relativePath}`);
return [relativePath, data];
})
);
const grouped = {};
entries.forEach(([relativePath, data]) => {
const noExtensionPath = relativePath.replace(/\.json$/i, "");
const pathParts = noExtensionPath.split("/").filter(Boolean);
if (!pathParts.length) {
return;
}
buildObjectPath(grouped, pathParts, data);
});
magickDataCache = {
manifest,
grouped,
files: Object.fromEntries(entries)
};
magickDataCache = await fetchJson(buildApiUrl("/api/v1/bootstrap/magick-dataset"));
return magickDataCache;
}
async function loadReferenceData() {
const { groupDecansBySign } = window.TarotCalc;
const [
planetsJson,
signsJson,
decansJson,
sabianJson,
planetScienceJson,
gematriaCiphersJson,
iChingJson,
calendarMonthsJson,
celestialHolidaysJson,
calendarHolidaysJson,
astronomyCyclesJson,
tarotDatabaseJson,
hebrewCalendarJson,
islamicCalendarJson,
wheelOfYearJson
] = await Promise.all([
fetchJson(`${DATA_ROOT}/planetary-correspondences.json`),
fetchJson(`${DATA_ROOT}/signs.json`),
fetchJson(`${DATA_ROOT}/decans.json`),
fetchJson(`${DATA_ROOT}/sabian-symbols.json`),
fetchJson(`${DATA_ROOT}/planet-science.json`),
fetchJson(`${DATA_ROOT}/gematria-ciphers.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/i-ching.json`),
fetchJson(`${DATA_ROOT}/calendar-months.json`),
fetchJson(`${DATA_ROOT}/celestial-holidays.json`),
fetchJson(`${DATA_ROOT}/calendar-holidays.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/astronomy-cycles.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/tarot-database.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/hebrew-calendar.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/islamic-calendar.json`).catch(() => ({})),
fetchJson(`${DATA_ROOT}/wheel-of-year.json`).catch(() => ({}))
]);
return fetchJson(buildApiUrl("/api/v1/bootstrap/reference-data"));
}
const planets = planetsJson.planets || {};
const signs = signsJson.signs || [];
const decans = decansJson.decans || [];
const sabianSymbols = Array.isArray(sabianJson?.symbols) ? sabianJson.symbols : [];
const planetScience = Array.isArray(planetScienceJson?.planets)
? planetScienceJson.planets
: [];
const gematriaCiphers = gematriaCiphersJson && typeof gematriaCiphersJson === "object"
? gematriaCiphersJson
: {};
const iChing = {
trigrams: Array.isArray(iChingJson?.trigrams) ? iChingJson.trigrams : [],
hexagrams: Array.isArray(iChingJson?.hexagrams) ? iChingJson.hexagrams : [],
correspondences: {
meta: iChingJson?.correspondences?.meta && typeof iChingJson.correspondences.meta === "object"
? iChingJson.correspondences.meta
: {},
tarotToTrigram: Array.isArray(iChingJson?.correspondences?.tarotToTrigram)
? iChingJson.correspondences.tarotToTrigram
: []
}
};
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
}));
}
const calendarMonths = Array.isArray(calendarMonthsJson?.months)
? calendarMonthsJson.months.map((month) => enrichCalendarMonth(month))
: [];
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
}));
}
const celestialHolidays = Array.isArray(celestialHolidaysJson?.holidays)
? celestialHolidaysJson.holidays.map((holiday) => enrichCelestialHoliday(holiday))
: [];
async function loadTarotCards(filters = {}) {
return fetchJson(buildApiUrl("/api/v1/tarot/cards", {
q: filters?.query,
arcana: filters?.arcana,
suit: filters?.suit
}));
}
const calendarHolidays = Array.isArray(calendarHolidaysJson?.holidays)
? calendarHolidaysJson.holidays.map((holiday) => enrichCalendarHoliday(holiday))
: [];
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
}));
}
const astronomyCycles = astronomyCyclesJson && typeof astronomyCyclesJson === "object"
? astronomyCyclesJson
: {};
const tarotDatabase = tarotDatabaseJson && typeof tarotDatabaseJson === "object"
? tarotDatabaseJson
: {};
const sourceMeanings = tarotDatabase.meanings && typeof tarotDatabase.meanings === "object"
? tarotDatabase.meanings
: {};
if (!sourceMeanings.majorByTrumpNumber || typeof sourceMeanings.majorByTrumpNumber !== "object") {
sourceMeanings.majorByTrumpNumber = {};
async function loadDeckOptions(forceRefresh = false) {
if (!forceRefresh && deckOptionsCache) {
return deckOptionsCache;
}
const existingByCardName = sourceMeanings.byCardName && typeof sourceMeanings.byCardName === "object"
? sourceMeanings.byCardName
: {};
deckOptionsCache = await fetchJson(buildApiUrl("/api/v1/decks/options"));
return deckOptionsCache;
}
sourceMeanings.byCardName = existingByCardName;
async function loadDeckManifest(deckId, forceRefresh = false) {
const normalizedDeckId = String(deckId || "").trim().toLowerCase();
if (!normalizedDeckId) {
return null;
}
tarotDatabase.meanings = sourceMeanings;
if (!forceRefresh && deckManifestCache.has(normalizedDeckId)) {
return deckManifestCache.get(normalizedDeckId);
}
const hebrewCalendar = hebrewCalendarJson && typeof hebrewCalendarJson === "object"
? hebrewCalendarJson
: {};
const islamicCalendar = islamicCalendarJson && typeof islamicCalendarJson === "object"
? islamicCalendarJson
: {};
const wheelOfYear = wheelOfYearJson && typeof wheelOfYearJson === "object"
? wheelOfYearJson
: {};
const manifest = await fetchJson(buildApiUrl(`/api/v1/decks/${encodeURIComponent(normalizedDeckId)}/manifest`));
deckManifestCache.set(normalizedDeckId, manifest);
return manifest;
}
return {
planets,
signs,
decansBySign: groupDecansBySign(decans),
sabianSymbols,
planetScience,
gematriaCiphers,
iChing,
calendarMonths,
celestialHolidays,
calendarHolidays,
astronomyCycles,
tarotDatabase,
hebrewCalendar,
islamicCalendar,
wheelOfYear
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,
loadQuizCategories,
loadQuizTemplates,
loadTarotCards,
loadReferenceData,
loadMagickManifest,
loadMagickDataset
loadMagickDataset,
probeConnection,
pullQuizQuestion,
pullTarotSpread,
toApiAssetUrl
};
document.addEventListener("connection:updated", resetCaches);
})();