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

132
app/app-config.js Normal file
View File

@@ -0,0 +1,132 @@
(function () {
const apiBaseUrlStorageKey = "tarot-time-api-base-url";
const apiKeyStorageKey = "tarot-time-api-key";
const defaultApiBaseUrl = "";
function normalizeBaseUrl(value) {
return String(value || "")
.trim()
.replace(/\/+$/, "");
}
function normalizeApiKey(value) {
return String(value || "").trim();
}
function normalizeConnectionSettings(settings) {
return {
apiBaseUrl: normalizeBaseUrl(settings?.apiBaseUrl),
apiKey: normalizeApiKey(settings?.apiKey)
};
}
function persistConnectionSettings(settings) {
let didPersist = true;
try {
const params = new URLSearchParams(window.location.search || "");
const queryValue = String(params.get("apiBaseUrl") || "").trim();
const queryApiKey = String(params.get("apiKey") || params.get("api_key") || "").trim();
if (queryValue) {
window.localStorage.setItem(apiBaseUrlStorageKey, normalizeBaseUrl(queryValue));
}
if (queryApiKey) {
window.localStorage.setItem(apiKeyStorageKey, normalizeApiKey(queryApiKey));
}
if (settings.apiBaseUrl) {
window.localStorage.setItem(apiBaseUrlStorageKey, settings.apiBaseUrl);
} else {
window.localStorage.removeItem(apiBaseUrlStorageKey);
}
if (settings.apiKey) {
window.localStorage.setItem(apiKeyStorageKey, settings.apiKey);
} else {
window.localStorage.removeItem(apiKeyStorageKey);
}
} catch (_error) {
didPersist = false;
}
return didPersist;
}
function readConfiguredConnectionSettings() {
try {
const params = new URLSearchParams(window.location.search || "");
const queryValue = String(params.get("apiBaseUrl") || "").trim();
const queryApiKey = String(params.get("apiKey") || params.get("api_key") || "").trim();
if (queryValue) {
window.localStorage.setItem(apiBaseUrlStorageKey, normalizeBaseUrl(queryValue));
}
if (queryApiKey) {
window.localStorage.setItem(apiKeyStorageKey, normalizeApiKey(queryApiKey));
}
const storedBaseUrl = String(window.localStorage.getItem(apiBaseUrlStorageKey) || "").trim();
const storedApiKey = String(window.localStorage.getItem(apiKeyStorageKey) || "").trim();
return normalizeConnectionSettings({
apiBaseUrl: storedBaseUrl,
apiKey: storedApiKey
});
} catch (_error) {
return normalizeConnectionSettings({
apiBaseUrl: "",
apiKey: ""
});
}
}
const initialConnectionSettings = readConfiguredConnectionSettings();
window.TarotAppConfig = {
...(window.TarotAppConfig || {}),
apiBaseUrl: initialConnectionSettings.apiBaseUrl,
apiKey: initialConnectionSettings.apiKey,
getApiBaseUrl() {
return normalizeBaseUrl(this.apiBaseUrl);
},
getApiKey() {
return normalizeApiKey(this.apiKey);
},
isConnectionConfigured() {
return Boolean(this.getApiBaseUrl());
},
getConnectionSettings() {
return {
apiBaseUrl: this.getApiBaseUrl(),
apiKey: this.getApiKey()
};
},
updateConnectionSettings(nextSettings = {}) {
const previous = this.getConnectionSettings();
const current = normalizeConnectionSettings({
...previous,
...nextSettings
});
const didPersist = persistConnectionSettings(current);
this.apiBaseUrl = current.apiBaseUrl;
this.apiKey = current.apiKey;
if (previous.apiBaseUrl !== current.apiBaseUrl || previous.apiKey !== current.apiKey) {
document.dispatchEvent(new CustomEvent("connection:updated", {
detail: {
previous,
current: { ...current }
}
}));
}
return {
...current,
didPersist
};
}
};
})();

View File

