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

2321 lines
79 KiB
JavaScript
Raw Normal View History

2026-03-09 23:27:03 -07:00
(function () {
"use strict";
const dataService = window.TarotDataService || {};
2026-03-15 16:20:43 -07:00
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.
}
}
2026-03-09 23:27:03 -07:00
const state = {
initialized: false,
catalog: null,
2026-03-14 00:45:15 -07:00
selectedSourceGroupId: "",
2026-03-09 23:27:03 -07:00
selectedSourceId: "",
2026-03-14 00:45:15 -07:00
selectedSourceIdByGroup: {},
compareSourceIdByGroup: {},
compareModeByGroup: {},
2026-03-09 23:27:03 -07:00
selectedWorkId: "",
selectedSectionId: "",
currentPassage: null,
2026-03-14 00:45:15 -07:00
comparePassage: null,
2026-03-09 23:27:03 -07:00
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,
2026-03-10 14:55:01 -07:00
highlightedVerseId: "",
2026-03-15 16:20:43 -07:00
displayPreferencesBySource: {},
showVerseHeads: readStoredBoolean(STORAGE_KEYS.showVerseHeads, true)
2026-03-09 23:27:03 -07:00
};
let sourceListEl;
let sourceCountEl;
let globalSearchFormEl;
let globalSearchInputEl;
2026-03-15 16:20:43 -07:00
let globalSearchClearEl;
2026-03-09 23:27:03 -07:00
let localSearchFormEl;
let localSearchInputEl;
2026-03-15 16:20:43 -07:00
let localSearchClearEl;
2026-03-14 00:45:15 -07:00
let translationSelectEl;
let translationControlEl;
let compareSelectEl;
let compareControlEl;
let compareToggleEl;
let compareToggleControlEl;
2026-03-09 23:27:03 -07:00
let workSelectEl;
let sectionSelectEl;
2026-03-12 04:37:26 -07:00
let detailHeadingEl;
2026-03-09 23:27:03 -07:00
let detailNameEl;
let detailSubEl;
2026-03-12 04:37:26 -07:00
let detailHeadingToolsEl;
2026-03-09 23:27:03 -07:00
let detailBodyEl;
2026-03-12 02:35:02 -07:00
let textLayoutEl;
2026-03-15 16:20:43 -07:00
let showVerseHeadsEl;
2026-03-09 23:27:03 -07:00
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");
2026-03-15 16:20:43 -07:00
globalSearchClearEl = document.getElementById("alpha-text-global-search-clear");
2026-03-09 23:27:03 -07:00
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
2026-03-15 16:20:43 -07:00
localSearchClearEl = document.getElementById("alpha-text-local-search-clear");
showVerseHeadsEl = document.getElementById("alpha-text-show-verse-heads");
2026-03-14 00:45:15 -07:00
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");
2026-03-09 23:27:03 -07:00
workSelectEl = document.getElementById("alpha-text-work-select");
sectionSelectEl = document.getElementById("alpha-text-section-select");
2026-03-12 04:37:26 -07:00
detailHeadingEl = document.querySelector("#alphabet-text-section .alpha-text-detail-heading");
2026-03-09 23:27:03 -07:00
detailNameEl = document.getElementById("alpha-text-detail-name");
detailSubEl = document.getElementById("alpha-text-detail-sub");
2026-03-12 04:37:26 -07:00
detailHeadingToolsEl = document.querySelector("#alphabet-text-section .alpha-text-heading-tools");
2026-03-09 23:27:03 -07:00
detailBodyEl = document.getElementById("alpha-text-detail-body");
2026-03-12 02:35:02 -07:00
textLayoutEl = sourceListEl?.closest?.(".planet-layout") || detailBodyEl?.closest?.(".planet-layout") || null;
2026-03-15 16:20:43 -07:00
syncReaderDisplayControls();
2026-03-09 23:27:03 -07:00
ensureLexiconPopup();
}
2026-03-15 16:20:43 -07:00
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");
}
}
2026-03-12 04:37:26 -07:00
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");
}
2026-03-12 02:35:02 -07:00
function showDetailOnlyMode() {
if (!(textLayoutEl instanceof HTMLElement)) {
return;
}
window.TarotChromeUi?.initializeSidebarPopouts?.();
window.TarotChromeUi?.initializeDetailPopouts?.();
window.TarotChromeUi?.initializeSidebarAutoCollapse?.();
window.TarotChromeUi?.showDetailOnly?.(textLayoutEl);
}
2026-03-15 16:20:43 -07:00
function showSidebarOnlyMode(persist = false) {
if (!(textLayoutEl instanceof HTMLElement)) {
return;
}
window.TarotChromeUi?.initializeSidebarPopouts?.();
window.TarotChromeUi?.initializeDetailPopouts?.();
window.TarotChromeUi?.showSidebarOnly?.(textLayoutEl, persist);
}
2026-03-09 23:27:03 -07:00
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 : [];
}
2026-03-14 00:45:15 -07:00
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 : [];
}
2026-03-09 23:27:03 -07:00
function findById(entries, value) {
const needle = normalizeId(value);
return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null;
}
2026-03-14 00:45:15 -07:00
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);
}
}
2026-03-09 23:27:03 -07:00
function getSelectedSource() {
2026-03-14 00:45:15 -07:00
return getSourceForGroup(getSelectedSourceGroup(), state.selectedSourceId)
|| findById(getSources(), state.selectedSourceId);
2026-03-09 23:27:03 -07:00
}
function getSelectedWork(source = getSelectedSource()) {
return findById(source?.works, state.selectedWorkId);
}
function getSelectedSection(source = getSelectedSource(), work = getSelectedWork(source)) {
return findById(work?.sections, state.selectedSectionId);
}
2026-03-10 14:55:01 -07:00
function normalizeTextValue(value) {
return String(value || "").trim();
}
2026-03-14 00:45:15 -07:00
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}`;
}
2026-03-15 16:20:43 -07:00
if (/[^aeiou]y$/i.test(baseLabel)) {
return `${normalizedCount} ${baseLabel.slice(0, -1)}ies`;
}
2026-03-14 00:45:15 -07:00
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
});
}
2026-03-10 14:55:01 -07:00
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 "";
}
2026-03-09 23:27:03 -07:00
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() {
2026-03-15 16:20:43 -07:00
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;
}
2026-03-10 14:55:01 -07:00
}
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;
2026-03-09 23:27:03 -07:00
}
2026-03-10 14:55:01 -07:00
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";
2026-03-09 23:27:03 -07:00
}
}
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, "\\$&");
}
2026-03-15 16:20:43 -07:00
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);
}
2026-03-09 23:27:03 -07:00
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;
}
2026-03-15 16:20:43 -07:00
const matcher = buildWholeWordMatcher(normalizedQuery, "giu");
if (!matcher) {
target.textContent = sourceText;
return;
}
2026-03-09 23:27:03 -07:00
let lastIndex = 0;
let match = matcher.exec(sourceText);
while (match) {
2026-03-15 16:20:43 -07:00
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)));
2026-03-09 23:27:03 -07:00
}
const mark = document.createElement("mark");
mark.className = "alpha-text-mark";
2026-03-15 16:20:43 -07:00
mark.textContent = sourceText.slice(matchStart, matchEnd);
2026-03-09 23:27:03 -07:00
target.appendChild(mark);
2026-03-15 16:20:43 -07:00
lastIndex = matchEnd;
2026-03-09 23:27:03 -07:00
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");
2026-03-10 14:55:01 -07:00
card.className = "detail-meta-card planet-meta-card";
2026-03-09 23:27:03 -07:00
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);
}
2026-03-10 14:55:01 -07:00
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 || "--"}`;
}
2026-03-09 23:27:03 -07:00
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 || "";
}
}
2026-03-14 00:45:15 -07:00
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);
}
2026-03-09 23:27:03 -07:00
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: [] };
2026-03-14 00:45:15 -07:00
state.catalog.sourceGroups = buildSourceGroups(getSources());
if (!state.selectedSourceGroupId && state.selectedSourceId) {
state.selectedSourceGroupId = findSourceGroupBySourceId(state.selectedSourceId)?.id || "";
2026-03-09 23:27:03 -07:00
}
2026-03-14 00:45:15 -07:00
if (!state.selectedSourceGroupId) {
state.selectedSourceGroupId = getSourceGroups()[0]?.id || "";
}
syncSelectionForGroup(getSelectedSourceGroup());
2026-03-09 23:27:03 -07:00
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();
2026-03-14 00:45:15 -07:00
const sourceGroups = getSourceGroups();
sourceGroups.forEach((group) => {
const source = getSourceForGroup(group);
2026-03-09 23:27:03 -07:00
const button = document.createElement("button");
button.type = "button";
button.className = "planet-list-item alpha-text-source-btn";
2026-03-14 00:45:15 -07:00
button.dataset.sourceGroupId = group.id;
2026-03-09 23:27:03 -07:00
button.setAttribute("role", "option");
2026-03-14 00:45:15 -07:00
const isSelected = normalizeId(group.id) === normalizeId(state.selectedSourceGroupId);
2026-03-09 23:27:03 -07:00
button.classList.toggle("is-selected", isSelected);
button.setAttribute("aria-selected", isSelected ? "true" : "false");
const name = document.createElement("span");
name.className = "planet-list-name";
2026-03-14 00:45:15 -07:00
name.textContent = group.title;
2026-03-09 23:27:03 -07:00
const meta = document.createElement("span");
meta.className = "alpha-text-source-meta";
2026-03-14 00:45:15 -07:00
meta.textContent = buildSourceGroupListMeta(group);
2026-03-09 23:27:03 -07:00
button.append(name, meta);
button.addEventListener("click", () => {
2026-03-14 00:45:15 -07:00
if (normalizeId(group.id) === normalizeId(state.selectedSourceGroupId)) {
2026-03-12 02:35:02 -07:00
showDetailOnlyMode();
2026-03-09 23:27:03 -07:00
return;
}
2026-03-14 00:45:15 -07:00
state.selectedSourceGroupId = group.id;
2026-03-09 23:27:03 -07:00
state.currentPassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
2026-03-14 00:45:15 -07:00
syncSelectionForGroup(group);
2026-03-09 23:27:03 -07:00
renderSourceList();
renderSelectors();
2026-03-12 02:35:02 -07:00
showDetailOnlyMode();
2026-03-09 23:27:03 -07:00
if (state.searchQuery && state.activeSearchScope === "source") {
void Promise.all([loadSelectedPassage(), runSearch("source")]);
return;
}
void loadSelectedPassage();
});
sourceListEl.appendChild(button);
});
2026-03-14 00:45:15 -07:00
if (!sourceGroups.length) {
2026-03-09 23:27:03 -07:00
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
}
if (sourceCountEl) {
2026-03-14 00:45:15 -07:00
sourceCountEl.textContent = `${sourceGroups.length} sources`;
2026-03-09 23:27:03 -07:00
}
}
function renderSelectors() {
2026-03-14 00:45:15 -07:00
const group = getSelectedSourceGroup();
2026-03-09 23:27:03 -07:00
const source = getSelectedSource();
const work = getSelectedWork(source);
2026-03-14 00:45:15 -07:00
const variants = getSourceVariants(group);
const compareCandidates = getCompareCandidates(group);
const compareSource = getCompareSource(group);
const compareEnabled = isCompareModeEnabled(group);
2026-03-09 23:27:03 -07:00
const works = Array.isArray(source?.works) ? source.works : [];
const sections = Array.isArray(work?.sections) ? work.sections : [];
2026-03-14 00:45:15 -07:00
fillSelect(translationSelectEl, variants, state.selectedSourceId, (entry) => buildTranslationOptionLabel(entry));
fillSelect(compareSelectEl, compareCandidates, compareSource?.id || "", (entry) => buildTranslationOptionLabel(entry));
2026-03-15 16:20:43 -07:00
fillSelect(workSelectEl, works, state.selectedWorkId, (entry) => `${entry.title} (${formatCountLabel(entry.sectionCount, String(source?.sectionLabel || "section").toLowerCase())})`);
2026-03-09 23:27:03 -07:00
fillSelect(sectionSelectEl, sections, state.selectedSectionId, (entry) => `${entry.label} · ${entry.verseCount} verses`);
2026-03-14 00:45:15 -07:00
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;
}
2026-03-09 23:27:03 -07:00
}
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) {
2026-03-14 00:45:15 -07:00
const sourceGroup = getSelectedSourceGroup();
2026-03-09 23:27:03 -07:00
const source = passage?.source || getSelectedSource();
const work = passage?.work || getSelectedWork(source);
const section = passage?.section || getSelectedSection(source, work);
2026-03-14 00:45:15 -07:00
const metadata = getSourceMetadata(source);
const version = normalizeTextValue(metadata.versionLabel || metadata.version);
const translator = normalizeTextValue(metadata.translator);
const compareSource = getCompareSource(sourceGroup);
2026-03-10 14:55:01 -07:00
const displayPreferences = getSourceDisplayPreferences(source, passage);
2026-03-09 23:27:03 -07:00
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>
2026-03-14 00:45:15 -07:00
${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>` : ""}
2026-03-09 23:27:03 -07:00
<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);
2026-03-10 14:55:01 -07:00
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);
});
2026-03-09 23:27:03 -07:00
2026-03-10 14:55:01 -07:00
displayGroup.append(displayLabel, displayButtons);
extraCard.appendChild(displayGroup);
2026-03-09 23:27:03 -07:00
}
2026-03-10 14:55:01 -07:00
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();
});
2026-03-09 23:27:03 -07:00
2026-03-10 14:55:01 -07:00
interlinearButtons.appendChild(interlinearButton);
interlinearGroup.append(interlinearLabel, interlinearButtons);
extraCard.appendChild(interlinearGroup);
}
metaGrid.appendChild(extraCard);
}
2026-03-09 23:27:03 -07:00
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;
}
2026-03-14 00:45:15 -07:00
function createPlainVerse(verse, source, displayPreferences, options = {}) {
const translationText = verse.text || "";
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
2026-03-14 00:45:15 -07:00
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
2026-03-09 23:27:03 -07:00
const article = document.createElement("article");
article.className = "alpha-text-verse";
2026-03-14 00:45:15 -07:00
article.classList.toggle("is-highlighted", isHighlighted);
2026-03-09 23:27:03 -07:00
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);
2026-03-10 14:55:01 -07:00
article.append(head);
2026-03-14 00:45:15 -07:00
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
2026-03-09 23:27:03 -07:00
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();
}
2026-03-14 00:45:15 -07:00
function appendVerseTextLines(target, verse, source, displayPreferences, translationText, highlightQuery = "") {
2026-03-10 14:55:01 -07:00
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}`;
2026-03-14 00:45:15 -07:00
appendHighlightedText(text, line.text, highlightQuery);
2026-03-10 14:55:01 -07:00
target.appendChild(text);
});
}
2026-03-14 00:45:15 -07:00
function createTokenVerse(verse, lexiconId, displayPreferences, source, options = {}) {
const translationText = buildTokenTranslationText(verse?.tokens, verse?.text);
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
2026-03-14 00:45:15 -07:00
const isHighlighted = options.highlight !== false && isHighlightedVerse(verse);
2026-03-09 23:27:03 -07:00
const article = document.createElement("article");
2026-03-10 14:55:01 -07:00
article.className = "alpha-text-verse";
article.classList.toggle("alpha-text-verse--interlinear", Boolean(displayPreferences?.showInterlinear));
2026-03-14 00:45:15 -07:00
article.classList.toggle("is-highlighted", isHighlighted);
2026-03-09 23:27:03 -07:00
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);
2026-03-09 23:27:03 -07:00
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);
2026-03-10 14:55:01 -07:00
article.append(head);
2026-03-14 00:45:15 -07:00
appendVerseTextLines(article, verse, source, displayPreferences, translationText, isHighlighted ? state.searchQuery : "");
2026-03-10 14:55:01 -07:00
if (displayPreferences?.showInterlinear) {
article.appendChild(tokenGrid);
}
2026-03-09 23:27:03 -07:00
return article;
}
2026-03-14 00:45:15 -07:00
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 = {}) {
2026-03-09 23:27:03 -07:00
const source = passage?.source || getSelectedSource();
2026-03-10 14:55:01 -07:00
const displayPreferences = getSourceDisplayPreferences(source, passage);
2026-03-14 00:45:15 -07:00
const card = createCard(options.title || getPassageLocationLabel(passage));
2026-03-10 14:55:01 -07:00
card.classList.add("alpha-text-reader-card");
2026-03-14 00:45:15 -07:00
if (options.compare) {
card.classList.add("alpha-text-reader-card--compare");
}
2026-03-09 23:27:03 -07:00
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
2026-03-14 00:45:15 -07:00
? createTokenVerse(verse, source.features.lexiconIds?.[0] || "", displayPreferences, source, options)
: createPlainVerse(verse, source, displayPreferences, options);
2026-03-09 23:27:03 -07:00
reader.appendChild(verseEl);
});
card.appendChild(reader);
2026-03-10 14:55:01 -07:00
2026-03-14 00:45:15 -07:00
const navigation = options.showNavigation === false ? null : createReaderNavigation(passage);
if (navigation) {
card.appendChild(navigation);
2026-03-10 14:55:01 -07:00
}
2026-03-14 00:45:15 -07:00
return card;
}
2026-03-10 14:55:01 -07:00
2026-03-14 00:45:15 -07:00
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
}));
2026-03-10 14:55:01 -07:00
}
2026-03-14 00:45:15 -07:00
return wrapper;
2026-03-09 23:27:03 -07:00
}
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";
2026-03-15 16:20:43 -07:00
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);
2026-03-09 23:27:03 -07:00
if (state.searchLoading) {
summary.textContent = `Searching ${scopeLabel} for \"${state.searchQuery}\"...`;
2026-03-15 16:20:43 -07:00
card.append(summary, actions);
2026-03-09 23:27:03 -07:00
return card;
}
if (state.searchError) {
summary.textContent = `Search scope: ${scopeLabel}`;
2026-03-15 16:20:43 -07:00
card.append(summary, actions, createEmptyMessage(state.searchError));
2026-03-09 23:27:03 -07:00
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}`;
2026-03-15 16:20:43 -07:00
card.append(summary, actions);
2026-03-09 23:27:03 -07:00
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() {
2026-03-12 04:37:26 -07:00
return (state.activeSearchScope === "global" || state.activeSearchScope === "source")
2026-03-09 23:27:03 -07:00
&& Boolean(state.searchQuery)
&& !state.highlightedVerseId;
}
function renderDetail() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
2026-03-14 00:45:15 -07:00
const compareEnabled = isCompareModeEnabled(getSelectedSourceGroup());
2026-03-09 23:27:03 -07:00
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
2026-03-12 04:37:26 -07:00
setGlobalSearchHeadingMode(globalSearchOnlyMode);
2026-03-09 23:27:03 -07:00
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"
2026-03-14 00:45:15 -07:00
: buildSourceDetailSubtitle(source, work);
2026-03-09 23:27:03 -07:00
}
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));
2026-03-14 00:45:15 -07:00
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));
}
2026-03-09 23:27:03 -07:00
renderLexiconPopup();
}
2026-03-14 00:45:15 -07:00
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.");
}
}
2026-03-09 23:27:03 -07:00
async function loadSelectedPassage() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
2026-03-14 00:45:15 -07:00
const compareSource = isCompareModeEnabled(getSelectedSourceGroup()) ? getCompareSource() : null;
2026-03-09 23:27:03 -07:00
if (!source || !work || !section) {
state.currentPassage = null;
2026-03-14 00:45:15 -07:00
state.comparePassage = null;
2026-03-09 23:27:03 -07:00
renderDetail();
return;
}
state.currentPassage = null;
2026-03-14 00:45:15 -07:00
state.comparePassage = null;
2026-03-09 23:27:03 -07:00
renderDetail();
2026-03-14 00:45:15 -07:00
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);
2026-03-09 23:27:03 -07:00
}
}
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;
}
2026-03-12 04:37:26 -07:00
if (normalizedScope === "global" || normalizedScope === "source") {
showDetailOnlyMode();
}
2026-03-09 23:27:03 -07:00
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;
}
2026-03-14 00:45:15 -07:00
const sourceGroup = findSourceGroupBySourceId(result.sourceId);
state.selectedSourceGroupId = sourceGroup?.id || "";
2026-03-09 23:27:03 -07:00
state.selectedSourceId = result.sourceId;
2026-03-14 00:45:15 -07:00
rememberSelectedSource(sourceGroup, result.sourceId);
2026-03-09 23:27:03 -07:00
state.selectedWorkId = result.workId;
state.selectedSectionId = result.sectionId;
state.highlightedVerseId = result.verseId;
dismissLexiconEntry({ restoreFocus: false });
syncSelectionForSource(getSelectedSource());
renderSourceList();
renderSelectors();
2026-03-12 02:35:02 -07:00
showDetailOnlyMode();
2026-03-09 23:27:03 -07:00
await loadSelectedPassage();
2026-03-10 14:55:01 -07:00
clearActiveSearchUi({ preserveHighlight: true });
renderDetail();
2026-03-09 23:27:03 -07:00
}
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();
}
});
}
2026-03-15 16:20:43 -07:00
if (globalSearchClearEl instanceof HTMLButtonElement) {
globalSearchClearEl.addEventListener("click", () => {
clearScopedSearch("global");
renderDetail();
});
}
2026-03-09 23:27:03 -07:00
if (localSearchFormEl instanceof HTMLFormElement) {
localSearchFormEl.addEventListener("submit", (event) => {
event.preventDefault();
void runSearch("source");
});
}
2026-03-15 16:20:43 -07:00
if (localSearchClearEl instanceof HTMLButtonElement) {
localSearchClearEl.addEventListener("click", () => {
clearScopedSearch("source");
renderDetail();
});
}
2026-03-09 23:27:03 -07:00
if (localSearchInputEl instanceof HTMLInputElement) {
localSearchInputEl.addEventListener("input", () => {
state.localSearchQuery = String(localSearchInputEl.value || "").trim();
updateSearchControls();
if (!state.localSearchQuery && state.activeSearchScope === "source" && state.searchQuery) {
clearSearchState();
renderDetail();
}
});
}
2026-03-15 16:20:43 -07:00
if (showVerseHeadsEl instanceof HTMLInputElement) {
showVerseHeadsEl.addEventListener("change", () => {
state.showVerseHeads = Boolean(showVerseHeadsEl.checked);
writeStoredBoolean(STORAGE_KEYS.showVerseHeads, state.showVerseHeads);
syncReaderDisplayControls();
});
}
2026-03-14 00:45:15 -07:00
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();
});
}
2026-03-09 23:27:03 -07:00
if (workSelectEl) {
workSelectEl.addEventListener("change", () => {
state.selectedWorkId = String(workSelectEl.value || "");
const source = getSelectedSource();
syncSelectionForSource(source);
state.currentPassage = null;
2026-03-14 00:45:15 -07:00
state.comparePassage = null;
2026-03-09 23:27:03 -07:00
state.lexiconEntry = null;
state.highlightedVerseId = "";
renderSelectors();
void loadSelectedPassage();
});
}
if (sectionSelectEl) {
sectionSelectEl.addEventListener("change", () => {
state.selectedSectionId = String(sectionSelectEl.value || "");
state.currentPassage = null;
2026-03-14 00:45:15 -07:00
state.comparePassage = null;
2026-03-09 23:27:03 -07:00
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();
2026-03-12 02:35:02 -07:00
window.TarotChromeUi?.initializeSidebarPopouts?.();
window.TarotChromeUi?.initializeDetailPopouts?.();
2026-03-09 23:27:03 -07:00
if (!sourceListEl || !detailBodyEl) {
return;
}
await ensureCatalogLoaded();
2026-03-15 16:20:43 -07:00
syncReaderDisplayControls();
2026-03-09 23:27:03 -07:00
renderSourceList();
renderSelectors();
updateSearchControls();
2026-03-15 16:20:43 -07:00
showSidebarOnlyMode(false);
2026-03-09 23:27:03 -07:00
if (!state.currentPassage) {
await loadSelectedPassage();
return;
}
renderDetail();
}
function resetState() {
state.catalog = null;
state.currentPassage = null;
2026-03-14 00:45:15 -07:00
state.comparePassage = null;
2026-03-09 23:27:03 -07:00
state.lexiconEntry = null;
2026-03-14 00:45:15 -07:00
state.selectedSourceGroupId = "";
2026-03-09 23:27:03 -07:00
state.selectedSourceId = "";
2026-03-14 00:45:15 -07:00
state.selectedSourceIdByGroup = {};
state.compareSourceIdByGroup = {};
state.compareModeByGroup = {};
2026-03-09 23:27:03 -07:00
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
};
})();