Files
TaroTime/app/ui-alphabet-text.js

1629 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function () {
"use strict";
const dataService = window.TarotDataService || {};
const state = {
initialized: false,
catalog: null,
selectedSourceId: "",
selectedWorkId: "",
selectedSectionId: "",
currentPassage: null,
lexiconEntry: null,
lexiconRequestId: 0,
lexiconOccurrenceResults: null,
lexiconOccurrenceLoading: false,
lexiconOccurrenceError: "",
lexiconOccurrenceVisible: false,
lexiconOccurrenceRequestId: 0,
globalSearchQuery: "",
localSearchQuery: "",
activeSearchScope: "global",
searchQuery: "",
searchResults: null,
searchLoading: false,
searchError: "",
searchRequestId: 0,
highlightedVerseId: "",
displayPreferencesBySource: {}
};
let sourceListEl;
let sourceCountEl;
let globalSearchFormEl;
let globalSearchInputEl;
let localSearchFormEl;
let localSearchInputEl;
let workSelectEl;
let sectionSelectEl;
let detailNameEl;
let detailSubEl;
let detailBodyEl;
let textLayoutEl;
let lexiconPopupEl;
let lexiconPopupTitleEl;
let lexiconPopupSubtitleEl;
let lexiconPopupBodyEl;
let lexiconPopupCloseEl;
let lexiconReturnFocusEl = null;
function normalizeId(value) {
return String(value || "")
.trim()
.toLowerCase();
}
function getElements() {
sourceListEl = document.getElementById("alpha-text-source-list");
sourceCountEl = document.getElementById("alpha-text-source-count");
globalSearchFormEl = document.getElementById("alpha-text-global-search-form");
globalSearchInputEl = document.getElementById("alpha-text-global-search-input");
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
workSelectEl = document.getElementById("alpha-text-work-select");
sectionSelectEl = document.getElementById("alpha-text-section-select");
detailNameEl = document.getElementById("alpha-text-detail-name");
detailSubEl = document.getElementById("alpha-text-detail-sub");
detailBodyEl = document.getElementById("alpha-text-detail-body");
textLayoutEl = sourceListEl?.closest?.(".planet-layout") || detailBodyEl?.closest?.(".planet-layout") || null;
ensureLexiconPopup();
}
function showDetailOnlyMode() {
if (!(textLayoutEl instanceof HTMLElement)) {
return;
}
window.TarotChromeUi?.initializeSidebarPopouts?.();
window.TarotChromeUi?.initializeDetailPopouts?.();
window.TarotChromeUi?.initializeSidebarAutoCollapse?.();
window.TarotChromeUi?.showDetailOnly?.(textLayoutEl);
}
function ensureLexiconPopup() {
if (lexiconPopupEl instanceof HTMLElement) {
return;
}
const popup = document.createElement("div");
popup.className = "alpha-text-lexicon-popup";
popup.hidden = true;
popup.setAttribute("aria-hidden", "true");
const backdrop = document.createElement("div");
backdrop.className = "alpha-text-lexicon-popup-backdrop";
backdrop.addEventListener("click", closeLexiconEntry);
const card = document.createElement("section");
card.className = "alpha-text-lexicon-popup-card";
card.setAttribute("role", "dialog");
card.setAttribute("aria-modal", "true");
card.setAttribute("aria-labelledby", "alpha-text-lexicon-popup-title");
card.setAttribute("tabindex", "-1");
const header = document.createElement("div");
header.className = "alpha-text-lexicon-popup-header";
const headingWrap = document.createElement("div");
headingWrap.className = "alpha-text-lexicon-popup-heading";
const title = document.createElement("h3");
title.id = "alpha-text-lexicon-popup-title";
title.textContent = "Lexicon Entry";
const subtitle = document.createElement("p");
subtitle.className = "alpha-text-lexicon-popup-subtitle";
subtitle.textContent = "Strong's definition";
headingWrap.append(title, subtitle);
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "alpha-text-lexicon-popup-close";
closeButton.textContent = "Close";
closeButton.addEventListener("click", closeLexiconEntry);
header.append(headingWrap, closeButton);
const body = document.createElement("div");
body.className = "alpha-text-lexicon-popup-body";
card.append(header, body);
popup.append(backdrop, card);
document.body.appendChild(popup);
lexiconPopupEl = popup;
lexiconPopupTitleEl = title;
lexiconPopupSubtitleEl = subtitle;
lexiconPopupBodyEl = body;
lexiconPopupCloseEl = closeButton;
}
function getSources() {
return Array.isArray(state.catalog?.sources) ? state.catalog.sources : [];
}
function findById(entries, value) {
const needle = normalizeId(value);
return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null;
}
function getSelectedSource() {
return findById(getSources(), state.selectedSourceId);
}
function getSelectedWork(source = getSelectedSource()) {
return findById(source?.works, state.selectedWorkId);
}
function getSelectedSection(source = getSelectedSource(), work = getSelectedWork(source)) {
return findById(work?.sections, state.selectedSectionId);
}
function normalizeTextValue(value) {
return String(value || "").trim();
}
const GREEK_TRANSLITERATION_MAP = {
α: "a", β: "b", γ: "g", δ: "d", ε: "e", ζ: "z", η: "e", θ: "th",
ι: "i", κ: "k", λ: "l", μ: "m", ν: "n", ξ: "x", ο: "o", π: "p",
ρ: "r", σ: "s", ς: "s", τ: "t", υ: "u", φ: "ph", χ: "ch", ψ: "ps",
ω: "o"
};
const HEBREW_TRANSLITERATION_MAP = {
א: "a", ב: "b", ג: "g", ד: "d", ה: "h", ו: "v", ז: "z", ח: "ch",
ט: "t", י: "y", כ: "k", ך: "k", ל: "l", מ: "m", ם: "m", נ: "n",
ן: "n", ס: "s", ע: "a", פ: "p", ף: "p", צ: "ts", ץ: "ts", ק: "q",
ר: "r", ש: "sh", ת: "t"
};
function stripSourceScriptMarks(value) {
return String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f\u0591-\u05c7]/g, "");
}
function transliterateSourceScriptText(value) {
const stripped = stripSourceScriptMarks(value);
let result = "";
for (const character of stripped) {
const lowerCharacter = character.toLowerCase();
const mapped = GREEK_TRANSLITERATION_MAP[lowerCharacter]
|| HEBREW_TRANSLITERATION_MAP[character]
|| HEBREW_TRANSLITERATION_MAP[lowerCharacter];
result += mapped != null ? mapped : character;
}
return result
.replace(/\s+([,.;:!?])/g, "$1")
.replace(/\s+/g, " ")
.trim();
}
function buildTokenDerivedTransliteration(verse) {
const tokenText = (Array.isArray(verse?.tokens) ? verse.tokens : [])
.map((token) => normalizeTextValue(token?.original))
.filter(Boolean)
.join(" ")
.replace(/\s+([,.;:!?])/g, "$1")
.trim();
return tokenText ? transliterateSourceScriptText(tokenText) : "";
}
function getVerseTransliteration(verse, source = null) {
const metadata = verse?.metadata && typeof verse.metadata === "object" ? verse.metadata : {};
const explicit = [
verse?.transliteration,
verse?.xlit,
metadata?.transliteration,
metadata?.transliterationText,
metadata?.xlit,
metadata?.romanizedText,
metadata?.romanized
].map(normalizeTextValue).find(Boolean) || "";
if (explicit) {
return explicit;
}
if (source?.features?.hasTokenAnnotations) {
return buildTokenDerivedTransliteration(verse);
}
return "";
}
function getSearchInput(scope) {
return scope === "source" ? localSearchInputEl : globalSearchInputEl;
}
function getStoredSearchQuery(scope) {
return scope === "source" ? state.localSearchQuery : state.globalSearchQuery;
}
function setStoredSearchQuery(scope, value) {
if (scope === "source") {
state.localSearchQuery = value;
return;
}
state.globalSearchQuery = value;
}
function updateSearchControls() {
return;
}
function clearActiveSearchUi(options = {}) {
const preserveHighlight = options.preserveHighlight === true;
const scope = state.activeSearchScope === "source" ? "source" : "global";
setStoredSearchQuery(scope, "");
const input = getSearchInput(scope);
if (input instanceof HTMLInputElement) {
input.value = "";
}
state.searchQuery = "";
state.searchResults = null;
state.searchLoading = false;
state.searchError = "";
state.searchRequestId += 1;
if (!preserveHighlight) {
state.highlightedVerseId = "";
}
updateSearchControls();
}
function getSourceDisplayCapabilities(source, passage) {
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
const hasOriginal = verses.some((verse) => normalizeTextValue(verse?.originalText));
const hasTransliteration = verses.some((verse) => getVerseTransliteration(verse, source));
const hasInterlinear = Boolean(source?.features?.hasTokenAnnotations);
const textModeCount = 1 + (hasOriginal ? 1 : 0) + (hasTransliteration ? 1 : 0);
return {
hasTranslation: true,
hasOriginal,
hasTransliteration,
hasInterlinear,
hasAnyExtras: hasOriginal || hasTransliteration || hasInterlinear,
supportsAllTextMode: textModeCount > 1
};
}
function getDefaultTextDisplayMode(capabilities) {
if (capabilities?.hasTranslation) {
return "translation";
}
if (capabilities?.hasOriginal) {
return "original";
}
if (capabilities?.hasTransliteration) {
return "transliteration";
}
return "translation";
}
function getAvailableTextDisplayModes(capabilities) {
const modes = [];
if (capabilities?.hasTranslation) {
modes.push("translation");
}
if (capabilities?.hasOriginal) {
modes.push("original");
}
if (capabilities?.hasTransliteration) {
modes.push("transliteration");
}
if (capabilities?.supportsAllTextMode) {
modes.push("all");
}
return modes;
}
function getSourceDisplayPreferences(source, passage) {
const sourceId = normalizeId(source?.id);
const capabilities = getSourceDisplayCapabilities(source, passage);
const availableTextModes = getAvailableTextDisplayModes(capabilities);
const stored = sourceId ? state.displayPreferencesBySource[sourceId] : null;
let textMode = stored?.textMode;
if (!availableTextModes.includes(textMode)) {
textMode = getDefaultTextDisplayMode(capabilities);
}
const preferences = {
textMode,
showInterlinear: capabilities.hasInterlinear ? Boolean(stored?.showInterlinear) : false
};
if (sourceId) {
state.displayPreferencesBySource[sourceId] = preferences;
}
return {
...preferences,
capabilities,
availableTextModes
};
}
function updateSourceDisplayPreferences(source, patch) {
const sourceId = normalizeId(source?.id);
if (!sourceId) {
return;
}
const current = state.displayPreferencesBySource[sourceId] || {};
state.displayPreferencesBySource[sourceId] = {
...current,
...patch
};
}
function formatTextDisplayModeLabel(mode) {
switch (mode) {
case "translation":
return "Translation";
case "original":
return "Original";
case "transliteration":
return "Transliteration";
case "all":
return "All";
default:
return "Display";
}
}
function clearSearchState() {
state.searchQuery = "";
state.searchResults = null;
state.searchLoading = false;
state.searchError = "";
state.highlightedVerseId = "";
state.searchRequestId += 1;
updateSearchControls();
}
function clearScopedSearch(scope) {
setStoredSearchQuery(scope, "");
const input = getSearchInput(scope);
if (input instanceof HTMLInputElement) {
input.value = "";
}
if (state.activeSearchScope === scope) {
clearSearchState();
} else {
updateSearchControls();
}
}
function escapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function appendHighlightedText(target, text, query) {
if (!(target instanceof HTMLElement)) {
return;
}
const sourceText = String(text || "");
const normalizedQuery = String(query || "").trim();
target.replaceChildren();
if (!normalizedQuery) {
target.textContent = sourceText;
return;
}
const matcher = new RegExp(escapeRegExp(normalizedQuery), "ig");
let lastIndex = 0;
let match = matcher.exec(sourceText);
while (match) {
if (match.index > lastIndex) {
target.appendChild(document.createTextNode(sourceText.slice(lastIndex, match.index)));
}
const mark = document.createElement("mark");
mark.className = "alpha-text-mark";
mark.textContent = sourceText.slice(match.index, match.index + match[0].length);
target.appendChild(mark);
lastIndex = match.index + match[0].length;
match = matcher.exec(sourceText);
}
if (lastIndex < sourceText.length) {
target.appendChild(document.createTextNode(sourceText.slice(lastIndex)));
}
}
function isHighlightedVerse(verse) {
return normalizeId(verse?.id) && normalizeId(verse?.id) === normalizeId(state.highlightedVerseId);
}
function scrollHighlightedVerseIntoView() {
const highlightedVerse = detailBodyEl?.querySelector?.(".alpha-text-verse.is-highlighted");
const detailPanel = highlightedVerse?.closest?.(".planet-detail-panel");
if (!(highlightedVerse instanceof HTMLElement) || !(detailPanel instanceof HTMLElement)) {
return;
}
const verseRect = highlightedVerse.getBoundingClientRect();
const panelRect = detailPanel.getBoundingClientRect();
const targetTop = detailPanel.scrollTop
+ (verseRect.top - panelRect.top)
- (detailPanel.clientHeight / 2)
+ (verseRect.height / 2);
detailPanel.scrollTo({
top: Math.max(0, targetTop),
behavior: "smooth"
});
}
function createCard(title) {
const card = document.createElement("div");
card.className = "detail-meta-card planet-meta-card";
if (title) {
const heading = document.createElement("strong");
heading.textContent = title;
card.appendChild(heading);
}
return card;
}
function createEmptyMessage(text) {
const message = document.createElement("div");
message.className = "alpha-text-empty";
message.textContent = text;
return message;
}
function renderPlaceholder(title, subtitle, message) {
if (detailNameEl) {
detailNameEl.textContent = title;
}
if (detailSubEl) {
detailSubEl.textContent = subtitle;
}
if (!detailBodyEl) {
return;
}
detailBodyEl.replaceChildren();
const card = createCard("Text Reader");
card.appendChild(createEmptyMessage(message));
detailBodyEl.appendChild(card);
}
function navigateToPassageTarget(target) {
if (!target) {
return;
}
state.selectedWorkId = target.workId;
state.selectedSectionId = target.sectionId;
state.lexiconEntry = null;
renderSelectors();
void loadSelectedPassage();
}
function getPassageLocationLabel(passage) {
const source = passage?.source || getSelectedSource();
const work = passage?.work || getSelectedWork(source);
const section = passage?.section || getSelectedSection(source, work);
return `${work?.title || "--"} · ${section?.title || section?.label || "--"}`;
}
function syncSelectionForSource(source) {
const works = Array.isArray(source?.works) ? source.works : [];
if (!works.length) {
state.selectedWorkId = "";
state.selectedSectionId = "";
return;
}
if (!findById(works, state.selectedWorkId)) {
state.selectedWorkId = works[0].id;
}
const work = getSelectedWork(source);
const sections = Array.isArray(work?.sections) ? work.sections : [];
if (!findById(sections, state.selectedSectionId)) {
state.selectedSectionId = sections[0]?.id || "";
}
}
async function ensureCatalogLoaded(forceRefresh = false) {
if (!forceRefresh && state.catalog) {
return state.catalog;
}
const payload = await dataService.loadTextLibrary?.(forceRefresh);
state.catalog = payload && typeof payload === "object"
? payload
: { meta: {}, sources: [], lexicons: [] };
if (!state.selectedSourceId) {
state.selectedSourceId = getSources()[0]?.id || "";
}
syncSelectionForSource(getSelectedSource());
return state.catalog;
}
function fillSelect(selectEl, entries, selectedValue, labelBuilder) {
if (!(selectEl instanceof HTMLSelectElement)) {
return;
}
selectEl.replaceChildren();
(Array.isArray(entries) ? entries : []).forEach((entry) => {
const option = document.createElement("option");
option.value = entry.id;
option.textContent = typeof labelBuilder === "function" ? labelBuilder(entry) : String(entry?.label || entry?.title || entry?.id || "");
option.selected = normalizeId(entry.id) === normalizeId(selectedValue);
selectEl.appendChild(option);
});
selectEl.disabled = !selectEl.options.length;
}
function renderSourceList() {
if (!sourceListEl) {
return;
}
sourceListEl.replaceChildren();
const sources = getSources();
sources.forEach((source) => {
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item alpha-text-source-btn";
button.dataset.sourceId = source.id;
button.setAttribute("role", "option");
const isSelected = normalizeId(source.id) === normalizeId(state.selectedSourceId);
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
const name = document.createElement("span");
name.className = "planet-list-name";
name.textContent = source.title;
const meta = document.createElement("span");
meta.className = "alpha-text-source-meta";
const sectionLabel = source.sectionLabel || "Section";
meta.textContent = `${source.shortTitle || source.title} · ${source.stats?.workCount || 0} ${source.workLabel || "Works"} · ${source.stats?.sectionCount || 0} ${sectionLabel.toLowerCase()}s`;
button.append(name, meta);
button.addEventListener("click", () => {
if (normalizeId(source.id) === normalizeId(state.selectedSourceId)) {
showDetailOnlyMode();
return;
}
state.selectedSourceId = source.id;
state.currentPassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
syncSelectionForSource(getSelectedSource());
renderSourceList();
renderSelectors();
showDetailOnlyMode();
if (state.searchQuery && state.activeSearchScope === "source") {
void Promise.all([loadSelectedPassage(), runSearch("source")]);
return;
}
void loadSelectedPassage();
});
sourceListEl.appendChild(button);
});
if (!sources.length) {
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
}
if (sourceCountEl) {
sourceCountEl.textContent = `${sources.length} sources`;
}
}
function renderSelectors() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const works = Array.isArray(source?.works) ? source.works : [];
const sections = Array.isArray(work?.sections) ? work.sections : [];
fillSelect(workSelectEl, works, state.selectedWorkId, (entry) => `${entry.title} (${entry.sectionCount} ${String(source?.sectionLabel || "section").toLowerCase()}s)`);
fillSelect(sectionSelectEl, sections, state.selectedSectionId, (entry) => `${entry.label} · ${entry.verseCount} verses`);
}
function closeLexiconEntry() {
dismissLexiconEntry();
}
function clearLexiconOccurrenceState() {
state.lexiconOccurrenceResults = null;
state.lexiconOccurrenceLoading = false;
state.lexiconOccurrenceError = "";
state.lexiconOccurrenceVisible = false;
state.lexiconOccurrenceRequestId += 1;
}
function dismissLexiconEntry(options = {}) {
const shouldRestoreFocus = options.restoreFocus !== false;
state.lexiconRequestId += 1;
state.lexiconEntry = null;
clearLexiconOccurrenceState();
renderLexiconPopup();
const returnFocusEl = lexiconReturnFocusEl;
lexiconReturnFocusEl = null;
if (shouldRestoreFocus && returnFocusEl instanceof HTMLElement && returnFocusEl.isConnected) {
requestAnimationFrame(() => {
if (returnFocusEl.isConnected) {
returnFocusEl.focus();
}
});
}
}
async function toggleLexiconOccurrences() {
const lexiconId = state.lexiconEntry?.lexicon?.id || state.lexiconEntry?.lexiconId || "";
const entryId = state.lexiconEntry?.entryId || "";
if (!lexiconId || !entryId) {
return;
}
if (state.lexiconOccurrenceVisible && !state.lexiconOccurrenceLoading) {
state.lexiconOccurrenceVisible = false;
renderLexiconPopup();
return;
}
state.lexiconOccurrenceVisible = true;
if (state.lexiconOccurrenceResults || state.lexiconOccurrenceError) {
renderLexiconPopup();
return;
}
const requestId = state.lexiconOccurrenceRequestId + 1;
state.lexiconOccurrenceRequestId = requestId;
state.lexiconOccurrenceLoading = true;
state.lexiconOccurrenceError = "";
renderLexiconPopup();
try {
const payload = await dataService.loadTextLexiconOccurrences?.(lexiconId, entryId, { limit: 100 });
if (requestId !== state.lexiconOccurrenceRequestId) {
return;
}
state.lexiconOccurrenceResults = payload;
state.lexiconOccurrenceLoading = false;
renderLexiconPopup();
} catch (error) {
if (requestId !== state.lexiconOccurrenceRequestId) {
return;
}
state.lexiconOccurrenceLoading = false;
state.lexiconOccurrenceError = error?.message || "Unable to load verse occurrences for this Strong's entry.";
renderLexiconPopup();
}
}
async function openLexiconOccurrence(result) {
dismissLexiconEntry({ restoreFocus: false });
await openSearchResult(result);
}
function appendLexiconOccurrencePreview(target, result) {
if (!(target instanceof HTMLElement)) {
return;
}
target.replaceChildren();
const previewTokens = Array.isArray(result?.previewTokens) ? result.previewTokens : [];
if (!previewTokens.length) {
target.textContent = result?.preview || result?.reference || "";
return;
}
previewTokens.forEach((token, index) => {
const text = String(token?.text || "").trim();
if (!text) {
return;
}
const previousText = String(previewTokens[index - 1]?.text || "").trim();
if (index > 0 && text !== "..." && previousText !== "...") {
target.appendChild(document.createTextNode(" "));
}
if (token?.isMatch) {
const mark = document.createElement("mark");
mark.className = "alpha-text-mark alpha-text-mark--lexicon";
mark.textContent = text;
target.appendChild(mark);
return;
}
target.appendChild(document.createTextNode(text));
});
}
function renderLexiconPopup() {
ensureLexiconPopup();
if (!(lexiconPopupEl instanceof HTMLElement) || !(lexiconPopupBodyEl instanceof HTMLElement)) {
return;
}
const payload = state.lexiconEntry;
const wasHidden = lexiconPopupEl.hidden;
if (!payload) {
lexiconPopupEl.hidden = true;
lexiconPopupEl.setAttribute("aria-hidden", "true");
lexiconPopupTitleEl.textContent = "Lexicon Entry";
lexiconPopupSubtitleEl.textContent = "Strong's definition";
lexiconPopupBodyEl.replaceChildren();
return;
}
lexiconPopupTitleEl.textContent = payload.entryId ? `Strong's ${payload.entryId}` : "Lexicon Entry";
lexiconPopupSubtitleEl.textContent = payload.loading
? "Loading definition..."
: "Strong's definition";
lexiconPopupBodyEl.replaceChildren();
if (payload.loading) {
lexiconPopupBodyEl.appendChild(createEmptyMessage(`Loading ${payload.entryId}...`));
} else if (payload.error) {
lexiconPopupBodyEl.appendChild(createEmptyMessage(payload.error));
} else {
const entry = payload.entry || {};
const head = document.createElement("div");
head.className = "alpha-text-lexicon-head";
const idPill = document.createElement("button");
idPill.type = "button";
idPill.className = "alpha-text-lexicon-id alpha-text-lexicon-id--button";
idPill.textContent = payload.entryId || "--";
idPill.setAttribute("aria-expanded", state.lexiconOccurrenceVisible ? "true" : "false");
idPill.addEventListener("click", () => {
void toggleLexiconOccurrences();
});
head.appendChild(idPill);
if (entry.lemma) {
const lemma = document.createElement("span");
lemma.className = "alpha-text-token-original";
lemma.textContent = entry.lemma;
head.appendChild(lemma);
}
lexiconPopupBodyEl.appendChild(head);
const rows = [
["Transliteration", entry.xlit],
["Pronunciation", entry.pron],
["Derivation", entry.derivation],
["Strong's Definition", entry.strongs_def],
["KJV Definition", entry.kjv_def]
].filter(([, value]) => String(value || "").trim());
if (rows.length) {
const dl = document.createElement("dl");
dl.className = "alpha-dl";
rows.forEach(([label, value]) => {
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = String(value || "").trim();
dl.append(dt, dd);
});
lexiconPopupBodyEl.appendChild(dl);
}
const occurrenceHint = document.createElement("p");
occurrenceHint.className = "alpha-text-lexicon-hint";
occurrenceHint.textContent = "Click the Strong's number to show verses that use this entry.";
lexiconPopupBodyEl.appendChild(occurrenceHint);
if (state.lexiconOccurrenceVisible) {
const occurrenceSection = document.createElement("section");
occurrenceSection.className = "alpha-text-lexicon-occurrences";
const occurrenceTitle = document.createElement("strong");
occurrenceTitle.textContent = "Verse Occurrences";
occurrenceSection.appendChild(occurrenceTitle);
if (state.lexiconOccurrenceLoading) {
occurrenceSection.appendChild(createEmptyMessage(`Loading verses for ${payload.entryId}...`));
} else if (state.lexiconOccurrenceError) {
occurrenceSection.appendChild(createEmptyMessage(state.lexiconOccurrenceError));
} else {
const occurrencePayload = state.lexiconOccurrenceResults;
const totalMatches = Number(occurrencePayload?.totalMatches) || 0;
const summary = document.createElement("p");
summary.className = "alpha-text-search-summary";
summary.textContent = totalMatches
? `${totalMatches} verses use ${payload.entryId}.${occurrencePayload?.truncated ? ` Showing the first ${occurrencePayload.resultCount} results.` : ""}`
: `No verses found for ${payload.entryId}.`;
occurrenceSection.appendChild(summary);
if (Array.isArray(occurrencePayload?.results) && occurrencePayload.results.length) {
const occurrenceList = document.createElement("div");
occurrenceList.className = "alpha-text-lexicon-occurrence-list";
occurrencePayload.results.forEach((result) => {
const button = document.createElement("button");
button.type = "button";
button.className = "alpha-text-lexicon-occurrence";
const headRow = document.createElement("div");
headRow.className = "alpha-text-search-result-head";
const reference = document.createElement("span");
reference.className = "alpha-text-search-reference";
reference.textContent = result.reference || `${result.workTitle} ${result.sectionLabel}:${result.verseNumber}`;
const location = document.createElement("span");
location.className = "alpha-text-search-location";
location.textContent = `${result.sourceShortTitle || result.sourceTitle} · ${result.workTitle} · ${result.sectionLabel}`;
const preview = document.createElement("p");
preview.className = "alpha-text-search-preview alpha-text-search-preview--compact";
appendLexiconOccurrencePreview(preview, result);
button.addEventListener("click", () => {
void openLexiconOccurrence(result);
});
headRow.append(reference, location);
button.append(headRow, preview);
occurrenceList.appendChild(button);
});
occurrenceSection.appendChild(occurrenceList);
}
}
lexiconPopupBodyEl.appendChild(occurrenceSection);
}
}
lexiconPopupEl.hidden = false;
lexiconPopupEl.setAttribute("aria-hidden", "false");
if (wasHidden && lexiconPopupCloseEl instanceof HTMLButtonElement) {
requestAnimationFrame(() => {
lexiconPopupCloseEl.focus();
});
}
}
async function loadLexiconEntry(lexiconId, entryId, triggerElement) {
if (!lexiconId || !entryId) {
return;
}
if (triggerElement instanceof HTMLElement) {
lexiconReturnFocusEl = triggerElement;
}
const requestId = state.lexiconRequestId + 1;
state.lexiconRequestId = requestId;
clearLexiconOccurrenceState();
state.lexiconEntry = {
loading: true,
lexiconId,
entryId: String(entryId).toUpperCase()
};
renderDetail();
try {
const payload = await dataService.loadTextLexiconEntry?.(lexiconId, entryId);
if (requestId !== state.lexiconRequestId) {
return;
}
state.lexiconEntry = payload;
renderDetail();
} catch (error) {
if (requestId !== state.lexiconRequestId) {
return;
}
state.lexiconEntry = {
error: error?.message || "Unable to load lexicon entry.",
lexiconId,
entryId: String(entryId).toUpperCase()
};
renderDetail();
}
}
function createMetaGrid(passage) {
const source = passage?.source || getSelectedSource();
const work = passage?.work || getSelectedWork(source);
const section = passage?.section || getSelectedSection(source, work);
const displayPreferences = getSourceDisplayPreferences(source, passage);
const metaGrid = document.createElement("div");
metaGrid.className = "alpha-text-meta-grid";
const overviewCard = createCard("Source Overview");
overviewCard.innerHTML += `
<dl class="alpha-dl">
<dt>Source</dt><dd>${source?.title || "--"}</dd>
<dt>Tradition</dt><dd>${source?.tradition || "--"}</dd>
<dt>Language</dt><dd>${source?.language || "--"}</dd>
<dt>Script</dt><dd>${source?.script || "--"}</dd>
<dt>${source?.workLabel || "Work"}</dt><dd>${work?.title || "--"}</dd>
<dt>${source?.sectionLabel || "Section"}</dt><dd>${section?.label || "--"}</dd>
</dl>
`;
metaGrid.appendChild(overviewCard);
if (displayPreferences.capabilities.hasAnyExtras) {
const extraCard = createCard("Extra");
extraCard.classList.add("alpha-text-extra-card");
if (displayPreferences.availableTextModes.length > 1) {
const displayGroup = document.createElement("div");
displayGroup.className = "alpha-text-extra-group";
const displayLabel = document.createElement("span");
displayLabel.className = "alpha-text-extra-label";
displayLabel.textContent = "Display";
const displayButtons = document.createElement("div");
displayButtons.className = "alpha-nav-btns alpha-text-extra-actions";
displayPreferences.availableTextModes.forEach((mode) => {
const button = document.createElement("button");
button.type = "button";
button.className = "alpha-nav-btn";
button.textContent = formatTextDisplayModeLabel(mode);
button.setAttribute("aria-pressed", displayPreferences.textMode === mode ? "true" : "false");
button.classList.toggle("is-selected", displayPreferences.textMode === mode);
button.addEventListener("click", () => {
updateSourceDisplayPreferences(source, { textMode: mode });
renderDetail();
});
displayButtons.appendChild(button);
});
displayGroup.append(displayLabel, displayButtons);
extraCard.appendChild(displayGroup);
}
if (displayPreferences.capabilities.hasInterlinear) {
const interlinearGroup = document.createElement("div");
interlinearGroup.className = "alpha-text-extra-group";
const interlinearLabel = document.createElement("span");
interlinearLabel.className = "alpha-text-extra-label";
interlinearLabel.textContent = "Reader";
const interlinearButtons = document.createElement("div");
interlinearButtons.className = "alpha-nav-btns alpha-text-extra-actions";
const interlinearButton = document.createElement("button");
interlinearButton.type = "button";
interlinearButton.className = "alpha-nav-btn";
interlinearButton.textContent = "Interlinear";
interlinearButton.setAttribute("aria-pressed", displayPreferences.showInterlinear ? "true" : "false");
interlinearButton.classList.toggle("is-selected", displayPreferences.showInterlinear);
interlinearButton.addEventListener("click", () => {
updateSourceDisplayPreferences(source, { showInterlinear: !displayPreferences.showInterlinear });
renderDetail();
});
interlinearButtons.appendChild(interlinearButton);
interlinearGroup.append(interlinearLabel, interlinearButtons);
extraCard.appendChild(interlinearGroup);
}
metaGrid.appendChild(extraCard);
}
if (source?.features?.hasTokenAnnotations) {
const noteCard = createCard("Reader Mode");
noteCard.appendChild(createEmptyMessage("This source is tokenized. Click a Strong's code chip to open its lexicon entry."));
metaGrid.appendChild(noteCard);
}
return metaGrid;
}
function createPlainVerse(verse) {
const source = getSelectedSource();
const displayPreferences = getSourceDisplayPreferences(source, state.currentPassage);
const article = document.createElement("article");
article.className = "alpha-text-verse";
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
const head = document.createElement("div");
head.className = "alpha-text-verse-head";
const reference = document.createElement("span");
reference.className = "alpha-text-verse-reference";
reference.textContent = verse.reference || (verse.number ? `Verse ${verse.number}` : "");
head.append(reference);
article.append(head);
appendVerseTextLines(article, verse, source, displayPreferences, verse.text || "");
return article;
}
function buildTokenTranslationText(tokens, fallbackText) {
const glossText = (Array.isArray(tokens) ? tokens : [])
.map((token) => String(token?.gloss || "").trim())
.filter(Boolean)
.join(" ")
.replace(/\s+([,.;:!?])/g, "$1")
.trim();
return glossText || String(fallbackText || "").trim();
}
function appendVerseTextLines(target, verse, source, displayPreferences, translationText) {
if (!(target instanceof HTMLElement)) {
return;
}
const mode = displayPreferences?.textMode || "translation";
const originalText = normalizeTextValue(verse?.originalText);
const transliterationText = getVerseTransliteration(verse, source);
const lines = [];
const appendLine = (text, variant) => {
const normalizedText = normalizeTextValue(text);
if (!normalizedText || lines.some((entry) => entry.text === normalizedText)) {
return;
}
lines.push({ text: normalizedText, variant });
};
if (mode === "all") {
appendLine(translationText, "translation");
appendLine(originalText, "original");
appendLine(transliterationText, "transliteration");
} else if (mode === "original") {
appendLine(originalText || translationText, originalText ? "original" : "translation");
} else if (mode === "transliteration") {
appendLine(transliterationText || translationText, transliterationText ? "transliteration" : "translation");
} else {
appendLine(translationText, "translation");
}
if (!lines.length) {
appendLine(translationText, "translation");
}
lines.forEach((line) => {
const text = document.createElement("p");
text.className = `alpha-text-verse-text alpha-text-verse-text--${line.variant}`;
appendHighlightedText(text, line.text, isHighlightedVerse(verse) ? state.searchQuery : "");
target.appendChild(text);
});
}
function createTokenVerse(verse, lexiconId, displayPreferences, source) {
const article = document.createElement("article");
article.className = "alpha-text-verse";
article.classList.toggle("alpha-text-verse--interlinear", Boolean(displayPreferences?.showInterlinear));
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
const head = document.createElement("div");
head.className = "alpha-text-verse-head";
const reference = document.createElement("span");
reference.className = "alpha-text-verse-reference";
reference.textContent = verse.reference || (verse.number ? `Verse ${verse.number}` : "");
const tokenGrid = document.createElement("div");
tokenGrid.className = "alpha-text-token-grid";
(Array.isArray(verse?.tokens) ? verse.tokens : []).forEach((token) => {
const strongId = Array.isArray(token?.strongs) ? token.strongs[0] : "";
const tokenEl = document.createElement(strongId ? "button" : "div");
tokenEl.className = `alpha-text-token${strongId ? " alpha-text-token--interactive" : ""}`;
if (tokenEl instanceof HTMLButtonElement) {
tokenEl.type = "button";
tokenEl.addEventListener("click", () => {
void loadLexiconEntry(lexiconId, strongId, tokenEl);
});
}
const glossEl = document.createElement("span");
glossEl.className = "alpha-text-token-gloss";
glossEl.textContent = token?.gloss || "—";
const originalEl = document.createElement("span");
originalEl.className = "alpha-text-token-original";
originalEl.textContent = token?.original || "—";
tokenEl.append(glossEl, originalEl);
if (strongId) {
const strongsEl = document.createElement("span");
strongsEl.className = "alpha-text-token-strongs";
strongsEl.textContent = Array.isArray(token.strongs) ? token.strongs.join(" · ") : strongId;
tokenEl.appendChild(strongsEl);
}
tokenGrid.appendChild(tokenEl);
});
head.append(reference);
article.append(head);
appendVerseTextLines(article, verse, source, displayPreferences, buildTokenTranslationText(verse?.tokens, verse?.text));
if (displayPreferences?.showInterlinear) {
article.appendChild(tokenGrid);
}
return article;
}
function createReaderCard(passage) {
const source = passage?.source || getSelectedSource();
const displayPreferences = getSourceDisplayPreferences(source, passage);
const card = createCard(getPassageLocationLabel(passage));
card.classList.add("alpha-text-reader-card");
const reader = document.createElement("div");
reader.className = "alpha-text-reader";
if (passage?.errorMessage) {
reader.appendChild(createEmptyMessage(passage.errorMessage));
card.appendChild(reader);
return card;
}
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
if (!verses.length) {
reader.appendChild(createEmptyMessage("No verses were found for this section."));
card.appendChild(reader);
return card;
}
verses.forEach((verse) => {
const verseEl = source?.features?.hasTokenAnnotations
? createTokenVerse(verse, source.features.lexiconIds?.[0] || "", displayPreferences, source)
: createPlainVerse(verse);
reader.appendChild(verseEl);
});
card.appendChild(reader);
const navigation = document.createElement("div");
navigation.className = "alpha-text-reader-navigation";
if (passage?.navigation?.previous) {
const previousButton = document.createElement("button");
previousButton.type = "button";
previousButton.className = "alpha-nav-btn alpha-text-reader-nav-btn";
previousButton.textContent = "← Previous";
previousButton.addEventListener("click", () => {
navigateToPassageTarget(passage.navigation.previous);
});
navigation.appendChild(previousButton);
}
if (passage?.navigation?.next) {
const nextButton = document.createElement("button");
nextButton.type = "button";
nextButton.className = "alpha-nav-btn alpha-text-reader-nav-btn alpha-text-reader-nav-btn--next";
nextButton.textContent = "Next →";
nextButton.addEventListener("click", () => {
navigateToPassageTarget(passage.navigation.next);
});
navigation.appendChild(nextButton);
}
if (navigation.childElementCount) {
card.appendChild(navigation);
}
return card;
}
function createSearchCard() {
const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery;
if (!hasSearchState) {
return null;
}
const card = createCard("Search Results");
const scopeLabel = state.activeSearchScope === "global"
? "all texts"
: (state.searchResults?.scope?.source?.title || getSelectedSource()?.title || "current source");
const summary = document.createElement("p");
summary.className = "alpha-text-search-summary";
if (state.searchLoading) {
summary.textContent = `Searching ${scopeLabel} for \"${state.searchQuery}\"...`;
card.appendChild(summary);
return card;
}
if (state.searchError) {
summary.textContent = `Search scope: ${scopeLabel}`;
card.append(summary, createEmptyMessage(state.searchError));
return card;
}
const payload = state.searchResults;
const totalMatches = Number(payload?.totalMatches) || 0;
const truncatedNote = payload?.truncated ? ` Showing the first ${payload.resultCount} results.` : "";
summary.textContent = `${totalMatches} matches in ${scopeLabel}.${truncatedNote}`;
card.appendChild(summary);
if (!Array.isArray(payload?.results) || !payload.results.length) {
card.appendChild(createEmptyMessage(`No matches found for \"${state.searchQuery}\".`));
return card;
}
const resultsEl = document.createElement("div");
resultsEl.className = "alpha-text-search-results";
payload.results.forEach((result) => {
const button = document.createElement("button");
button.type = "button";
button.className = "alpha-text-search-result";
button.classList.toggle(
"is-active",
normalizeId(result?.sourceId) === normalizeId(state.selectedSourceId)
&& normalizeId(result?.workId) === normalizeId(state.selectedWorkId)
&& normalizeId(result?.sectionId) === normalizeId(state.selectedSectionId)
&& normalizeId(result?.verseId) === normalizeId(state.highlightedVerseId)
);
const head = document.createElement("div");
head.className = "alpha-text-search-result-head";
const reference = document.createElement("span");
reference.className = "alpha-text-search-reference";
reference.textContent = result.reference || `${result.workTitle} ${result.sectionLabel}:${result.verseNumber}`;
const location = document.createElement("span");
location.className = "alpha-text-search-location";
location.textContent = state.activeSearchScope === "global"
? `${result.sourceShortTitle || result.sourceTitle} · ${result.workTitle} · ${result.sectionLabel}`
: `${result.workTitle} · ${result.sectionLabel}`;
const preview = document.createElement("p");
preview.className = "alpha-text-search-preview";
appendHighlightedText(preview, result.preview || result.reference || "", state.searchQuery);
button.addEventListener("click", () => {
void openSearchResult(result);
});
head.append(reference, location);
button.append(head, preview);
resultsEl.appendChild(button);
});
card.appendChild(resultsEl);
return card;
}
function isGlobalSearchOnlyMode() {
return state.activeSearchScope === "global"
&& Boolean(state.searchQuery)
&& !state.highlightedVerseId;
}
function renderDetail() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
if (!source || !work || !section) {
renderPlaceholder("Text Reader", "Select a source to begin", "Choose a text source and section from the left panel.");
renderLexiconPopup();
return;
}
if (detailNameEl) {
detailNameEl.textContent = globalSearchOnlyMode
? `Global Search${state.searchQuery ? `: ${state.searchQuery}` : ""}`
: (state.currentPassage?.section?.title || section.title);
}
if (detailSubEl) {
detailSubEl.textContent = globalSearchOnlyMode
? "All text sources"
: `${source.title} · ${work.title}`;
}
if (!detailBodyEl) {
return;
}
detailBodyEl.replaceChildren();
const searchCard = createSearchCard();
if (searchCard) {
detailBodyEl.appendChild(searchCard);
}
if (globalSearchOnlyMode) {
renderLexiconPopup();
return;
}
if (!state.currentPassage) {
const loadingCard = createCard("Text Reader");
loadingCard.appendChild(createEmptyMessage("Loading section…"));
detailBodyEl.appendChild(loadingCard);
renderLexiconPopup();
return;
}
detailBodyEl.appendChild(createMetaGrid(state.currentPassage));
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
renderLexiconPopup();
}
async function loadSelectedPassage() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
if (!source || !work || !section) {
state.currentPassage = null;
renderDetail();
return;
}
state.currentPassage = null;
renderDetail();
try {
state.currentPassage = await dataService.loadTextSection?.(source.id, work.id, section.id);
renderDetail();
if (state.highlightedVerseId) {
requestAnimationFrame(scrollHighlightedVerseIntoView);
}
} catch (error) {
state.currentPassage = {
source,
work,
section,
verses: [],
errorMessage: error?.message || "Unable to load this section."
};
renderDetail();
}
}
async function runSearch(scope, forceRefresh = false) {
const searchFn = dataService.searchTextLibrary;
if (typeof searchFn !== "function") {
state.searchError = "Text search is unavailable.";
state.searchLoading = false;
state.searchResults = null;
renderDetail();
return;
}
const normalizedScope = scope === "source" ? "source" : "global";
const query = String(getSearchInput(normalizedScope)?.value || getStoredSearchQuery(normalizedScope) || "").trim();
setStoredSearchQuery(normalizedScope, query);
state.activeSearchScope = normalizedScope;
state.searchQuery = query;
state.searchError = "";
state.searchResults = null;
state.highlightedVerseId = "";
updateSearchControls();
if (!query) {
clearSearchState();
renderDetail();
return;
}
const requestId = state.searchRequestId + 1;
state.searchRequestId = requestId;
state.searchLoading = true;
renderDetail();
try {
const payload = await searchFn(query, {
sourceId: normalizedScope === "source" ? state.selectedSourceId : "",
limit: 50
}, forceRefresh);
if (requestId !== state.searchRequestId) {
return;
}
state.searchResults = payload;
state.searchLoading = false;
renderDetail();
} catch (error) {
if (requestId !== state.searchRequestId) {
return;
}
state.searchLoading = false;
state.searchError = error?.message || "Unable to search this text library.";
renderDetail();
}
}
async function openSearchResult(result) {
if (!result) {
return;
}
state.selectedSourceId = result.sourceId;
state.selectedWorkId = result.workId;
state.selectedSectionId = result.sectionId;
state.highlightedVerseId = result.verseId;
dismissLexiconEntry({ restoreFocus: false });
syncSelectionForSource(getSelectedSource());
renderSourceList();
renderSelectors();
showDetailOnlyMode();
await loadSelectedPassage();
clearActiveSearchUi({ preserveHighlight: true });
renderDetail();
}
function bindControls() {
if (state.initialized) {
return;
}
if (globalSearchFormEl instanceof HTMLFormElement) {
globalSearchFormEl.addEventListener("submit", (event) => {
event.preventDefault();
void runSearch("global");
});
}
if (globalSearchInputEl instanceof HTMLInputElement) {
globalSearchInputEl.addEventListener("input", () => {
state.globalSearchQuery = String(globalSearchInputEl.value || "").trim();
updateSearchControls();
if (!state.globalSearchQuery && state.activeSearchScope === "global" && state.searchQuery) {
clearSearchState();
renderDetail();
}
});
}
if (localSearchFormEl instanceof HTMLFormElement) {
localSearchFormEl.addEventListener("submit", (event) => {
event.preventDefault();
void runSearch("source");
});
}
if (localSearchInputEl instanceof HTMLInputElement) {
localSearchInputEl.addEventListener("input", () => {
state.localSearchQuery = String(localSearchInputEl.value || "").trim();
updateSearchControls();
if (!state.localSearchQuery && state.activeSearchScope === "source" && state.searchQuery) {
clearSearchState();
renderDetail();
}
});
}
if (workSelectEl) {
workSelectEl.addEventListener("change", () => {
state.selectedWorkId = String(workSelectEl.value || "");
const source = getSelectedSource();
syncSelectionForSource(source);
state.currentPassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
renderSelectors();
void loadSelectedPassage();
});
}
if (sectionSelectEl) {
sectionSelectEl.addEventListener("change", () => {
state.selectedSectionId = String(sectionSelectEl.value || "");
state.currentPassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
void loadSelectedPassage();
});
}
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.lexiconEntry) {
closeLexiconEntry();
}
});
state.initialized = true;
}
async function ensureAlphabetTextSection() {
getElements();
bindControls();
window.TarotChromeUi?.initializeSidebarPopouts?.();
window.TarotChromeUi?.initializeDetailPopouts?.();
if (!sourceListEl || !detailBodyEl) {
return;
}
await ensureCatalogLoaded();
renderSourceList();
renderSelectors();
updateSearchControls();
if (!state.currentPassage) {
await loadSelectedPassage();
return;
}
renderDetail();
}
function resetState() {
state.catalog = null;
state.currentPassage = null;
state.lexiconEntry = null;
state.selectedSourceId = "";
state.selectedWorkId = "";
state.selectedSectionId = "";
state.lexiconRequestId = 0;
state.lexiconOccurrenceResults = null;
state.lexiconOccurrenceLoading = false;
state.lexiconOccurrenceError = "";
state.lexiconOccurrenceVisible = false;
state.lexiconOccurrenceRequestId = 0;
state.globalSearchQuery = "";
state.localSearchQuery = "";
state.activeSearchScope = "global";
state.searchQuery = "";
state.searchResults = null;
state.searchLoading = false;
state.searchError = "";
state.searchRequestId = 0;
state.highlightedVerseId = "";
lexiconReturnFocusEl = null;
if (globalSearchInputEl instanceof HTMLInputElement) {
globalSearchInputEl.value = "";
}
if (localSearchInputEl instanceof HTMLInputElement) {
localSearchInputEl.value = "";
}
updateSearchControls();
renderLexiconPopup();
}
document.addEventListener("connection:updated", resetState);
window.AlphabetTextUi = {
ensureAlphabetTextSection
};
})();