@@ -77,7 +77,7 @@
clearInterval(nowInterval);
}
const tick = () => {
const tick = async () => {
if (!referenceData || !currentGeo || renderInProgress) {
return;
}
@@ -91,12 +91,17 @@
return;
}
config.services.updateNowPanel?.(referenceData, currentGeo, config.nowElements, currentTimeFormat);
config.calendarVisualsUi?.applyDynamicNowIndicatorVisual?.(now);
try {
await config.services.updateNowPanel?.(referenceData, currentGeo, config.nowElements, currentTimeFormat);
config.calendarVisualsUi?.applyDynamicNowIndicatorVisual?.(now);
} catch (_error) {
}
};
tick();
nowInterval = setInterval(tick, 1000);
void tick();
nowInterval = setInterval(() => {
void tick();
}, 1000);
}
async function renderWeek() {
@@ -138,7 +143,7 @@
applyCenteredWeekWindow(anchorDate);
const events = config.services.buildWeekEvents?.(currentGeo, referenceData, anchorDate) || [];
const events = await config.services.buildWeekEvents?.(currentGeo, referenceData, anchorDate) || [];
config.calendar?.clear?.();
config.calendar?.createEvents?.(events);
config.calendarVisualsUi?.applySunRulerGradient?.(anchorDate);

View File

@@ -1,4 +1,5 @@
(function () {
const dataService = window.TarotDataService || {};
const {
DAY_IN_MS,
toTitleCase,
@@ -11,83 +12,24 @@
const BACKFILL_DAYS = 4;
const FORECAST_DAYS = 7;
function buildWeekEvents(geo, referenceData, anchorDate) {
const baseDate = anchorDate || new Date();
const events = [];
let runningId = 1;
for (let offset = -BACKFILL_DAYS; offset <= FORECAST_DAYS; offset += 1) {
const date = new Date(baseDate.getTime() + offset * DAY_IN_MS);
const hours = calcPlanetaryHoursForDayAndLocation(date, geo);
const moonIllum = window.SunCalc.getMoonIllumination(date);
const moonPhase = getMoonPhaseName(moonIllum.phase);
const sunInfo = getDecanForDate(date, referenceData.signs, referenceData.decansBySign);
const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess";
events.push({
id: `moon-${offset}`,
calendarId: "astrology",
category: "allday",
title: `Moon: ${moonPhase} (${Math.round(moonIllum.fraction * 100)}%) · ${moonTarot}`,
start: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59),
isReadOnly: true
});
if (sunInfo?.sign) {
const rulerPlanet = referenceData.planets[sunInfo.sign.rulingPlanetId];
const bodyParts = [
`${sunInfo.sign.symbol} ${toTitleCase(sunInfo.sign.element)} ${toTitleCase(sunInfo.sign.modality)}`,
rulerPlanet ? `Sign ruler: ${rulerPlanet.symbol} ${rulerPlanet.name}` : `Sign ruler: ${sunInfo.sign.rulingPlanetId}`
];
if (sunInfo.decan) {
const decanRuler = referenceData.planets[sunInfo.decan.rulerPlanetId];
const decanText = decanRuler
? `Decan ${sunInfo.decan.index}: ${sunInfo.decan.tarotMinorArcana} (${decanRuler.symbol} ${decanRuler.name})`
: `Decan ${sunInfo.decan.index}: ${sunInfo.decan.tarotMinorArcana}`;
bodyParts.unshift(decanText);
}
events.push({
id: `sun-${offset}`,
calendarId: "astrology",
category: "allday",
title: `Sun in ${sunInfo.sign.name} · ${sunInfo.sign.tarot.majorArcana}`,
body: bodyParts.join("\n"),
start: new Date(date.getFullYear(), date.getMonth(), date.getDate()),
end: new Date(date.getFullYear(), date.getMonth(), date.getDate(), 23, 59, 59),
isReadOnly: true
});
}
for (const hour of hours) {
const planet = referenceData.planets[hour.planetId];
if (!planet) continue;
const calendarId = PLANET_CALENDAR_IDS.has(hour.planetId)
? `planet-${hour.planetId}`
: "planetary";
events.push({
id: `ph-${runningId++}`,
calendarId,
category: "time",
title: `${planet.symbol} ${planet.name} · ${planet.tarot.majorArcana}`,
body: `${planet.weekday} current · ${hour.isDaylight ? "Day" : "Night"} hour\n${planet.magickTypes}`,
raw: {
planetSymbol: planet.symbol,
planetName: planet.name,
tarotName: planet.tarot.majorArcana
},
start: hour.start,
end: hour.end,
isReadOnly: true
});
}
function normalizeApiEvent(event) {
if (!event || typeof event !== "object") {
return event;
}
return events;
return {
...event,
start: event.start ? new Date(event.start) : event.start,
end: event.end ? new Date(event.end) : event.end
};
}
async function buildWeekEvents(geo, referenceData, anchorDate) {
const payload = await dataService.fetchWeekEvents?.(geo, anchorDate);
const events = Array.isArray(payload?.events)
? payload.events
: (Array.isArray(payload) ? payload : []);
return events.map((event) => normalizeApiEvent(event));
}
window.TarotEventBuilder = {

View File

@@ -107,8 +107,6 @@
quality: 82
};
const DECK_REGISTRY_PATH = "asset/tarot deck/decks.json";
let deckManifestSources = buildDeckManifestSources();
const manifestCache = new Map();
@@ -116,6 +114,21 @@
const cardBackThumbnailCache = new Map();
let activeDeckId = DEFAULT_DECK_ID;
function getApiBaseUrl() {
return String(window.TarotDataService?.getApiBaseUrl?.() || window.TarotAppConfig?.apiBaseUrl || "")
.trim()
.replace(/\/+$/, "");
}
function rewriteBasePathForApi(basePath) {
const normalizedBasePath = String(basePath || "").trim();
if (!normalizedBasePath) {
return normalizedBasePath;
}
return window.TarotDataService?.toApiAssetUrl?.(normalizedBasePath) || normalizedBasePath;
}
function canonicalMajorName(cardName) {
return String(cardName || "")
.trim()
@@ -256,6 +269,46 @@
return /^(https?:)?\/\//i.test(String(pathValue || ""));
}
function joinAssetPath(basePath, relativePath) {
const normalizedBasePath = String(basePath || "").trim().replace(/\/+$/, "");
const normalizedRelativePath = String(relativePath || "")
.trim()
.replace(/^\.\//, "")
.replace(/^\/+/, "");
if (!normalizedBasePath) {
return normalizedRelativePath;
}
if (!normalizedRelativePath) {
return normalizedBasePath;
}
if (!isRemoteAssetPath(normalizedBasePath)) {
return `${normalizedBasePath}/${normalizedRelativePath}`;
}
try {
const url = new URL(normalizedBasePath);
const encodedRelativePath = normalizedRelativePath
.split("/")
.filter(Boolean)
.map((segment) => {
try {
return encodeURIComponent(decodeURIComponent(segment));
} catch {
return encodeURIComponent(segment);
}
})
.join("/");
url.pathname = `${url.pathname.replace(/\/+$/, "")}/${encodedRelativePath}`;
return url.toString();
} catch {
return `${normalizedBasePath}/${normalizedRelativePath}`;
}
}
function toDeckAssetPath(manifest, relativeOrAbsolutePath) {
const normalizedPath = String(relativeOrAbsolutePath || "").trim();
if (!normalizedPath) {
@@ -266,7 +319,7 @@
return normalizedPath;
}
return `${manifest.basePath}/${normalizedPath.replace(/^\.\//, "")}`;
return joinAssetPath(manifest.basePath, normalizedPath);
}
function resolveDeckCardBackPath(manifest) {
@@ -364,7 +417,7 @@
const id = String(entry?.id || "").trim().toLowerCase();
const basePath = String(entry?.basePath || "").trim().replace(/\/$/, "");
const manifestPath = String(entry?.manifestPath || "").trim();
if (!id || !basePath || !manifestPath) {
if (!id || !manifestPath) {
return;
}
@@ -382,14 +435,35 @@
}
function buildDeckManifestSources() {
const registry = readManifestJsonSync(DECK_REGISTRY_PATH);
const registryDecks = Array.isArray(registry)
? registry
: (Array.isArray(registry?.decks) ? registry.decks : null);
if (!window.TarotDataService?.isApiEnabled?.() && !getApiBaseUrl()) {
return {};
}
const registryUrl = window.TarotDataService?.buildApiUrl?.("/api/v1/decks/options") || `${getApiBaseUrl()}/api/v1/decks/options`;
if (!registryUrl) {
return {};
}
const registry = readManifestJsonSync(registryUrl);
const registryDecks = Array.isArray(registry?.decks)
? registry.decks.map((entry) => ({
id: entry?.id,
label: entry?.label,
manifestPath: window.TarotDataService?.buildApiUrl?.(`/api/v1/decks/${encodeURIComponent(String(entry?.id || "").trim().toLowerCase())}/manifest`) || `${getApiBaseUrl()}/api/v1/decks/${encodeURIComponent(String(entry?.id || "").trim().toLowerCase())}/manifest`
}))
: [];
return toDeckSourceMap(registryDecks);
}
function resetConnectionCaches() {
deckManifestSources = buildDeckManifestSources();
manifestCache.clear();
cardBackCache.clear();
cardBackThumbnailCache.clear();
setActiveDeck(activeDeckId);
}
function getDeckManifestSources(forceRefresh = false) {
if (forceRefresh || !deckManifestSources || Object.keys(deckManifestSources).length === 0) {
deckManifestSources = buildDeckManifestSources();
@@ -442,9 +516,9 @@
return {
id: source.id,
label: String(rawManifest.label || source.label || source.id),
basePath: String(source.basePath || "").replace(/\/$/, ""),
basePath: rewriteBasePathForApi(String(rawManifest.basePath || source.basePath || "").replace(/\/$/, "")),
cardBack: String(rawManifest.cardBack || "").trim(),
cardBackPath: String(source.cardBackPath || "").trim(),
cardBackPath: String(rawManifest.cardBackPath || source.cardBackPath || "").trim(),
thumbnails: normalizeThumbnailConfig(rawManifest.thumbnails, source.thumbnailRoot),
majors: rawManifest.majors || {},
minors: rawManifest.minors || {},
@@ -689,17 +763,17 @@
}
if (variant === "thumbnail") {
return resolveDeckThumbnailPath(manifest, relativePath) || `${manifest.basePath}/${relativePath}`;
return resolveDeckThumbnailPath(manifest, relativePath) || toDeckAssetPath(manifest, relativePath);
}
return `${manifest.basePath}/${relativePath}`;
return toDeckAssetPath(manifest, relativePath);
}
function resolveTarotCardImage(cardName, optionsOrDeckId) {
const { resolvedDeckId } = resolveDeckOptions(optionsOrDeckId);
const activePath = resolveWithDeck(resolvedDeckId, cardName);
if (activePath) {
return encodeURI(activePath);
return activePath;
}
return null;
@@ -709,7 +783,7 @@
const { resolvedDeckId } = resolveDeckOptions(optionsOrDeckId);
const thumbnailPath = resolveWithDeck(resolvedDeckId, cardName, "thumbnail");
if (thumbnailPath) {
return encodeURI(thumbnailPath);
return thumbnailPath;
}
return null;
@@ -720,7 +794,7 @@
if (cardBackCache.has(resolvedDeckId)) {
const cachedPath = cardBackCache.get(resolvedDeckId);
return cachedPath ? encodeURI(cachedPath) : null;
return cachedPath || null;
}
const manifest = getDeckManifest(resolvedDeckId);
@@ -728,7 +802,7 @@
cardBackCache.set(resolvedDeckId, activeBackPath || null);
if (activeBackPath) {
return encodeURI(activeBackPath);
return activeBackPath;
}
return null;
@@ -739,7 +813,7 @@
if (cardBackThumbnailCache.has(resolvedDeckId)) {
const cachedPath = cardBackThumbnailCache.get(resolvedDeckId);
return cachedPath ? encodeURI(cachedPath) : null;
return cachedPath || null;
}
const manifest = getDeckManifest(resolvedDeckId);
@@ -747,7 +821,7 @@
const thumbnailPath = resolveDeckThumbnailPath(manifest, relativeBackPath) || resolveDeckCardBackPath(manifest);
cardBackThumbnailCache.set(resolvedDeckId, thumbnailPath || null);
return thumbnailPath ? encodeURI(thumbnailPath) : null;
return thumbnailPath || null;
}
function resolveDisplayNameWithDeck(deckId, cardName, trumpNumber) {
@@ -852,6 +926,8 @@
setActiveDeck(nextDeck);
});
document.addEventListener("connection:updated", resetConnectionCaches);
window.TarotCardImages = {
resolveTarotCardImage,
resolveTarotCardThumbnail,

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);
})();

View File

@@ -73,6 +73,100 @@
.settings-trigger[aria-pressed="true"] {
background: #3f3f46;
}
body.connection-gated {
overflow: hidden;
}
.connection-gate {
position: fixed;
inset: 0;
z-index: 120;
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top, rgba(245, 158, 11, 0.18), transparent 34%),
linear-gradient(180deg, rgba(9, 9, 11, 0.84), rgba(9, 9, 11, 0.96));
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.connection-gate[hidden] {
display: none;
}
.connection-gate-card {
width: min(460px, calc(100vw - 32px));
padding: 24px;
border: 1px solid rgba(245, 158, 11, 0.28);
border-radius: 18px;
background: linear-gradient(180deg, rgba(24, 24, 27, 0.96), rgba(9, 9, 11, 0.98));
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.45);
box-sizing: border-box;
}
.connection-gate-eyebrow {
margin-bottom: 10px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.16em;
text-transform: uppercase;
color: #fbbf24;
}
.connection-gate-title {
margin: 0;
font-size: 28px;
line-height: 1.1;
color: #fafaf9;
}
.connection-gate-copy {
margin: 12px 0 18px;
color: #d4d4d8;
font-size: 14px;
line-height: 1.6;
}
.connection-gate-fields {
display: grid;
gap: 12px;
}
.connection-gate-status {
min-height: 20px;
margin-top: 14px;
font-size: 13px;
color: #d4d4d8;
}
.connection-gate-status[data-tone="error"] {
color: #fca5a5;
}
.connection-gate-status[data-tone="success"] {
color: #86efac;
}
.connection-gate-status[data-tone="pending"] {
color: #fcd34d;
}
.connection-gate-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 10px;
margin-top: 18px;
}
.connection-gate-actions button {
padding: 9px 14px;
border-radius: 8px;
border: 1px solid #3f3f46;
background: #27272a;
color: #f4f4f5;
cursor: pointer;
}
.connection-gate-actions button:hover {
background: #3f3f46;
}
#connection-gate-connect {
border-color: #d97706;
background: linear-gradient(180deg, #f59e0b, #d97706);
color: #111827;
font-weight: 700;
}
#connection-gate-connect:hover {
background: linear-gradient(180deg, #fbbf24, #ea580c);
}
#tarot-section {
height: calc(100vh - 61px);
background: #18181b;
@@ -3509,6 +3603,9 @@
color: #f4f4f5;
box-sizing: border-box;
}
.settings-field input[type="password"] {
letter-spacing: 0.04em;
}
.settings-actions {
margin-top: 12px;
display: flex;
@@ -3528,6 +3625,23 @@
.settings-popup-header button:hover {
background: #3f3f46;
}
@media (max-width: 640px) {
.connection-gate {
padding: 16px;
}
.connection-gate-card {
padding: 18px;
}
.connection-gate-title {
font-size: 24px;
}
.connection-gate-actions {
justify-content: stretch;
}
.connection-gate-actions button {
flex: 1 1 100%;
}
}
#month-strip {
height: 28px;
background: #18181b;

View File

@@ -258,7 +258,7 @@
function enochianGlyphUrl(letter) {
const code = enochianGlyphCode(letter);
return code ? `asset/img/enochian/char(${code}).png` : "";
return code ? (window.TarotDataService?.toApiAssetUrl?.(`img/enochian/char(${code}).png`) || "") : "";
}
function enochianGlyphImageHtml(letter, className) {

View File

@@ -564,7 +564,7 @@
<dt>Element / Planet</dt><dd>${letter.elementOrPlanet || "—"}</dd>
<dt>Tarot</dt><dd>${letter.tarot || "—"}</dd>
<dt>Numerology</dt><dd>${letter.numerology || "—"}</dd>
<dt>Glyph Source</dt><dd>Local cache: asset/img/enochian (sourced from dCode set)</dd>
<dt>Glyph Source</dt><dd>API asset: img/enochian (sourced from dCode set)</dd>
<dt>Position</dt><dd>#${letter.index} of 21</dd>
</dl>
`));

View File

@@ -3,6 +3,7 @@
let config = {
getAlphabets: () => null,
getGematriaDb: () => null,
getGematriaElements: () => ({
cipherEl: null,
inputEl: null,
@@ -24,6 +25,10 @@
return config.getAlphabets?.() || null;
}
function getConfiguredGematriaDb() {
return config.getGematriaDb?.() || null;
}
function getElements() {
return config.getGematriaElements?.() || {
cipherEl: null,
@@ -169,14 +174,20 @@
return state.loadingPromise;
}
state.loadingPromise = fetch("data/gematria-ciphers.json")
.then((response) => {
if (!response.ok) {
throw new Error(`Failed to load gematria ciphers (${response.status})`);
state.loadingPromise = Promise.resolve()
.then(async () => {
const configuredDb = getConfiguredGematriaDb();
if (configuredDb) {
return configuredDb;
}
return response.json();
const referenceData = await window.TarotDataService?.loadReferenceData?.();
return referenceData?.gematriaCiphers || null;
})
.then((db) => {
if (!db) {
throw new Error("Gematria cipher data unavailable from API.");
}
state.db = sanitizeGematriaDb(db);
return state.db;
})
@@ -342,6 +353,11 @@
...config,
...nextConfig
};
const configuredDb = getConfiguredGematriaDb();
if (configuredDb) {
state.db = sanitizeGematriaDb(configuredDb);
}
}
window.AlphabetGematriaUi = {

View File

@@ -39,7 +39,8 @@
signPlacementById: new Map(),
planetPlacementById: new Map(),
pathPlacementByNo: new Map()
}
},
gematriaDb: null
};
function arabicDisplayName(letter) {
@@ -86,6 +87,7 @@
function ensureGematriaCalculator() {
alphabetGematriaUi.init?.({
getAlphabets: () => state.alphabets,
getGematriaDb: () => state.gematriaDb,
getGematriaElements
});
alphabetGematriaUi.ensureCalculator?.();
@@ -403,6 +405,8 @@
state.monthRefsByHebrewId = buildMonthReferencesByHebrew(referenceData, state.alphabets);
}
state.gematriaDb = referenceData?.gematriaCiphers || null;
if (state.initialized) {
ensureGematriaCalculator();
syncFilterControls();

View File

@@ -1,6 +1,8 @@
(function () {
"use strict";
const dataService = window.TarotDataService || {};
const {
DAY_IN_MS,
getDateKey,
@@ -25,62 +27,65 @@
let moonCountdownCache = null;
let decanCountdownCache = null;
function updateNowPanel(referenceData, geo, elements, timeFormat = "minutes") {
if (!referenceData || !geo || !elements) {
return { dayKey: getDateKey(new Date()), skyRefreshKey: "" };
function renderNowStatsFromSnapshot(elements, stats) {
if (elements.nowStatsPlanetsEl) {
elements.nowStatsPlanetsEl.replaceChildren();
const planetPositions = Array.isArray(stats?.planetPositions) ? stats.planetPositions : [];
if (!planetPositions.length) {
elements.nowStatsPlanetsEl.textContent = "--";
} else {
planetPositions.forEach((position) => {
const item = document.createElement("div");
item.className = "now-stats-planet";
item.textContent = String(position?.label || "").trim() || "--";
elements.nowStatsPlanetsEl.appendChild(item);
});
}
}
const now = new Date();
const dayKey = getDateKey(now);
if (elements.nowStatsSabianEl) {
const sunSabianSymbol = stats?.sunSabianSymbol || null;
const moonSabianSymbol = stats?.moonSabianSymbol || null;
const sunLine = sunSabianSymbol?.phrase
? `Sun Sabian ${sunSabianSymbol.absoluteDegree}: ${sunSabianSymbol.phrase}`
: "Sun Sabian: --";
const moonLine = moonSabianSymbol?.phrase
? `Moon Sabian ${moonSabianSymbol.absoluteDegree}: ${moonSabianSymbol.phrase}`
: "Moon Sabian: --";
elements.nowStatsSabianEl.textContent = `${sunLine}\n${moonLine}`;
}
}
const todayHours = calcPlanetaryHoursForDayAndLocation(now, geo);
const yesterday = new Date(now.getTime() - DAY_IN_MS);
const yesterdayHours = calcPlanetaryHoursForDayAndLocation(yesterday, geo);
const tomorrow = new Date(now.getTime() + DAY_IN_MS);
const tomorrowHours = calcPlanetaryHoursForDayAndLocation(tomorrow, geo);
const allHours = [...yesterdayHours, ...todayHours, ...tomorrowHours].sort(
(a, b) => a.start.getTime() - b.start.getTime()
);
const currentHour = allHours.find((entry) => now >= entry.start && now < entry.end);
const currentHourSkyKey = currentHour
? `${currentHour.planetId}-${currentHour.start.toISOString()}`
: "no-hour";
function applyNowSnapshot(elements, snapshot, timeFormat) {
const timestamp = snapshot?.timestamp ? new Date(snapshot.timestamp) : new Date();
const dayKey = String(snapshot?.dayKey || getDateKey(timestamp));
const currentHour = snapshot?.currentHour || null;
if (currentHour) {
const planet = referenceData.planets[currentHour.planetId];
elements.nowHourEl.textContent = planet
? `${planet.symbol} ${planet.name}`
: currentHour.planetId;
if (currentHour?.planet) {
elements.nowHourEl.textContent = `${currentHour.planet.symbol} ${currentHour.planet.name}`;
if (elements.nowHourTarotEl) {
const hourCardName = planet?.tarot?.majorArcana || "";
const hourTrumpNumber = planet?.tarot?.number;
const hourCardName = currentHour.planet?.tarot?.majorArcana || "";
const hourTrumpNumber = currentHour.planet?.tarot?.number;
elements.nowHourTarotEl.textContent = hourCardName
? nowUiHelpers.getDisplayTarotName(hourCardName, hourTrumpNumber)
: "--";
}
const msLeft = Math.max(0, currentHour.end.getTime() - now.getTime());
elements.nowCountdownEl.textContent = nowUiHelpers.formatCountdown(msLeft, timeFormat);
elements.nowCountdownEl.textContent = nowUiHelpers.formatCountdown(currentHour.msRemaining, timeFormat);
if (elements.nowHourNextEl) {
const nextHour = allHours.find(
(entry) => entry.start.getTime() >= currentHour.end.getTime() - 1000
);
if (nextHour) {
const nextPlanet = referenceData.planets[nextHour.planetId];
elements.nowHourNextEl.textContent = nextPlanet
? `> ${nextPlanet.name}`
: `> ${nextHour.planetId}`;
} else {
elements.nowHourNextEl.textContent = "> --";
}
const nextPlanet = currentHour.nextHourPlanet;
elements.nowHourNextEl.textContent = nextPlanet
? `> ${nextPlanet.name}`
: "> --";
}
nowUiHelpers.setNowCardImage(
elements.nowHourCardEl,
planet?.tarot?.majorArcana,
currentHour.planet?.tarot?.majorArcana,
"Current planetary hour card",
planet?.tarot?.number
currentHour.planet?.tarot?.number
);
} else {
elements.nowHourEl.textContent = "--";
@@ -94,28 +99,27 @@
nowUiHelpers.setNowCardImage(elements.nowHourCardEl, null, "Current planetary hour card");
}
const moonIllum = window.SunCalc.getMoonIllumination(now);
const moonPhase = getMoonPhaseName(moonIllum.phase);
const moonTarot = referenceData.planets.luna?.tarot?.majorArcana || "The High Priestess";
elements.nowMoonEl.textContent = `${moonPhase} (${Math.round(moonIllum.fraction * 100)}%)`;
elements.nowMoonTarotEl.textContent = nowUiHelpers.getDisplayTarotName(moonTarot, referenceData.planets.luna?.tarot?.number);
const moon = snapshot?.moon || null;
const illuminationFraction = Number(moon?.illuminationFraction || 0);
const moonTarot = moon?.tarot?.majorArcana || "The High Priestess";
elements.nowMoonEl.textContent = moon
? `${moon.phase} (${Math.round(illuminationFraction * 100)}%)`
: "--";
elements.nowMoonTarotEl.textContent = moon
? nowUiHelpers.getDisplayTarotName(moonTarot, moon?.tarot?.number)
: "--";
nowUiHelpers.setNowCardImage(
elements.nowMoonCardEl,
moonTarot,
moon?.tarot?.majorArcana,
"Current moon phase card",
referenceData.planets.luna?.tarot?.number
moon?.tarot?.number
);
if (!moonCountdownCache || moonCountdownCache.fromPhase !== moonPhase || now >= moonCountdownCache.changeAt) {
moonCountdownCache = nowUiHelpers.findNextMoonPhaseTransition(now);
}
if (elements.nowMoonCountdownEl) {
if (moonCountdownCache?.changeAt) {
const remaining = moonCountdownCache.changeAt.getTime() - now.getTime();
elements.nowMoonCountdownEl.textContent = nowUiHelpers.formatCountdown(remaining, timeFormat);
if (moon?.countdown) {
elements.nowMoonCountdownEl.textContent = nowUiHelpers.formatCountdown(moon.countdown.msRemaining, timeFormat);
if (elements.nowMoonNextEl) {
elements.nowMoonNextEl.textContent = `> ${moonCountdownCache.nextPhase}`;
elements.nowMoonNextEl.textContent = `> ${moon.countdown.nextPhase}`;
}
} else {
elements.nowMoonCountdownEl.textContent = "--";
@@ -125,45 +129,39 @@
}
}
const sunInfo = getDecanForDate(now, referenceData.signs, referenceData.decansBySign);
const decanSkyKey = sunInfo?.sign
? `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`
: "no-decan";
if (sunInfo?.sign) {
const signStartDate = nowUiHelpers.getSignStartDate(now, sunInfo.sign);
const daysSinceSignStart = (now.getTime() - signStartDate.getTime()) / DAY_IN_MS;
const signDegree = Math.min(29.9, Math.max(0, daysSinceSignStart));
const signMajorName = nowUiHelpers.getDisplayTarotName(sunInfo.sign.tarot.majorArcana, sunInfo.sign.tarot.trumpNumber);
elements.nowDecanEl.textContent = `${sunInfo.sign.symbol} ${sunInfo.sign.name} · ${signMajorName} (${signDegree.toFixed(1)}°)`;
const decanInfo = snapshot?.decan || null;
if (decanInfo?.sign) {
const signMajorName = nowUiHelpers.getDisplayTarotName(
decanInfo.sign?.tarot?.majorArcana,
decanInfo.sign?.tarot?.number
);
const signDegree = Number.isFinite(Number(decanInfo.signDegree))
? Number(decanInfo.signDegree).toFixed(1)
: "0.0";
const currentDecanKey = `${sunInfo.sign.id}-${sunInfo.decan?.index || 1}`;
if (!decanCountdownCache || decanCountdownCache.key !== currentDecanKey || now >= decanCountdownCache.changeAt) {
decanCountdownCache = nowUiHelpers.findNextDecanTransition(now, referenceData.signs, referenceData.decansBySign);
}
elements.nowDecanEl.textContent = `${decanInfo.sign.symbol} ${decanInfo.sign.name} · ${signMajorName} (${signDegree}°)`;
if (sunInfo.decan) {
const decanCardName = sunInfo.decan.tarotMinorArcana;
elements.nowDecanTarotEl.textContent = nowUiHelpers.getDisplayTarotName(decanCardName);
nowUiHelpers.setNowCardImage(elements.nowDecanCardEl, sunInfo.decan.tarotMinorArcana, "Current decan card");
if (decanInfo.decan?.tarotMinorArcana) {
elements.nowDecanTarotEl.textContent = nowUiHelpers.getDisplayTarotName(decanInfo.decan.tarotMinorArcana);
nowUiHelpers.setNowCardImage(elements.nowDecanCardEl, decanInfo.decan.tarotMinorArcana, "Current decan card");
} else {
const signTarotName = sunInfo.sign.tarot?.majorArcana || "--";
const signTarotName = decanInfo.sign?.tarot?.majorArcana || "--";
elements.nowDecanTarotEl.textContent = signTarotName === "--"
? "--"
: nowUiHelpers.getDisplayTarotName(signTarotName, sunInfo.sign.tarot?.trumpNumber);
: nowUiHelpers.getDisplayTarotName(signTarotName, decanInfo.sign?.tarot?.number);
nowUiHelpers.setNowCardImage(
elements.nowDecanCardEl,
sunInfo.sign.tarot?.majorArcana,
decanInfo.sign?.tarot?.majorArcana,
"Current decan card",
sunInfo.sign.tarot?.trumpNumber
decanInfo.sign?.tarot?.number
);
}
if (elements.nowDecanCountdownEl) {
if (decanCountdownCache?.changeAt) {
const remaining = decanCountdownCache.changeAt.getTime() - now.getTime();
elements.nowDecanCountdownEl.textContent = nowUiHelpers.formatCountdown(remaining, timeFormat);
if (decanInfo.countdown) {
elements.nowDecanCountdownEl.textContent = nowUiHelpers.formatCountdown(decanInfo.countdown.msRemaining, timeFormat);
if (elements.nowDecanNextEl) {
elements.nowDecanNextEl.textContent = `> ${nowUiHelpers.getDisplayTarotName(decanCountdownCache.nextLabel)}`;
elements.nowDecanNextEl.textContent = `> ${nowUiHelpers.getDisplayTarotName(decanInfo.countdown.nextLabel)}`;
}
} else {
elements.nowDecanCountdownEl.textContent = "--";
@@ -184,14 +182,23 @@
}
}
nowUiHelpers.updateNowStats(referenceData, elements, now);
renderNowStatsFromSnapshot(elements, snapshot?.stats || {});
return {
dayKey,
skyRefreshKey: `${currentHourSkyKey}|${decanSkyKey}|${moonPhase}`
skyRefreshKey: String(snapshot?.skyRefreshKey || "")
};
}
async function updateNowPanel(referenceData, geo, elements, timeFormat = "minutes") {
if (!referenceData || !geo || !elements) {
return { dayKey: getDateKey(new Date()), skyRefreshKey: "" };
}
const snapshot = await dataService.fetchNowSnapshot?.(geo, new Date());
return applyNowSnapshot(elements, snapshot || {}, timeFormat);
}
window.TarotNowUi = {
updateNowPanel
};

View File

@@ -2,6 +2,8 @@
(function () {
"use strict";
const dataService = window.TarotDataService || {};
const state = {
initialized: false,
scoreCorrect: 0,
@@ -15,6 +17,7 @@
runRetrySet: new Set(),
currentQuestion: null,
answeredCurrent: false,
loadingQuestion: false,
autoAdvanceTimer: null,
autoAdvanceDelayMs: 1500
};
@@ -239,49 +242,15 @@
};
}
function instantiateQuestion(template) {
if (!template) {
return null;
}
const prompt = String(resolveDifficultyValue(template.promptByDifficulty) || "").trim();
const answer = normalizeOption(resolveDifficultyValue(template.answerByDifficulty));
const pool = toUniqueOptionList(resolveDifficultyValue(template.poolByDifficulty) || []);
if (!prompt || !answer) {
return null;
}
const built = buildOptions(answer, pool);
if (!built) {
return null;
}
return {
key: template.key,
categoryId: template.categoryId,
category: template.category,
prompt,
answer,
options: built.options,
correctIndex: built.correctIndex
};
async function buildQuestionBank(referenceData, magickDataset) {
const payload = await dataService.loadQuizTemplates?.();
return Array.isArray(payload?.templates)
? payload.templates
: (Array.isArray(payload) ? payload : []);
}
function buildQuestionBank(referenceData, magickDataset) {
if (typeof quizQuestionBank.buildQuestionBank !== "function") {
return [];
}
return quizQuestionBank.buildQuestionBank(
referenceData,
magickDataset,
DYNAMIC_CATEGORY_REGISTRY
);
}
function refreshQuestionBank(referenceData, magickDataset) {
state.questionBank = buildQuestionBank(referenceData, magickDataset);
async function refreshQuestionBank(referenceData, magickDataset) {
state.questionBank = await buildQuestionBank(referenceData, magickDataset);
state.templateByKey = new Map(state.questionBank.map((template) => [template.key, template]));
const hasTemplate = (key) => state.templateByKey.has(key);
@@ -380,7 +349,7 @@
|| "Category";
}
function startRun(resetScore = false) {
async function startRun(resetScore = false) {
clearAutoAdvanceTimer();
if (resetScore) {
@@ -425,7 +394,7 @@
state.answeredCurrent = true;
updateScoreboard();
showNextQuestion();
await showNextQuestion();
}
function popNextTemplateFromRun() {
@@ -450,6 +419,7 @@
}
function renderRunCompleteState() {
state.loadingQuestion = false;
state.currentQuestion = null;
state.answeredCurrent = true;
questionTypeEl.textContent = getRunLabel();
@@ -479,6 +449,7 @@
return;
}
state.loadingQuestion = false;
state.currentQuestion = question;
state.answeredCurrent = false;
@@ -505,6 +476,7 @@
return;
}
state.loadingQuestion = false;
state.currentQuestion = null;
state.answeredCurrent = true;
@@ -514,9 +486,25 @@
feedbackEl.textContent = "Try Random/All or switch to another category.";
}
function showNextQuestion() {
async function createQuestionFromTemplate(template) {
if (!template) {
return null;
}
return dataService.pullQuizQuestion?.({
templateKey: template.key,
difficulty: getActiveDifficulty(),
includeAnswer: true
});
}
async function showNextQuestion() {
clearAutoAdvanceTimer();
if (state.loadingQuestion) {
return;
}
const totalPending = state.runUnseenKeys.length + state.runRetryKeys.length;
if (totalPending <= 0) {
if (state.questionBank.length) {
@@ -528,13 +516,21 @@
}
const maxAttempts = totalPending + 1;
state.loadingQuestion = true;
feedbackEl.textContent = "Loading question...";
for (let index = 0; index < maxAttempts; index += 1) {
const template = popNextTemplateFromRun();
if (!template) {
continue;
}
const question = instantiateQuestion(template);
let question = null;
try {
question = await createQuestionFromTemplate(template);
} catch (_error) {
question = null;
}
if (question) {
renderQuestion(question);
return;
@@ -589,21 +585,21 @@
categoryEl.addEventListener("change", () => {
state.selectedCategory = String(categoryEl.value || "random");
startRun(true);
void startRun(true);
});
difficultyEl.addEventListener("change", () => {
state.selectedDifficulty = String(difficultyEl.value || "normal").toLowerCase();
syncDifficultyControl();
startRun(true);
void startRun(true);
});
resetEl.addEventListener("click", () => {
startRun(true);
void startRun(true);
});
}
function ensureQuizSection(referenceData, magickDataset) {
async function ensureQuizSection(referenceData, magickDataset) {
ensureQuizSection._referenceData = referenceData;
ensureQuizSection._magickDataset = magickDataset;
@@ -618,23 +614,23 @@
updateScoreboard();
}
refreshQuestionBank(referenceData, magickDataset);
await refreshQuestionBank(referenceData, magickDataset);
const categoryAdjusted = renderCategoryOptions();
syncDifficultyControl();
if (categoryAdjusted) {
startRun(false);
await startRun(false);
return;
}
const hasRunPending = state.runUnseenKeys.length > 0 || state.runRetryKeys.length > 0;
if (!state.currentQuestion && !hasRunPending) {
startRun(false);
await startRun(false);
return;
}
if (!state.currentQuestion && hasRunPending) {
showNextQuestion();
await showNextQuestion();
}
updateScoreboard();

View File

@@ -14,6 +14,7 @@
onSettingsApplied: null,
onSyncSkyBackground: null,
onStatus: null,
onConnectionSaved: null,
onReopenActiveSection: null,
getActiveSection: null,
onRenderWeek: null
@@ -30,10 +31,37 @@
timeFormatEl: document.getElementById("time-format"),
birthDateEl: document.getElementById("birth-date"),
tarotDeckEl: document.getElementById("tarot-deck"),
apiBaseUrlEl: document.getElementById("api-base-url"),
apiKeyEl: document.getElementById("api-key"),
saveSettingsEl: document.getElementById("save-settings"),
useLocationEl: document.getElementById("use-location")
};
}
function getConnectionSettings() {
return window.TarotAppConfig?.getConnectionSettings?.() || {
apiBaseUrl: String(window.TarotAppConfig?.apiBaseUrl || "").trim(),
apiKey: String(window.TarotAppConfig?.apiKey || "").trim()
};
}
function syncConnectionInputs() {
const { apiBaseUrlEl, apiKeyEl } = getElements();
const connectionSettings = getConnectionSettings();
if (apiBaseUrlEl) {
apiBaseUrlEl.value = String(connectionSettings.apiBaseUrl || "");
}
if (apiKeyEl) {
apiKeyEl.value = String(connectionSettings.apiKey || "");
}
}
function hasConnectionChanged(previous, next) {
return String(previous?.apiBaseUrl || "").trim() !== String(next?.apiBaseUrl || "").trim()
|| String(previous?.apiKey || "").trim() !== String(next?.apiKey || "").trim();
}
function setStatus(text) {
if (typeof config.onStatus === "function") {
@@ -259,6 +287,7 @@
function applySettingsToInputs(settings) {
const { latEl, lngEl, timeFormatEl, birthDateEl, tarotDeckEl } = getElements();
syncTarotDeckInputOptions();
syncConnectionInputs();
const normalized = normalizeSettings(settings);
latEl.value = String(normalized.latitude);
lngEl.value = String(normalized.longitude);
@@ -292,12 +321,22 @@
});
}
function getConnectionSettingsFromInputs() {
const { apiBaseUrlEl, apiKeyEl } = getElements();
return {
apiBaseUrl: String(apiBaseUrlEl?.value || "").trim(),
apiKey: String(apiKeyEl?.value || "").trim()
};
}
function openSettingsPopup() {
const { settingsPopupEl, openSettingsEl } = getElements();
if (!settingsPopupEl) {
return;
}
applySettingsToInputs(loadSavedSettings());
settingsPopupEl.hidden = false;
if (openSettingsEl) {
openSettingsEl.setAttribute("aria-expanded", "true");
@@ -319,6 +358,10 @@
async function handleSaveSettings() {
try {
const settings = getSettingsFromInputs();
const previousConnectionSettings = getConnectionSettings();
const connectionSettings = getConnectionSettingsFromInputs();
const connectionChanged = hasConnectionChanged(previousConnectionSettings, connectionSettings);
const connectionResult = window.TarotAppConfig?.updateConnectionSettings?.(connectionSettings) || { didPersist: true };
const normalized = applySettingsToInputs(settings);
syncSky({ latitude: normalized.latitude, longitude: normalized.longitude }, true);
const didPersist = saveSettings(normalized);
@@ -327,11 +370,13 @@
config.onReopenActiveSection?.(config.getActiveSection());
}
closeSettingsPopup();
if (typeof config.onRenderWeek === "function") {
if (connectionChanged && typeof config.onConnectionSaved === "function") {
await config.onConnectionSaved(connectionResult, connectionSettings);
} else if (typeof config.onRenderWeek === "function") {
await config.onRenderWeek();
}
if (!didPersist) {
if (!didPersist || connectionResult.didPersist === false) {
setStatus("Settings applied for this session. Browser storage is unavailable.");
}
} catch (error) {
@@ -419,6 +464,11 @@
closeSettingsPopup();
}
});
document.addEventListener("connection:updated", () => {
syncConnectionInputs();
syncTarotDeckInputOptions();
});
}
function init(nextConfig = {}) {

View File

@@ -1,9 +1,12 @@
(function () {
"use strict";
const dataService = window.TarotDataService || {};
let initialized = false;
let activeTarotSpread = null;
let activeTarotSpreadDraw = [];
let activeTarotSpreadLoading = false;
let config = {
ensureTarotSection: null,
getReferenceData: () => null,
@@ -59,22 +62,6 @@
return value === "celtic-cross" ? "celtic-cross" : "three-card";
}
function drawNFromDeck(n) {
const allCards = window.TarotSectionUi?.getCards?.() || [];
if (!allCards.length) return [];
const shuffled = [...allCards];
for (let index = shuffled.length - 1; index > 0; index -= 1) {
const swapIndex = Math.floor(Math.random() * (index + 1));
[shuffled[index], shuffled[swapIndex]] = [shuffled[swapIndex], shuffled[index]];
}
return shuffled.slice(0, n).map((card) => ({
...card,
reversed: Math.random() < 0.3
}));
}
function escapeHtml(value) {
return String(value || "")
.replace(/&/g, "&amp;")
@@ -88,15 +75,27 @@
return spreadId === "celtic-cross" ? CELTIC_CROSS_POSITIONS : THREE_CARD_POSITIONS;
}
function regenerateTarotSpreadDraw() {
async function regenerateTarotSpreadDraw() {
const normalizedSpread = normalizeTarotSpread(activeTarotSpread);
const positions = getSpreadPositions(normalizedSpread);
const cards = drawNFromDeck(positions.length);
activeTarotSpreadDraw = positions.map((position, index) => ({
position,
card: cards[index] || null,
revealed: false
}));
activeTarotSpreadLoading = true;
try {
const payload = await dataService.pullTarotSpread?.(normalizedSpread);
const apiPositions = Array.isArray(payload?.positions) ? payload.positions : [];
activeTarotSpreadDraw = apiPositions.map((entry, index) => ({
position: entry?.position || positions[index] || null,
card: entry?.card
? {
...entry.card,
reversed: Boolean(entry?.reversed ?? entry.card?.reversed)
}
: null,
revealed: false
}));
} finally {
activeTarotSpreadLoading = false;
}
}
function renderTarotSpreadMeanings() {
@@ -158,8 +157,20 @@
|| ""
).trim();
if (activeTarotSpreadLoading) {
tarotSpreadBoardEl.innerHTML = '<div class="spread-empty">Loading spread from API...</div>';
if (tarotSpreadMeaningsEl) {
tarotSpreadMeaningsEl.innerHTML = "";
}
return;
}
if (!activeTarotSpreadDraw.length) {
regenerateTarotSpreadDraw();
tarotSpreadBoardEl.innerHTML = '<div class="spread-empty">Loading spread...</div>';
if (tarotSpreadMeaningsEl) {
tarotSpreadMeaningsEl.innerHTML = "";
}
return;
}
tarotSpreadBoardEl.className = `tarot-spread-board tarot-spread-board--${isCeltic ? "celtic" : "three"}`;
@@ -267,10 +278,14 @@
function showTarotSpreadView(spreadId = "three-card") {
activeTarotSpread = normalizeTarotSpread(spreadId);
regenerateTarotSpreadDraw();
activeTarotSpreadLoading = true;
activeTarotSpreadDraw = [];
applyViewState();
ensureTarotBrowseData();
renderTarotSpread();
void regenerateTarotSpreadDraw().then(() => {
renderTarotSpread();
});
}
function setSpread(spreadId, openTarotSection = false) {
@@ -281,8 +296,12 @@
}
function revealAll() {
if (activeTarotSpreadLoading) {
return;
}
if (!activeTarotSpreadDraw.length) {
regenerateTarotSpreadDraw();
return;
}
activeTarotSpreadDraw.forEach((entry) => {
@@ -376,8 +395,12 @@
if (tarotSpreadRedrawEl) {
tarotSpreadRedrawEl.addEventListener("click", () => {
regenerateTarotSpreadDraw();
activeTarotSpreadLoading = true;
activeTarotSpreadDraw = [];
renderTarotSpread();
void regenerateTarotSpreadDraw().then(() => {
renderTarotSpread();
});
});
}

View File

@@ -1,4 +1,5 @@
(function () {
const dataService = window.TarotDataService || {};
const {
resolveTarotCardImage,
resolveTarotCardThumbnail,
@@ -41,7 +42,8 @@
magickDataset: null,
referenceData: null,
monthRefsByCardId: new Map(),
courtCardByDecanId: new Map()
courtCardByDecanId: new Map(),
loadingPromise: null
};
const TAROT_TRUMP_NUMBER_BY_NAME = {
@@ -781,7 +783,19 @@
}
}
function ensureTarotSection(referenceData, magickDataset = null) {
async function loadCards(referenceData, magickDataset) {
const payload = await dataService.loadTarotCards?.();
const cards = Array.isArray(payload?.cards)
? payload.cards
: (Array.isArray(payload) ? payload : []);
return cards.map((card) => ({
...card,
id: String(card?.id || "").trim() || cardId(card)
}));
}
async function ensureTarotSection(referenceData, magickDataset = null) {
state.referenceData = referenceData || state.referenceData;
if (magickDataset) {
@@ -853,151 +867,156 @@
return;
}
const databaseBuilder = window.TarotCardDatabase?.buildTarotDatabase;
if (typeof databaseBuilder !== "function") {
if (state.loadingPromise) {
await state.loadingPromise;
return;
}
const cards = databaseBuilder(referenceData, magickDataset).map((card) => ({
...card,
id: cardId(card)
}));
state.loadingPromise = (async () => {
const cards = await loadCards(referenceData, magickDataset);
state.cards = cards;
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, cards);
state.courtCardByDecanId = buildCourtCardByDecanId(cards);
state.filteredCards = [...cards];
renderList(elements);
renderHouseOfCards(elements);
syncHouseControls(elements);
state.cards = cards;
state.monthRefsByCardId = buildMonthReferencesByCard(referenceData, cards);
state.courtCardByDecanId = buildCourtCardByDecanId(cards);
state.filteredCards = [...cards];
renderList(elements);
renderHouseOfCards(elements);
syncHouseControls(elements);
if (cards.length > 0) {
selectCardById(cards[0].id, elements);
}
elements.tarotCardListEl.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
if (cards.length > 0) {
selectCardById(cards[0].id, elements);
}
const button = target instanceof Element
? target.closest(".tarot-list-item")
: null;
if (!(button instanceof HTMLButtonElement)) {
return;
}
const selectedId = button.dataset.cardId;
if (!selectedId) {
return;
}
selectCardById(selectedId, elements);
});
if (elements.tarotSearchInputEl) {
elements.tarotSearchInputEl.addEventListener("input", () => {
state.searchQuery = elements.tarotSearchInputEl.value || "";
applySearchFilter(elements);
});
}
if (elements.tarotSearchClearEl && elements.tarotSearchInputEl) {
elements.tarotSearchClearEl.addEventListener("click", () => {
elements.tarotSearchInputEl.value = "";
state.searchQuery = "";
applySearchFilter(elements);
elements.tarotSearchInputEl.focus();
});
}
if (elements.tarotHouseFocusToggleEl) {
elements.tarotHouseFocusToggleEl.addEventListener("click", () => {
state.houseFocusMode = !state.houseFocusMode;
syncHouseControls(elements);
});
}
if (elements.tarotHouseTopCardsVisibleEl) {
elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => {
state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
[
[elements.tarotHouseTopInfoHebrewEl, "hebrew"],
[elements.tarotHouseTopInfoPlanetEl, "planet"],
[elements.tarotHouseTopInfoZodiacEl, "zodiac"],
[elements.tarotHouseTopInfoTrumpEl, "trump"],
[elements.tarotHouseTopInfoPathEl, "path"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseTopInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseBottomCardsVisibleEl) {
elements.tarotHouseBottomCardsVisibleEl.addEventListener("change", () => {
state.houseBottomCardsVisible = Boolean(elements.tarotHouseBottomCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
[
[elements.tarotHouseBottomInfoZodiacEl, "zodiac"],
[elements.tarotHouseBottomInfoDecanEl, "decan"],
[elements.tarotHouseBottomInfoMonthEl, "month"],
[elements.tarotHouseBottomInfoRulerEl, "ruler"],
[elements.tarotHouseBottomInfoDateEl, "date"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseBottomInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseExportEl) {
elements.tarotHouseExportEl.addEventListener("click", () => {
exportHouseOfCards(elements, "png");
});
}
if (elements.tarotHouseExportWebpEl) {
elements.tarotHouseExportWebpEl.addEventListener("click", () => {
exportHouseOfCards(elements, "webp");
});
}
if (elements.tarotDetailImageEl) {
elements.tarotDetailImageEl.addEventListener("click", () => {
if (elements.tarotDetailImageEl.style.display === "none" || !state.selectedCardId) {
elements.tarotCardListEl.addEventListener("click", (event) => {
const target = event.target;
if (!(target instanceof Node)) {
return;
}
const request = buildLightboxCardRequestById(state.selectedCardId);
if (!request?.src) {
const button = target instanceof Element
? target.closest(".tarot-list-item")
: null;
if (!(button instanceof HTMLButtonElement)) {
return;
}
window.TarotUiLightbox?.open?.(request);
});
}
const selectedId = button.dataset.cardId;
if (!selectedId) {
return;
}
state.initialized = true;
selectCardById(selectedId, elements);
});
if (elements.tarotSearchInputEl) {
elements.tarotSearchInputEl.addEventListener("input", () => {
state.searchQuery = elements.tarotSearchInputEl.value || "";
applySearchFilter(elements);
});
}
if (elements.tarotSearchClearEl && elements.tarotSearchInputEl) {
elements.tarotSearchClearEl.addEventListener("click", () => {
elements.tarotSearchInputEl.value = "";
state.searchQuery = "";
applySearchFilter(elements);
elements.tarotSearchInputEl.focus();
});
}
if (elements.tarotHouseFocusToggleEl) {
elements.tarotHouseFocusToggleEl.addEventListener("click", () => {
state.houseFocusMode = !state.houseFocusMode;
syncHouseControls(elements);
});
}
if (elements.tarotHouseTopCardsVisibleEl) {
elements.tarotHouseTopCardsVisibleEl.addEventListener("change", () => {
state.houseTopCardsVisible = Boolean(elements.tarotHouseTopCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
[
[elements.tarotHouseTopInfoHebrewEl, "hebrew"],
[elements.tarotHouseTopInfoPlanetEl, "planet"],
[elements.tarotHouseTopInfoZodiacEl, "zodiac"],
[elements.tarotHouseTopInfoTrumpEl, "trump"],
[elements.tarotHouseTopInfoPathEl, "path"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseTopInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseBottomCardsVisibleEl) {
elements.tarotHouseBottomCardsVisibleEl.addEventListener("change", () => {
state.houseBottomCardsVisible = Boolean(elements.tarotHouseBottomCardsVisibleEl.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
}
[
[elements.tarotHouseBottomInfoZodiacEl, "zodiac"],
[elements.tarotHouseBottomInfoDecanEl, "decan"],
[elements.tarotHouseBottomInfoMonthEl, "month"],
[elements.tarotHouseBottomInfoRulerEl, "ruler"],
[elements.tarotHouseBottomInfoDateEl, "date"]
].forEach(([checkbox, key]) => {
if (!checkbox) {
return;
}
checkbox.addEventListener("change", () => {
state.houseBottomInfoModes[key] = Boolean(checkbox.checked);
renderHouseOfCards(elements);
syncHouseControls(elements);
});
});
if (elements.tarotHouseExportEl) {
elements.tarotHouseExportEl.addEventListener("click", () => {
exportHouseOfCards(elements, "png");
});
}
if (elements.tarotHouseExportWebpEl) {
elements.tarotHouseExportWebpEl.addEventListener("click", () => {
exportHouseOfCards(elements, "webp");
});
}
if (elements.tarotDetailImageEl) {
elements.tarotDetailImageEl.addEventListener("click", () => {
if (elements.tarotDetailImageEl.style.display === "none" || !state.selectedCardId) {
return;
}
const request = buildLightboxCardRequestById(state.selectedCardId);
if (!request?.src) {
return;
}
window.TarotUiLightbox?.open?.(request);
});
}
state.initialized = true;
})();
try {
await state.loadingPromise;
} finally {
state.loadingPromise = null;
}
}
function selectCardByTrump(trumpNumber) {