Files
TaroTime/app/ui-alphabet-text.js
2026-03-15 16:20:43 -07:00

2321 lines
79 KiB
JavaScript
Raw Permalink 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 STORAGE_KEYS = {
showVerseHeads: "tarotime.alphaText.showVerseHeads"
};
function readStoredBoolean(key, fallback) {
try {
const value = window.localStorage?.getItem?.(key);
if (value === "true") {
return true;
}
if (value === "false") {
return false;
}
} catch (error) {
// Ignore storage failures and keep in-memory defaults.
}
return fallback;
}
function writeStoredBoolean(key, value) {
try {
window.localStorage?.setItem?.(key, value ? "true" : "false");
} catch (error) {
// Ignore storage failures and keep in-memory state.
}
}
const state = {
initialized: false,
catalog: null,
selectedSourceGroupId: "",
selectedSourceId: "",
selectedSourceIdByGroup: {},
compareSourceIdByGroup: {},
compareModeByGroup: {},
selectedWorkId: "",
selectedSectionId: "",
currentPassage: null,
comparePassage: 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: {},
showVerseHeads: readStoredBoolean(STORAGE_KEYS.showVerseHeads, true)
};
let sourceListEl;
let sourceCountEl;
let globalSearchFormEl;
let globalSearchInputEl;
let globalSearchClearEl;
let localSearchFormEl;
let localSearchInputEl;
let localSearchClearEl;
let translationSelectEl;
let translationControlEl;
let compareSelectEl;
let compareControlEl;
let compareToggleEl;
let compareToggleControlEl;
let workSelectEl;
let sectionSelectEl;
let detailHeadingEl;
let detailNameEl;
let detailSubEl;
let detailHeadingToolsEl;
let detailBodyEl;
let textLayoutEl;
let showVerseHeadsEl;
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");
globalSearchClearEl = document.getElementById("alpha-text-global-search-clear");
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
localSearchClearEl = document.getElementById("alpha-text-local-search-clear");
showVerseHeadsEl = document.getElementById("alpha-text-show-verse-heads");
translationSelectEl = document.getElementById("alpha-text-translation-select");
translationControlEl = translationSelectEl?.closest?.(".alpha-text-control") || null;
compareSelectEl = document.getElementById("alpha-text-compare-select");
compareControlEl = compareSelectEl?.closest?.(".alpha-text-control") || null;
compareToggleEl = document.getElementById("alpha-text-compare-toggle");
compareToggleControlEl = document.getElementById("alpha-text-compare-toggle-control");
workSelectEl = document.getElementById("alpha-text-work-select");
sectionSelectEl = document.getElementById("alpha-text-section-select");
detailHeadingEl = document.querySelector("#alphabet-text-section .alpha-text-detail-heading");
detailNameEl = document.getElementById("alpha-text-detail-name");
detailSubEl = document.getElementById("alpha-text-detail-sub");
detailHeadingToolsEl = document.querySelector("#alphabet-text-section .alpha-text-heading-tools");
detailBodyEl = document.getElementById("alpha-text-detail-body");
textLayoutEl = sourceListEl?.closest?.(".planet-layout") || detailBodyEl?.closest?.(".planet-layout") || null;
syncReaderDisplayControls();
ensureLexiconPopup();
}
function syncReaderDisplayControls() {
if (showVerseHeadsEl instanceof HTMLInputElement) {
showVerseHeadsEl.checked = Boolean(state.showVerseHeads);
}
if (textLayoutEl instanceof HTMLElement) {
textLayoutEl.classList.toggle("alpha-text-hide-verse-heads", !state.showVerseHeads);
textLayoutEl.setAttribute("data-show-verse-heads", state.showVerseHeads ? "true" : "false");
}
}
function setGlobalSearchHeadingMode(isGlobalSearchOnly) {
if (textLayoutEl instanceof HTMLElement) {
textLayoutEl.classList.toggle("alpha-text-global-search-only", Boolean(isGlobalSearchOnly));
textLayoutEl.setAttribute("data-global-search-only", isGlobalSearchOnly ? "true" : "false");
}
if (detailHeadingEl instanceof HTMLElement) {
detailHeadingEl.hidden = Boolean(isGlobalSearchOnly);
detailHeadingEl.setAttribute("aria-hidden", isGlobalSearchOnly ? "true" : "false");
}
if (!(detailHeadingToolsEl instanceof HTMLElement)) {
return;
}
detailHeadingToolsEl.hidden = Boolean(isGlobalSearchOnly);
detailHeadingToolsEl.setAttribute("aria-hidden", isGlobalSearchOnly ? "true" : "false");
}
function showDetailOnlyMode() {
if (!(textLayoutEl instanceof HTMLElement)) {
return;
}
window.TarotChromeUi?.initializeSidebarPopouts?.();
window.TarotChromeUi?.initializeDetailPopouts?.();
window.TarotChromeUi?.initializeSidebarAutoCollapse?.();
window.TarotChromeUi?.showDetailOnly?.(textLayoutEl);
}
function showSidebarOnlyMode(persist = false) {
if (!(textLayoutEl instanceof HTMLElement)) {
return;
}
window.TarotChromeUi?.initializeSidebarPopouts?.();
window.TarotChromeUi?.initializeDetailPopouts?.();
window.TarotChromeUi?.showSidebarOnly?.(textLayoutEl, persist);
}
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 getSourceGroupId(source) {
const metadata = getSourceMetadata(source);
return normalizeId(metadata.workKey || source?.id || source?.title);
}
function buildSourceGroups(sources) {
const groupsById = new Map();
(Array.isArray(sources) ? sources : []).forEach((source, index) => {
const groupId = getSourceGroupId(source) || `source-group-${index + 1}`;
if (!groupsById.has(groupId)) {
groupsById.set(groupId, {
id: groupId,
title: normalizeTextValue(source?.title) || normalizeTextValue(source?.shortTitle) || "Untitled Source",
order: index,
variants: []
});
}
groupsById.get(groupId).variants.push(source);
});
return [...groupsById.values()].sort((left, right) => left.order - right.order);
}
function getSourceGroups() {
return Array.isArray(state.catalog?.sourceGroups) ? state.catalog.sourceGroups : [];
}
function findById(entries, value) {
const needle = normalizeId(value);
return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null;
}
function getSelectedSourceGroup() {
return findById(getSourceGroups(), state.selectedSourceGroupId);
}
function getSourceVariants(group = getSelectedSourceGroup()) {
return Array.isArray(group?.variants) ? group.variants : [];
}
function getSourceForGroup(group = getSelectedSourceGroup(), sourceId = state.selectedSourceId) {
return findById(getSourceVariants(group), sourceId) || getSourceVariants(group)[0] || null;
}
function findSourceGroupBySourceId(sourceId) {
const needle = normalizeId(sourceId);
return getSourceGroups().find((group) => getSourceVariants(group).some((source) => normalizeId(source?.id) === needle)) || null;
}
function rememberSelectedSource(group, sourceId) {
const groupId = normalizeId(group?.id);
const normalizedSourceId = normalizeTextValue(sourceId);
if (!groupId || !normalizedSourceId) {
return;
}
state.selectedSourceIdByGroup[groupId] = normalizedSourceId;
}
function rememberCompareSource(group, sourceId) {
const groupId = normalizeId(group?.id);
const normalizedSourceId = normalizeTextValue(sourceId);
if (!groupId || !normalizedSourceId) {
return;
}
state.compareSourceIdByGroup[groupId] = normalizedSourceId;
}
function isCompareAvailable(group = getSelectedSourceGroup()) {
return getSourceVariants(group).length > 1;
}
function isCompareModeEnabled(group = getSelectedSourceGroup()) {
const groupId = normalizeId(group?.id);
return Boolean(groupId && state.compareModeByGroup[groupId] && isCompareAvailable(group));
}
function setCompareModeEnabled(group, isEnabled) {
const groupId = normalizeId(group?.id);
if (!groupId) {
return;
}
state.compareModeByGroup[groupId] = Boolean(isEnabled);
}
function getCompareCandidates(group = getSelectedSourceGroup()) {
const activeSourceId = normalizeId(state.selectedSourceId);
return getSourceVariants(group).filter((source) => normalizeId(source?.id) !== activeSourceId);
}
function getCompareSource(group = getSelectedSourceGroup()) {
const groupId = normalizeId(group?.id);
const candidates = getCompareCandidates(group);
const rememberedSourceId = groupId ? state.compareSourceIdByGroup[groupId] : "";
return findById(candidates, rememberedSourceId) || candidates[0] || null;
}
function syncCompareSelection(group = getSelectedSourceGroup()) {
const groupId = normalizeId(group?.id);
if (!groupId) {
return;
}
if (!isCompareAvailable(group)) {
delete state.compareSourceIdByGroup[groupId];
delete state.compareModeByGroup[groupId];
return;
}
const compareSource = getCompareSource(group);
if (compareSource?.id) {
rememberCompareSource(group, compareSource.id);
}
}
function getSelectedSource() {
return getSourceForGroup(getSelectedSourceGroup(), state.selectedSourceId)
|| 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();
}
function buildTranslationOptionLabel(source) {
const metadata = getSourceMetadata(source);
return normalizeTextValue(metadata.translator)
|| normalizeTextValue(metadata.versionLabel || metadata.version)
|| normalizeTextValue(source?.shortTitle)
|| normalizeTextValue(source?.title)
|| "Translation";
}
function getSourceMetadata(source) {
return source?.metadata && typeof source.metadata === "object" ? source.metadata : {};
}
function includesNormalizedText(container, value) {
const containerText = normalizeTextValue(container).toLowerCase();
const valueText = normalizeTextValue(value).toLowerCase();
return Boolean(containerText && valueText && containerText.includes(valueText));
}
function formatCountLabel(count, label) {
const normalizedCount = Number(count) || 0;
const baseLabel = normalizeTextValue(label) || "item";
if (normalizedCount === 1) {
return `${normalizedCount} ${baseLabel}`;
}
if (/[^aeiou]y$/i.test(baseLabel)) {
return `${normalizedCount} ${baseLabel.slice(0, -1)}ies`;
}
return `${normalizedCount} ${baseLabel.endsWith("s") ? baseLabel : `${baseLabel}s`}`;
}
function getSourceEditionLabel(source) {
const metadata = getSourceMetadata(source);
const version = normalizeTextValue(metadata.versionLabel || metadata.version);
const translator = normalizeTextValue(metadata.translator);
if (
version
&& translator
&& normalizeId(version) !== normalizeId(translator)
&& !includesNormalizedText(version, translator)
&& !includesNormalizedText(translator, version)
) {
return `${version} · ${translator}`;
}
return version || translator;
}
function buildSourceListMeta(source) {
const shortTitle = normalizeTextValue(source?.shortTitle);
const title = normalizeTextValue(source?.title);
const editionLabel = getSourceEditionLabel(source);
const parts = [];
if (shortTitle && normalizeId(shortTitle) !== normalizeId(title)) {
parts.push(shortTitle);
}
if (editionLabel && !parts.some((part) => includesNormalizedText(part, editionLabel) || includesNormalizedText(editionLabel, part))) {
parts.push(editionLabel);
}
parts.push(formatCountLabel(source?.stats?.workCount, source?.workLabel || "Work"));
parts.push(formatCountLabel(source?.stats?.sectionCount, source?.sectionLabel || "Section"));
return parts.join(" · ");
}
function buildSourceGroupListMeta(group) {
const activeSource = getSourceForGroup(group);
if (!group || getSourceVariants(group).length <= 1) {
return buildSourceListMeta(activeSource);
}
const translators = Array.from(new Set(
getSourceVariants(group)
.map((source) => normalizeTextValue(getSourceMetadata(source).translator))
.filter(Boolean)
));
const parts = [];
if (translators.length) {
parts.push(translators.join(" / "));
}
parts.push(formatCountLabel(getSourceVariants(group).length, "translation"));
parts.push(formatCountLabel(activeSource?.stats?.sectionCount, activeSource?.sectionLabel || "Section"));
return parts.join(" · ");
}
function buildSourceDetailSubtitle(source, work) {
const parts = [normalizeTextValue(source?.title) || "--"];
const editionLabel = getSourceEditionLabel(source);
const workTitle = normalizeTextValue(work?.title);
if (editionLabel) {
parts.push(editionLabel);
}
if (workTitle && normalizeId(workTitle) !== normalizeId(source?.title)) {
parts.push(workTitle);
}
return parts.join(" · ");
}
function buildCompareCardTitle(passage) {
const source = passage?.source || getSelectedSource();
const section = passage?.section || getSelectedSection(source, getSelectedWork(source));
return `${buildTranslationOptionLabel(source)} · ${section?.title || section?.label || "--"}`;
}
function extractVerseCountText(verse, source, displayPreferences, translationText = "") {
const mode = displayPreferences?.textMode || "translation";
const originalText = normalizeTextValue(verse?.originalText);
const transliterationText = getVerseTransliteration(verse, source);
if (mode === "original") {
return originalText || normalizeTextValue(translationText);
}
if (mode === "transliteration") {
return transliterationText || normalizeTextValue(translationText);
}
return normalizeTextValue(translationText)
|| originalText
|| transliterationText;
}
function getTextCounts(value) {
const normalized = String(value || "")
.normalize("NFD")
.replace(/[\u0300-\u036f]/g, "");
const words = normalized.match(/[\p{L}\p{N}]+(?:['-][\p{L}\p{N}]+)*/gu) || [];
const letters = normalized.match(/\p{L}/gu) || [];
const vowels = normalized.match(/[AEIOUYaeiouy]/g) || [];
const consonants = letters.length - vowels.length;
return {
words: words.length,
letters: letters.length,
consonants: Math.max(0, consonants),
vowels: vowels.length
};
}
function formatCountSummary(counts) {
return `W:${counts.words} L:${counts.letters} C:${counts.consonants} V:${counts.vowels}`;
}
function sumPassageCounts(passage, source, displayPreferences) {
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
return verses.reduce((totals, verse) => {
const translationText = source?.features?.hasTokenAnnotations
? buildTokenTranslationText(verse?.tokens, verse?.text)
: verse?.text;
const counts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
totals.words += counts.words;
totals.letters += counts.letters;
totals.consonants += counts.consonants;
totals.vowels += counts.vowels;
return totals;
}, {
words: 0,
letters: 0,
consonants: 0,
vowels: 0
});
}
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() {
const globalQuery = String(globalSearchInputEl?.value || state.globalSearchQuery || "").trim();
const localQuery = String(localSearchInputEl?.value || state.localSearchQuery || "").trim();
const hasGlobalSearch = Boolean(globalQuery) || (state.activeSearchScope === "global" && Boolean(state.searchQuery));
const hasLocalSearch = Boolean(localQuery) || (state.activeSearchScope === "source" && Boolean(state.searchQuery));
if (globalSearchClearEl instanceof HTMLButtonElement) {
globalSearchClearEl.disabled = !hasGlobalSearch;
}
if (localSearchClearEl instanceof HTMLButtonElement) {
localSearchClearEl.disabled = !hasLocalSearch;
}
}
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 buildWholeWordMatcher(query, flags = "iu") {
const normalizedQuery = String(query || "").trim();
if (!normalizedQuery) {
return null;
}
return new RegExp(`(^|[^\\p{L}\\p{N}])(${escapeRegExp(normalizedQuery)})(?=$|[^\\p{L}\\p{N}])`, flags);
}
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 = buildWholeWordMatcher(normalizedQuery, "giu");
if (!matcher) {
target.textContent = sourceText;
return;
}
let lastIndex = 0;
let match = matcher.exec(sourceText);
while (match) {
const prefixLength = String(match[1] || "").length;
const matchedText = String(match[2] || "");
const matchStart = match.index + prefixLength;
const matchEnd = matchStart + matchedText.length;
if (matchStart > lastIndex) {
target.appendChild(document.createTextNode(sourceText.slice(lastIndex, matchStart)));
}
const mark = document.createElement("mark");
mark.className = "alpha-text-mark";
mark.textContent = sourceText.slice(matchStart, matchEnd);
target.appendChild(mark);
lastIndex = matchEnd;
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 || "";
}
}
function syncSelectionForGroup(group = getSelectedSourceGroup()) {
const variants = getSourceVariants(group);
if (!variants.length) {
state.selectedSourceGroupId = "";
state.selectedSourceId = "";
state.selectedWorkId = "";
state.selectedSectionId = "";
return;
}
state.selectedSourceGroupId = group.id;
const rememberedSourceId = state.selectedSourceIdByGroup[normalizeId(group.id)] || "";
const source = findById(variants, state.selectedSourceId)
|| findById(variants, rememberedSourceId)
|| variants[0];
state.selectedSourceId = source?.id || "";
rememberSelectedSource(group, state.selectedSourceId);
syncSelectionForSource(source);
syncCompareSelection(group);
}
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: [] };
state.catalog.sourceGroups = buildSourceGroups(getSources());
if (!state.selectedSourceGroupId && state.selectedSourceId) {
state.selectedSourceGroupId = findSourceGroupBySourceId(state.selectedSourceId)?.id || "";
}
if (!state.selectedSourceGroupId) {
state.selectedSourceGroupId = getSourceGroups()[0]?.id || "";
}
syncSelectionForGroup(getSelectedSourceGroup());
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 sourceGroups = getSourceGroups();
sourceGroups.forEach((group) => {
const source = getSourceForGroup(group);
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item alpha-text-source-btn";
button.dataset.sourceGroupId = group.id;
button.setAttribute("role", "option");
const isSelected = normalizeId(group.id) === normalizeId(state.selectedSourceGroupId);
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 = group.title;
const meta = document.createElement("span");
meta.className = "alpha-text-source-meta";
meta.textContent = buildSourceGroupListMeta(group);
button.append(name, meta);
button.addEventListener("click", () => {
if (normalizeId(group.id) === normalizeId(state.selectedSourceGroupId)) {
showDetailOnlyMode();
return;
}
state.selectedSourceGroupId = group.id;
state.currentPassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
syncSelectionForGroup(group);
renderSourceList();
renderSelectors();
showDetailOnlyMode();
if (state.searchQuery && state.activeSearchScope === "source") {
void Promise.all([loadSelectedPassage(), runSearch("source")]);
return;
}
void loadSelectedPassage();
});
sourceListEl.appendChild(button);
});
if (!sourceGroups.length) {
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
}
if (sourceCountEl) {
sourceCountEl.textContent = `${sourceGroups.length} sources`;
}
}
function renderSelectors() {
const group = getSelectedSourceGroup();
const source = getSelectedSource();
const work = getSelectedWork(source);
const variants = getSourceVariants(group);
const compareCandidates = getCompareCandidates(group);
const compareSource = getCompareSource(group);
const compareEnabled = isCompareModeEnabled(group);
const works = Array.isArray(source?.works) ? source.works : [];
const sections = Array.isArray(work?.sections) ? work.sections : [];
fillSelect(translationSelectEl, variants, state.selectedSourceId, (entry) => buildTranslationOptionLabel(entry));
fillSelect(compareSelectEl, compareCandidates, compareSource?.id || "", (entry) => buildTranslationOptionLabel(entry));
fillSelect(workSelectEl, works, state.selectedWorkId, (entry) => `${entry.title} (${formatCountLabel(entry.sectionCount, String(source?.sectionLabel || "section").toLowerCase())})`);
fillSelect(sectionSelectEl, sections, state.selectedSectionId, (entry) => `${entry.label} · ${entry.verseCount} verses`);
if (translationSelectEl instanceof HTMLSelectElement) {
translationSelectEl.disabled = variants.length <= 1;
}
if (translationControlEl instanceof HTMLElement) {
translationControlEl.hidden = variants.length <= 1;
}
if (compareToggleEl instanceof HTMLButtonElement) {
compareToggleEl.textContent = compareEnabled ? "On" : "Off";
compareToggleEl.setAttribute("aria-pressed", compareEnabled ? "true" : "false");
compareToggleEl.classList.toggle("is-selected", compareEnabled);
}
if (compareToggleControlEl instanceof HTMLElement) {
compareToggleControlEl.hidden = !isCompareAvailable(group);
}
if (compareSelectEl instanceof HTMLSelectElement) {
compareSelectEl.disabled = !compareEnabled || compareCandidates.length === 0;
}
if (compareControlEl instanceof HTMLElement) {
compareControlEl.hidden = !compareEnabled || compareCandidates.length === 0;
}
}
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 sourceGroup = getSelectedSourceGroup();
const source = passage?.source || getSelectedSource();
const work = passage?.work || getSelectedWork(source);
const section = passage?.section || getSelectedSection(source, work);
const metadata = getSourceMetadata(source);
const version = normalizeTextValue(metadata.versionLabel || metadata.version);
const translator = normalizeTextValue(metadata.translator);
const compareSource = getCompareSource(sourceGroup);
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>
${version ? `<dt>Version</dt><dd>${version}</dd>` : ""}
${translator ? `<dt>Translator</dt><dd>${translator}</dd>` : ""}
${getSourceVariants(sourceGroup).length > 1 ? `<dt>Translations</dt><dd>${getSourceVariants(sourceGroup).map((entry) => buildTranslationOptionLabel(entry)).join(" / ")}</dd>` : ""}
${isCompareModeEnabled(sourceGroup) && compareSource ? `<dt>Compare</dt><dd>${buildTranslationOptionLabel(compareSource)}</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);
const totalsCard = createCard("Entry Totals");
const totals = sumPassageCounts(passage, source, displayPreferences);
totalsCard.innerHTML += `
<dl class="alpha-dl">
<dt>Words</dt><dd>${totals.words}</dd>
<dt>Letters</dt><dd>${totals.letters}</dd>
<dt>Consonants</dt><dd>${totals.consonants}</dd>
<dt>Vowels</dt><dd>${totals.vowels}</dd>
</dl>
`;
metaGrid.appendChild(totalsCard);
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, source, displayPreferences, options = {}) {
const translationText = verse.text || "";
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
const article = document.createElement("article");
article.className = "alpha-text-verse";
article.classList.toggle("is-highlighted", isHighlighted);
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 stats = document.createElement("span");
stats.className = "alpha-text-verse-counts";
stats.textContent = formatCountSummary(verseCounts);
head.append(reference, stats);
article.append(head);
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
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, highlightQuery = "") {
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, highlightQuery);
target.appendChild(text);
});
}
function createTokenVerse(verse, lexiconId, displayPreferences, source, options = {}) {
const translationText = buildTokenTranslationText(verse?.tokens, verse?.text);
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
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", isHighlighted);
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 stats = document.createElement("span");
stats.className = "alpha-text-verse-counts";
stats.textContent = formatCountSummary(verseCounts);
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, stats);
article.append(head);
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
if (displayPreferences?.showInterlinear) {
article.appendChild(tokenGrid);
}
return article;
}
function createReaderNavigation(passage) {
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);
}
return navigation.childElementCount ? navigation : null;
}
function createReaderCard(passage, options = {}) {
const source = passage?.source || getSelectedSource();
const displayPreferences = getSourceDisplayPreferences(source, passage);
const card = createCard(options.title || getPassageLocationLabel(passage));
card.classList.add("alpha-text-reader-card");
if (options.compare) {
card.classList.add("alpha-text-reader-card--compare");
}
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, options)
: createPlainVerse(verse, source, displayPreferences, options);
reader.appendChild(verseEl);
});
card.appendChild(reader);
const navigation = options.showNavigation === false ? null : createReaderNavigation(passage);
if (navigation) {
card.appendChild(navigation);
}
return card;
}
function createCompareReaderGrid(primaryPassage, comparePassage) {
const wrapper = document.createElement("div");
wrapper.className = "alpha-text-reader-compare";
wrapper.appendChild(createReaderCard(primaryPassage, {
title: buildCompareCardTitle(primaryPassage),
showNavigation: false
}));
if (comparePassage) {
wrapper.appendChild(createReaderCard(comparePassage, {
title: buildCompareCardTitle(comparePassage),
compare: true,
highlight: false,
showNavigation: false
}));
}
return wrapper;
}
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";
const actions = document.createElement("div");
actions.className = "alpha-text-search-actions";
const closeButton = document.createElement("button");
closeButton.type = "button";
closeButton.className = "alpha-nav-btn";
closeButton.textContent = "Close Search";
closeButton.addEventListener("click", () => {
clearScopedSearch(state.activeSearchScope === "source" ? "source" : "global");
renderDetail();
});
actions.appendChild(closeButton);
if (state.searchLoading) {
summary.textContent = `Searching ${scopeLabel} for \"${state.searchQuery}\"...`;
card.append(summary, actions);
return card;
}
if (state.searchError) {
summary.textContent = `Search scope: ${scopeLabel}`;
card.append(summary, actions, 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.append(summary, actions);
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" || state.activeSearchScope === "source")
&& Boolean(state.searchQuery)
&& !state.highlightedVerseId;
}
function renderDetail() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
const compareEnabled = isCompareModeEnabled(getSelectedSourceGroup());
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
setGlobalSearchHeadingMode(globalSearchOnlyMode);
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"
: buildSourceDetailSubtitle(source, work);
}
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));
if (compareEnabled && state.comparePassage) {
detailBodyEl.appendChild(createCompareReaderGrid(state.currentPassage, state.comparePassage));
const compareNavigation = createReaderNavigation(state.currentPassage);
if (compareNavigation) {
detailBodyEl.appendChild(compareNavigation);
}
} else {
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
}
renderLexiconPopup();
}
function getComparableWork(source, work) {
const works = Array.isArray(source?.works) ? source.works : [];
return findById(works, work?.id)
|| works.find((entry) => normalizeId(entry?.title) === normalizeId(work?.title))
|| works[0]
|| null;
}
function getComparableSection(work, section) {
const sections = Array.isArray(work?.sections) ? work.sections : [];
return findById(sections, section?.id)
|| sections.find((entry) => Number(entry?.number || 0) === Number(section?.number || 0))
|| sections.find((entry) => normalizeId(entry?.title) === normalizeId(section?.title))
|| sections.find((entry) => normalizeId(entry?.label) === normalizeId(section?.label))
|| sections[0]
|| null;
}
function buildPassageLoadError(source, work, section, message) {
return {
source,
work,
section,
verses: [],
errorMessage: message
};
}
async function loadComparablePassage(compareSource, currentWork, currentSection) {
const compareWork = getComparableWork(compareSource, currentWork);
const compareSection = getComparableSection(compareWork, currentSection);
if (!compareWork || !compareSection) {
return buildPassageLoadError(compareSource, compareWork, compareSection, "Unable to align this comparison section.");
}
try {
return await dataService.loadTextSection?.(compareSource.id, compareWork.id, compareSection.id);
} catch (error) {
return buildPassageLoadError(compareSource, compareWork, compareSection, error?.message || "Unable to load the comparison translation.");
}
}
async function loadSelectedPassage() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
const compareSource = isCompareModeEnabled(getSelectedSourceGroup()) ? getCompareSource() : null;
if (!source || !work || !section) {
state.currentPassage = null;
state.comparePassage = null;
renderDetail();
return;
}
state.currentPassage = null;
state.comparePassage = null;
renderDetail();
const [primaryResult, compareResult] = await Promise.allSettled([
dataService.loadTextSection?.(source.id, work.id, section.id),
compareSource ? loadComparablePassage(compareSource, work, section) : Promise.resolve(null)
]);
if (primaryResult.status === "fulfilled") {
state.currentPassage = primaryResult.value;
} else {
state.currentPassage = buildPassageLoadError(source, work, section, primaryResult.reason?.message || "Unable to load this section.");
}
if (compareResult.status === "fulfilled") {
state.comparePassage = compareResult.value;
} else if (compareSource) {
const compareWork = getComparableWork(compareSource, work);
const compareSection = getComparableSection(compareWork, section);
state.comparePassage = buildPassageLoadError(compareSource, compareWork, compareSection, compareResult.reason?.message || "Unable to load the comparison translation.");
}
renderDetail();
if (state.highlightedVerseId) {
requestAnimationFrame(scrollHighlightedVerseIntoView);
}
}
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;
}
if (normalizedScope === "global" || normalizedScope === "source") {
showDetailOnlyMode();
}
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;
}
const sourceGroup = findSourceGroupBySourceId(result.sourceId);
state.selectedSourceGroupId = sourceGroup?.id || "";
state.selectedSourceId = result.sourceId;
rememberSelectedSource(sourceGroup, 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 (globalSearchClearEl instanceof HTMLButtonElement) {
globalSearchClearEl.addEventListener("click", () => {
clearScopedSearch("global");
renderDetail();
});
}
if (localSearchFormEl instanceof HTMLFormElement) {
localSearchFormEl.addEventListener("submit", (event) => {
event.preventDefault();
void runSearch("source");
});
}
if (localSearchClearEl instanceof HTMLButtonElement) {
localSearchClearEl.addEventListener("click", () => {
clearScopedSearch("source");
renderDetail();
});
}
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 (showVerseHeadsEl instanceof HTMLInputElement) {
showVerseHeadsEl.addEventListener("change", () => {
state.showVerseHeads = Boolean(showVerseHeadsEl.checked);
writeStoredBoolean(STORAGE_KEYS.showVerseHeads, state.showVerseHeads);
syncReaderDisplayControls();
});
}
if (translationSelectEl instanceof HTMLSelectElement) {
translationSelectEl.addEventListener("change", () => {
const sourceGroup = getSelectedSourceGroup();
state.selectedSourceId = String(translationSelectEl.value || "");
rememberSelectedSource(sourceGroup, state.selectedSourceId);
syncSelectionForSource(getSelectedSource());
state.currentPassage = null;
state.comparePassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
syncCompareSelection(sourceGroup);
renderSourceList();
renderSelectors();
showDetailOnlyMode();
if (state.searchQuery && state.activeSearchScope === "source") {
void Promise.all([loadSelectedPassage(), runSearch("source")]);
return;
}
void loadSelectedPassage();
});
}
if (compareToggleEl instanceof HTMLButtonElement) {
compareToggleEl.addEventListener("click", () => {
const sourceGroup = getSelectedSourceGroup();
setCompareModeEnabled(sourceGroup, !isCompareModeEnabled(sourceGroup));
syncCompareSelection(sourceGroup);
state.comparePassage = null;
renderSelectors();
void loadSelectedPassage();
});
}
if (compareSelectEl instanceof HTMLSelectElement) {
compareSelectEl.addEventListener("change", () => {
const sourceGroup = getSelectedSourceGroup();
rememberCompareSource(sourceGroup, String(compareSelectEl.value || ""));
state.comparePassage = null;
renderSelectors();
void loadSelectedPassage();
});
}
if (workSelectEl) {
workSelectEl.addEventListener("change", () => {
state.selectedWorkId = String(workSelectEl.value || "");
const source = getSelectedSource();
syncSelectionForSource(source);
state.currentPassage = null;
state.comparePassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
renderSelectors();
void loadSelectedPassage();
});
}
if (sectionSelectEl) {
sectionSelectEl.addEventListener("change", () => {
state.selectedSectionId = String(sectionSelectEl.value || "");
state.currentPassage = null;
state.comparePassage = 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();
syncReaderDisplayControls();
renderSourceList();
renderSelectors();
updateSearchControls();
showSidebarOnlyMode(false);
if (!state.currentPassage) {
await loadSelectedPassage();
return;
}
renderDetail();
}
function resetState() {
state.catalog = null;
state.currentPassage = null;
state.comparePassage = null;
state.lexiconEntry = null;
state.selectedSourceGroupId = "";
state.selectedSourceId = "";
state.selectedSourceIdByGroup = {};
state.compareSourceIdByGroup = {};
state.compareModeByGroup = {};
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
};
})();