1740 lines
57 KiB
JavaScript
1740 lines
57 KiB
JavaScript
(function () {
|
||
"use strict";
|
||
|
||
const dataService = window.TarotDataService || {};
|
||
|
||
const state = {
|
||
initialized: false,
|
||
catalog: null,
|
||
selectedSourceId: "",
|
||
selectedWorkId: "",
|
||
selectedSectionId: "",
|
||
currentPassage: null,
|
||
lexiconEntry: null,
|
||
lexiconRequestId: 0,
|
||
lexiconOccurrenceResults: null,
|
||
lexiconOccurrenceLoading: false,
|
||
lexiconOccurrenceError: "",
|
||
lexiconOccurrenceVisible: false,
|
||
lexiconOccurrenceRequestId: 0,
|
||
globalSearchQuery: "",
|
||
localSearchQuery: "",
|
||
activeSearchScope: "global",
|
||
searchQuery: "",
|
||
searchResults: null,
|
||
searchLoading: false,
|
||
searchError: "",
|
||
searchRequestId: 0,
|
||
highlightedVerseId: "",
|
||
displayPreferencesBySource: {}
|
||
};
|
||
|
||
let sourceListEl;
|
||
let sourceCountEl;
|
||
let globalSearchFormEl;
|
||
let globalSearchInputEl;
|
||
let localSearchFormEl;
|
||
let localSearchInputEl;
|
||
let workSelectEl;
|
||
let sectionSelectEl;
|
||
let detailHeadingEl;
|
||
let detailNameEl;
|
||
let detailSubEl;
|
||
let detailHeadingToolsEl;
|
||
let detailBodyEl;
|
||
let textLayoutEl;
|
||
let lexiconPopupEl;
|
||
let lexiconPopupTitleEl;
|
||
let lexiconPopupSubtitleEl;
|
||
let lexiconPopupBodyEl;
|
||
let lexiconPopupCloseEl;
|
||
let lexiconReturnFocusEl = null;
|
||
|
||
function normalizeId(value) {
|
||
return String(value || "")
|
||
.trim()
|
||
.toLowerCase();
|
||
}
|
||
|
||
function getElements() {
|
||
sourceListEl = document.getElementById("alpha-text-source-list");
|
||
sourceCountEl = document.getElementById("alpha-text-source-count");
|
||
globalSearchFormEl = document.getElementById("alpha-text-global-search-form");
|
||
globalSearchInputEl = document.getElementById("alpha-text-global-search-input");
|
||
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
|
||
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
|
||
workSelectEl = document.getElementById("alpha-text-work-select");
|
||
sectionSelectEl = document.getElementById("alpha-text-section-select");
|
||
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;
|
||
ensureLexiconPopup();
|
||
}
|
||
|
||
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 ensureLexiconPopup() {
|
||
if (lexiconPopupEl instanceof HTMLElement) {
|
||
return;
|
||
}
|
||
|
||
const popup = document.createElement("div");
|
||
popup.className = "alpha-text-lexicon-popup";
|
||
popup.hidden = true;
|
||
popup.setAttribute("aria-hidden", "true");
|
||
|
||
const backdrop = document.createElement("div");
|
||
backdrop.className = "alpha-text-lexicon-popup-backdrop";
|
||
backdrop.addEventListener("click", closeLexiconEntry);
|
||
|
||
const card = document.createElement("section");
|
||
card.className = "alpha-text-lexicon-popup-card";
|
||
card.setAttribute("role", "dialog");
|
||
card.setAttribute("aria-modal", "true");
|
||
card.setAttribute("aria-labelledby", "alpha-text-lexicon-popup-title");
|
||
card.setAttribute("tabindex", "-1");
|
||
|
||
const header = document.createElement("div");
|
||
header.className = "alpha-text-lexicon-popup-header";
|
||
|
||
const headingWrap = document.createElement("div");
|
||
headingWrap.className = "alpha-text-lexicon-popup-heading";
|
||
|
||
const title = document.createElement("h3");
|
||
title.id = "alpha-text-lexicon-popup-title";
|
||
title.textContent = "Lexicon Entry";
|
||
|
||
const subtitle = document.createElement("p");
|
||
subtitle.className = "alpha-text-lexicon-popup-subtitle";
|
||
subtitle.textContent = "Strong's definition";
|
||
|
||
headingWrap.append(title, subtitle);
|
||
|
||
const closeButton = document.createElement("button");
|
||
closeButton.type = "button";
|
||
closeButton.className = "alpha-text-lexicon-popup-close";
|
||
closeButton.textContent = "Close";
|
||
closeButton.addEventListener("click", closeLexiconEntry);
|
||
|
||
header.append(headingWrap, closeButton);
|
||
|
||
const body = document.createElement("div");
|
||
body.className = "alpha-text-lexicon-popup-body";
|
||
|
||
card.append(header, body);
|
||
popup.append(backdrop, card);
|
||
document.body.appendChild(popup);
|
||
|
||
lexiconPopupEl = popup;
|
||
lexiconPopupTitleEl = title;
|
||
lexiconPopupSubtitleEl = subtitle;
|
||
lexiconPopupBodyEl = body;
|
||
lexiconPopupCloseEl = closeButton;
|
||
}
|
||
|
||
function getSources() {
|
||
return Array.isArray(state.catalog?.sources) ? state.catalog.sources : [];
|
||
}
|
||
|
||
function findById(entries, value) {
|
||
const needle = normalizeId(value);
|
||
return (Array.isArray(entries) ? entries : []).find((entry) => normalizeId(entry?.id) === needle) || null;
|
||
}
|
||
|
||
function getSelectedSource() {
|
||
return findById(getSources(), state.selectedSourceId);
|
||
}
|
||
|
||
function getSelectedWork(source = getSelectedSource()) {
|
||
return findById(source?.works, state.selectedWorkId);
|
||
}
|
||
|
||
function getSelectedSection(source = getSelectedSource(), work = getSelectedWork(source)) {
|
||
return findById(work?.sections, state.selectedSectionId);
|
||
}
|
||
|
||
function normalizeTextValue(value) {
|
||
return String(value || "").trim();
|
||
}
|
||
|
||
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() {
|
||
return;
|
||
}
|
||
|
||
function clearActiveSearchUi(options = {}) {
|
||
const preserveHighlight = options.preserveHighlight === true;
|
||
const scope = state.activeSearchScope === "source" ? "source" : "global";
|
||
|
||
setStoredSearchQuery(scope, "");
|
||
const input = getSearchInput(scope);
|
||
if (input instanceof HTMLInputElement) {
|
||
input.value = "";
|
||
}
|
||
|
||
state.searchQuery = "";
|
||
state.searchResults = null;
|
||
state.searchLoading = false;
|
||
state.searchError = "";
|
||
state.searchRequestId += 1;
|
||
|
||
if (!preserveHighlight) {
|
||
state.highlightedVerseId = "";
|
||
}
|
||
|
||
updateSearchControls();
|
||
}
|
||
|
||
function getSourceDisplayCapabilities(source, passage) {
|
||
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
|
||
const hasOriginal = verses.some((verse) => normalizeTextValue(verse?.originalText));
|
||
const hasTransliteration = verses.some((verse) => getVerseTransliteration(verse, source));
|
||
const hasInterlinear = Boolean(source?.features?.hasTokenAnnotations);
|
||
const textModeCount = 1 + (hasOriginal ? 1 : 0) + (hasTransliteration ? 1 : 0);
|
||
|
||
return {
|
||
hasTranslation: true,
|
||
hasOriginal,
|
||
hasTransliteration,
|
||
hasInterlinear,
|
||
hasAnyExtras: hasOriginal || hasTransliteration || hasInterlinear,
|
||
supportsAllTextMode: textModeCount > 1
|
||
};
|
||
}
|
||
|
||
function getDefaultTextDisplayMode(capabilities) {
|
||
if (capabilities?.hasTranslation) {
|
||
return "translation";
|
||
}
|
||
if (capabilities?.hasOriginal) {
|
||
return "original";
|
||
}
|
||
if (capabilities?.hasTransliteration) {
|
||
return "transliteration";
|
||
}
|
||
return "translation";
|
||
}
|
||
|
||
function getAvailableTextDisplayModes(capabilities) {
|
||
const modes = [];
|
||
if (capabilities?.hasTranslation) {
|
||
modes.push("translation");
|
||
}
|
||
if (capabilities?.hasOriginal) {
|
||
modes.push("original");
|
||
}
|
||
if (capabilities?.hasTransliteration) {
|
||
modes.push("transliteration");
|
||
}
|
||
if (capabilities?.supportsAllTextMode) {
|
||
modes.push("all");
|
||
}
|
||
return modes;
|
||
}
|
||
|
||
function getSourceDisplayPreferences(source, passage) {
|
||
const sourceId = normalizeId(source?.id);
|
||
const capabilities = getSourceDisplayCapabilities(source, passage);
|
||
const availableTextModes = getAvailableTextDisplayModes(capabilities);
|
||
const stored = sourceId ? state.displayPreferencesBySource[sourceId] : null;
|
||
|
||
let textMode = stored?.textMode;
|
||
if (!availableTextModes.includes(textMode)) {
|
||
textMode = getDefaultTextDisplayMode(capabilities);
|
||
}
|
||
|
||
const preferences = {
|
||
textMode,
|
||
showInterlinear: capabilities.hasInterlinear ? Boolean(stored?.showInterlinear) : false
|
||
};
|
||
|
||
if (sourceId) {
|
||
state.displayPreferencesBySource[sourceId] = preferences;
|
||
}
|
||
|
||
return {
|
||
...preferences,
|
||
capabilities,
|
||
availableTextModes
|
||
};
|
||
}
|
||
|
||
function updateSourceDisplayPreferences(source, patch) {
|
||
const sourceId = normalizeId(source?.id);
|
||
if (!sourceId) {
|
||
return;
|
||
}
|
||
|
||
const current = state.displayPreferencesBySource[sourceId] || {};
|
||
state.displayPreferencesBySource[sourceId] = {
|
||
...current,
|
||
...patch
|
||
};
|
||
}
|
||
|
||
function formatTextDisplayModeLabel(mode) {
|
||
switch (mode) {
|
||
case "translation":
|
||
return "Translation";
|
||
case "original":
|
||
return "Original";
|
||
case "transliteration":
|
||
return "Transliteration";
|
||
case "all":
|
||
return "All";
|
||
default:
|
||
return "Display";
|
||
}
|
||
}
|
||
|
||
function clearSearchState() {
|
||
state.searchQuery = "";
|
||
state.searchResults = null;
|
||
state.searchLoading = false;
|
||
state.searchError = "";
|
||
state.highlightedVerseId = "";
|
||
state.searchRequestId += 1;
|
||
|
||
updateSearchControls();
|
||
}
|
||
|
||
function clearScopedSearch(scope) {
|
||
setStoredSearchQuery(scope, "");
|
||
|
||
const input = getSearchInput(scope);
|
||
if (input instanceof HTMLInputElement) {
|
||
input.value = "";
|
||
}
|
||
|
||
if (state.activeSearchScope === scope) {
|
||
clearSearchState();
|
||
} else {
|
||
updateSearchControls();
|
||
}
|
||
}
|
||
|
||
function escapeRegExp(value) {
|
||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||
}
|
||
|
||
function appendHighlightedText(target, text, query) {
|
||
if (!(target instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const sourceText = String(text || "");
|
||
const normalizedQuery = String(query || "").trim();
|
||
target.replaceChildren();
|
||
if (!normalizedQuery) {
|
||
target.textContent = sourceText;
|
||
return;
|
||
}
|
||
|
||
const matcher = new RegExp(escapeRegExp(normalizedQuery), "ig");
|
||
let lastIndex = 0;
|
||
let match = matcher.exec(sourceText);
|
||
while (match) {
|
||
if (match.index > lastIndex) {
|
||
target.appendChild(document.createTextNode(sourceText.slice(lastIndex, match.index)));
|
||
}
|
||
|
||
const mark = document.createElement("mark");
|
||
mark.className = "alpha-text-mark";
|
||
mark.textContent = sourceText.slice(match.index, match.index + match[0].length);
|
||
target.appendChild(mark);
|
||
|
||
lastIndex = match.index + match[0].length;
|
||
match = matcher.exec(sourceText);
|
||
}
|
||
|
||
if (lastIndex < sourceText.length) {
|
||
target.appendChild(document.createTextNode(sourceText.slice(lastIndex)));
|
||
}
|
||
}
|
||
|
||
function isHighlightedVerse(verse) {
|
||
return normalizeId(verse?.id) && normalizeId(verse?.id) === normalizeId(state.highlightedVerseId);
|
||
}
|
||
|
||
function scrollHighlightedVerseIntoView() {
|
||
const highlightedVerse = detailBodyEl?.querySelector?.(".alpha-text-verse.is-highlighted");
|
||
const detailPanel = highlightedVerse?.closest?.(".planet-detail-panel");
|
||
if (!(highlightedVerse instanceof HTMLElement) || !(detailPanel instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const verseRect = highlightedVerse.getBoundingClientRect();
|
||
const panelRect = detailPanel.getBoundingClientRect();
|
||
const targetTop = detailPanel.scrollTop
|
||
+ (verseRect.top - panelRect.top)
|
||
- (detailPanel.clientHeight / 2)
|
||
+ (verseRect.height / 2);
|
||
|
||
detailPanel.scrollTo({
|
||
top: Math.max(0, targetTop),
|
||
behavior: "smooth"
|
||
});
|
||
}
|
||
|
||
function createCard(title) {
|
||
const card = document.createElement("div");
|
||
card.className = "detail-meta-card planet-meta-card";
|
||
if (title) {
|
||
const heading = document.createElement("strong");
|
||
heading.textContent = title;
|
||
card.appendChild(heading);
|
||
}
|
||
return card;
|
||
}
|
||
|
||
function createEmptyMessage(text) {
|
||
const message = document.createElement("div");
|
||
message.className = "alpha-text-empty";
|
||
message.textContent = text;
|
||
return message;
|
||
}
|
||
|
||
function renderPlaceholder(title, subtitle, message) {
|
||
if (detailNameEl) {
|
||
detailNameEl.textContent = title;
|
||
}
|
||
if (detailSubEl) {
|
||
detailSubEl.textContent = subtitle;
|
||
}
|
||
if (!detailBodyEl) {
|
||
return;
|
||
}
|
||
|
||
detailBodyEl.replaceChildren();
|
||
const card = createCard("Text Reader");
|
||
card.appendChild(createEmptyMessage(message));
|
||
detailBodyEl.appendChild(card);
|
||
}
|
||
|
||
function navigateToPassageTarget(target) {
|
||
if (!target) {
|
||
return;
|
||
}
|
||
|
||
state.selectedWorkId = target.workId;
|
||
state.selectedSectionId = target.sectionId;
|
||
state.lexiconEntry = null;
|
||
renderSelectors();
|
||
void loadSelectedPassage();
|
||
}
|
||
|
||
function getPassageLocationLabel(passage) {
|
||
const source = passage?.source || getSelectedSource();
|
||
const work = passage?.work || getSelectedWork(source);
|
||
const section = passage?.section || getSelectedSection(source, work);
|
||
return `${work?.title || "--"} · ${section?.title || section?.label || "--"}`;
|
||
}
|
||
|
||
function syncSelectionForSource(source) {
|
||
const works = Array.isArray(source?.works) ? source.works : [];
|
||
if (!works.length) {
|
||
state.selectedWorkId = "";
|
||
state.selectedSectionId = "";
|
||
return;
|
||
}
|
||
|
||
if (!findById(works, state.selectedWorkId)) {
|
||
state.selectedWorkId = works[0].id;
|
||
}
|
||
|
||
const work = getSelectedWork(source);
|
||
const sections = Array.isArray(work?.sections) ? work.sections : [];
|
||
if (!findById(sections, state.selectedSectionId)) {
|
||
state.selectedSectionId = sections[0]?.id || "";
|
||
}
|
||
}
|
||
|
||
async function ensureCatalogLoaded(forceRefresh = false) {
|
||
if (!forceRefresh && state.catalog) {
|
||
return state.catalog;
|
||
}
|
||
|
||
const payload = await dataService.loadTextLibrary?.(forceRefresh);
|
||
state.catalog = payload && typeof payload === "object"
|
||
? payload
|
||
: { meta: {}, sources: [], lexicons: [] };
|
||
|
||
if (!state.selectedSourceId) {
|
||
state.selectedSourceId = getSources()[0]?.id || "";
|
||
}
|
||
|
||
syncSelectionForSource(getSelectedSource());
|
||
return state.catalog;
|
||
}
|
||
|
||
function fillSelect(selectEl, entries, selectedValue, labelBuilder) {
|
||
if (!(selectEl instanceof HTMLSelectElement)) {
|
||
return;
|
||
}
|
||
|
||
selectEl.replaceChildren();
|
||
(Array.isArray(entries) ? entries : []).forEach((entry) => {
|
||
const option = document.createElement("option");
|
||
option.value = entry.id;
|
||
option.textContent = typeof labelBuilder === "function" ? labelBuilder(entry) : String(entry?.label || entry?.title || entry?.id || "");
|
||
option.selected = normalizeId(entry.id) === normalizeId(selectedValue);
|
||
selectEl.appendChild(option);
|
||
});
|
||
|
||
selectEl.disabled = !selectEl.options.length;
|
||
}
|
||
|
||
function renderSourceList() {
|
||
if (!sourceListEl) {
|
||
return;
|
||
}
|
||
|
||
sourceListEl.replaceChildren();
|
||
const sources = getSources();
|
||
sources.forEach((source) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "planet-list-item alpha-text-source-btn";
|
||
button.dataset.sourceId = source.id;
|
||
button.setAttribute("role", "option");
|
||
|
||
const isSelected = normalizeId(source.id) === normalizeId(state.selectedSourceId);
|
||
button.classList.toggle("is-selected", isSelected);
|
||
button.setAttribute("aria-selected", isSelected ? "true" : "false");
|
||
|
||
const name = document.createElement("span");
|
||
name.className = "planet-list-name";
|
||
name.textContent = source.title;
|
||
|
||
const meta = document.createElement("span");
|
||
meta.className = "alpha-text-source-meta";
|
||
const sectionLabel = source.sectionLabel || "Section";
|
||
meta.textContent = `${source.shortTitle || source.title} · ${source.stats?.workCount || 0} ${source.workLabel || "Works"} · ${source.stats?.sectionCount || 0} ${sectionLabel.toLowerCase()}s`;
|
||
|
||
button.append(name, meta);
|
||
button.addEventListener("click", () => {
|
||
if (normalizeId(source.id) === normalizeId(state.selectedSourceId)) {
|
||
showDetailOnlyMode();
|
||
return;
|
||
}
|
||
|
||
state.selectedSourceId = source.id;
|
||
state.currentPassage = null;
|
||
state.lexiconEntry = null;
|
||
state.highlightedVerseId = "";
|
||
syncSelectionForSource(getSelectedSource());
|
||
renderSourceList();
|
||
renderSelectors();
|
||
showDetailOnlyMode();
|
||
|
||
if (state.searchQuery && state.activeSearchScope === "source") {
|
||
void Promise.all([loadSelectedPassage(), runSearch("source")]);
|
||
return;
|
||
}
|
||
|
||
void loadSelectedPassage();
|
||
});
|
||
|
||
sourceListEl.appendChild(button);
|
||
});
|
||
|
||
if (!sources.length) {
|
||
sourceListEl.appendChild(createEmptyMessage("No text sources are available."));
|
||
}
|
||
|
||
if (sourceCountEl) {
|
||
sourceCountEl.textContent = `${sources.length} sources`;
|
||
}
|
||
}
|
||
|
||
function renderSelectors() {
|
||
const source = getSelectedSource();
|
||
const work = getSelectedWork(source);
|
||
const works = Array.isArray(source?.works) ? source.works : [];
|
||
const sections = Array.isArray(work?.sections) ? work.sections : [];
|
||
|
||
fillSelect(workSelectEl, works, state.selectedWorkId, (entry) => `${entry.title} (${entry.sectionCount} ${String(source?.sectionLabel || "section").toLowerCase()}s)`);
|
||
fillSelect(sectionSelectEl, sections, state.selectedSectionId, (entry) => `${entry.label} · ${entry.verseCount} verses`);
|
||
}
|
||
|
||
function closeLexiconEntry() {
|
||
dismissLexiconEntry();
|
||
}
|
||
|
||
function clearLexiconOccurrenceState() {
|
||
state.lexiconOccurrenceResults = null;
|
||
state.lexiconOccurrenceLoading = false;
|
||
state.lexiconOccurrenceError = "";
|
||
state.lexiconOccurrenceVisible = false;
|
||
state.lexiconOccurrenceRequestId += 1;
|
||
}
|
||
|
||
function dismissLexiconEntry(options = {}) {
|
||
const shouldRestoreFocus = options.restoreFocus !== false;
|
||
state.lexiconRequestId += 1;
|
||
state.lexiconEntry = null;
|
||
clearLexiconOccurrenceState();
|
||
renderLexiconPopup();
|
||
|
||
const returnFocusEl = lexiconReturnFocusEl;
|
||
lexiconReturnFocusEl = null;
|
||
|
||
if (shouldRestoreFocus && returnFocusEl instanceof HTMLElement && returnFocusEl.isConnected) {
|
||
requestAnimationFrame(() => {
|
||
if (returnFocusEl.isConnected) {
|
||
returnFocusEl.focus();
|
||
}
|
||
});
|
||
}
|
||
}
|
||
|
||
async function toggleLexiconOccurrences() {
|
||
const lexiconId = state.lexiconEntry?.lexicon?.id || state.lexiconEntry?.lexiconId || "";
|
||
const entryId = state.lexiconEntry?.entryId || "";
|
||
if (!lexiconId || !entryId) {
|
||
return;
|
||
}
|
||
|
||
if (state.lexiconOccurrenceVisible && !state.lexiconOccurrenceLoading) {
|
||
state.lexiconOccurrenceVisible = false;
|
||
renderLexiconPopup();
|
||
return;
|
||
}
|
||
|
||
state.lexiconOccurrenceVisible = true;
|
||
if (state.lexiconOccurrenceResults || state.lexiconOccurrenceError) {
|
||
renderLexiconPopup();
|
||
return;
|
||
}
|
||
|
||
const requestId = state.lexiconOccurrenceRequestId + 1;
|
||
state.lexiconOccurrenceRequestId = requestId;
|
||
state.lexiconOccurrenceLoading = true;
|
||
state.lexiconOccurrenceError = "";
|
||
renderLexiconPopup();
|
||
|
||
try {
|
||
const payload = await dataService.loadTextLexiconOccurrences?.(lexiconId, entryId, { limit: 100 });
|
||
if (requestId !== state.lexiconOccurrenceRequestId) {
|
||
return;
|
||
}
|
||
|
||
state.lexiconOccurrenceResults = payload;
|
||
state.lexiconOccurrenceLoading = false;
|
||
renderLexiconPopup();
|
||
} catch (error) {
|
||
if (requestId !== state.lexiconOccurrenceRequestId) {
|
||
return;
|
||
}
|
||
|
||
state.lexiconOccurrenceLoading = false;
|
||
state.lexiconOccurrenceError = error?.message || "Unable to load verse occurrences for this Strong's entry.";
|
||
renderLexiconPopup();
|
||
}
|
||
}
|
||
|
||
async function openLexiconOccurrence(result) {
|
||
dismissLexiconEntry({ restoreFocus: false });
|
||
await openSearchResult(result);
|
||
}
|
||
|
||
function appendLexiconOccurrencePreview(target, result) {
|
||
if (!(target instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
target.replaceChildren();
|
||
|
||
const previewTokens = Array.isArray(result?.previewTokens) ? result.previewTokens : [];
|
||
if (!previewTokens.length) {
|
||
target.textContent = result?.preview || result?.reference || "";
|
||
return;
|
||
}
|
||
|
||
previewTokens.forEach((token, index) => {
|
||
const text = String(token?.text || "").trim();
|
||
if (!text) {
|
||
return;
|
||
}
|
||
|
||
const previousText = String(previewTokens[index - 1]?.text || "").trim();
|
||
if (index > 0 && text !== "..." && previousText !== "...") {
|
||
target.appendChild(document.createTextNode(" "));
|
||
}
|
||
|
||
if (token?.isMatch) {
|
||
const mark = document.createElement("mark");
|
||
mark.className = "alpha-text-mark alpha-text-mark--lexicon";
|
||
mark.textContent = text;
|
||
target.appendChild(mark);
|
||
return;
|
||
}
|
||
|
||
target.appendChild(document.createTextNode(text));
|
||
});
|
||
}
|
||
|
||
function renderLexiconPopup() {
|
||
ensureLexiconPopup();
|
||
|
||
if (!(lexiconPopupEl instanceof HTMLElement) || !(lexiconPopupBodyEl instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const payload = state.lexiconEntry;
|
||
const wasHidden = lexiconPopupEl.hidden;
|
||
|
||
if (!payload) {
|
||
lexiconPopupEl.hidden = true;
|
||
lexiconPopupEl.setAttribute("aria-hidden", "true");
|
||
lexiconPopupTitleEl.textContent = "Lexicon Entry";
|
||
lexiconPopupSubtitleEl.textContent = "Strong's definition";
|
||
lexiconPopupBodyEl.replaceChildren();
|
||
return;
|
||
}
|
||
|
||
lexiconPopupTitleEl.textContent = payload.entryId ? `Strong's ${payload.entryId}` : "Lexicon Entry";
|
||
lexiconPopupSubtitleEl.textContent = payload.loading
|
||
? "Loading definition..."
|
||
: "Strong's definition";
|
||
lexiconPopupBodyEl.replaceChildren();
|
||
|
||
if (payload.loading) {
|
||
lexiconPopupBodyEl.appendChild(createEmptyMessage(`Loading ${payload.entryId}...`));
|
||
} else if (payload.error) {
|
||
lexiconPopupBodyEl.appendChild(createEmptyMessage(payload.error));
|
||
} else {
|
||
const entry = payload.entry || {};
|
||
const head = document.createElement("div");
|
||
head.className = "alpha-text-lexicon-head";
|
||
|
||
const idPill = document.createElement("button");
|
||
idPill.type = "button";
|
||
idPill.className = "alpha-text-lexicon-id alpha-text-lexicon-id--button";
|
||
idPill.textContent = payload.entryId || "--";
|
||
idPill.setAttribute("aria-expanded", state.lexiconOccurrenceVisible ? "true" : "false");
|
||
idPill.addEventListener("click", () => {
|
||
void toggleLexiconOccurrences();
|
||
});
|
||
head.appendChild(idPill);
|
||
|
||
if (entry.lemma) {
|
||
const lemma = document.createElement("span");
|
||
lemma.className = "alpha-text-token-original";
|
||
lemma.textContent = entry.lemma;
|
||
head.appendChild(lemma);
|
||
}
|
||
|
||
lexiconPopupBodyEl.appendChild(head);
|
||
|
||
const rows = [
|
||
["Transliteration", entry.xlit],
|
||
["Pronunciation", entry.pron],
|
||
["Derivation", entry.derivation],
|
||
["Strong's Definition", entry.strongs_def],
|
||
["KJV Definition", entry.kjv_def]
|
||
].filter(([, value]) => String(value || "").trim());
|
||
|
||
if (rows.length) {
|
||
const dl = document.createElement("dl");
|
||
dl.className = "alpha-dl";
|
||
rows.forEach(([label, value]) => {
|
||
const dt = document.createElement("dt");
|
||
dt.textContent = label;
|
||
const dd = document.createElement("dd");
|
||
dd.textContent = String(value || "").trim();
|
||
dl.append(dt, dd);
|
||
});
|
||
lexiconPopupBodyEl.appendChild(dl);
|
||
}
|
||
|
||
const occurrenceHint = document.createElement("p");
|
||
occurrenceHint.className = "alpha-text-lexicon-hint";
|
||
occurrenceHint.textContent = "Click the Strong's number to show verses that use this entry.";
|
||
lexiconPopupBodyEl.appendChild(occurrenceHint);
|
||
|
||
if (state.lexiconOccurrenceVisible) {
|
||
const occurrenceSection = document.createElement("section");
|
||
occurrenceSection.className = "alpha-text-lexicon-occurrences";
|
||
|
||
const occurrenceTitle = document.createElement("strong");
|
||
occurrenceTitle.textContent = "Verse Occurrences";
|
||
occurrenceSection.appendChild(occurrenceTitle);
|
||
|
||
if (state.lexiconOccurrenceLoading) {
|
||
occurrenceSection.appendChild(createEmptyMessage(`Loading verses for ${payload.entryId}...`));
|
||
} else if (state.lexiconOccurrenceError) {
|
||
occurrenceSection.appendChild(createEmptyMessage(state.lexiconOccurrenceError));
|
||
} else {
|
||
const occurrencePayload = state.lexiconOccurrenceResults;
|
||
const totalMatches = Number(occurrencePayload?.totalMatches) || 0;
|
||
const summary = document.createElement("p");
|
||
summary.className = "alpha-text-search-summary";
|
||
summary.textContent = totalMatches
|
||
? `${totalMatches} verses use ${payload.entryId}.${occurrencePayload?.truncated ? ` Showing the first ${occurrencePayload.resultCount} results.` : ""}`
|
||
: `No verses found for ${payload.entryId}.`;
|
||
occurrenceSection.appendChild(summary);
|
||
|
||
if (Array.isArray(occurrencePayload?.results) && occurrencePayload.results.length) {
|
||
const occurrenceList = document.createElement("div");
|
||
occurrenceList.className = "alpha-text-lexicon-occurrence-list";
|
||
|
||
occurrencePayload.results.forEach((result) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "alpha-text-lexicon-occurrence";
|
||
|
||
const headRow = document.createElement("div");
|
||
headRow.className = "alpha-text-search-result-head";
|
||
|
||
const reference = document.createElement("span");
|
||
reference.className = "alpha-text-search-reference";
|
||
reference.textContent = result.reference || `${result.workTitle} ${result.sectionLabel}:${result.verseNumber}`;
|
||
|
||
const location = document.createElement("span");
|
||
location.className = "alpha-text-search-location";
|
||
location.textContent = `${result.sourceShortTitle || result.sourceTitle} · ${result.workTitle} · ${result.sectionLabel}`;
|
||
|
||
const preview = document.createElement("p");
|
||
preview.className = "alpha-text-search-preview alpha-text-search-preview--compact";
|
||
appendLexiconOccurrencePreview(preview, result);
|
||
|
||
button.addEventListener("click", () => {
|
||
void openLexiconOccurrence(result);
|
||
});
|
||
|
||
headRow.append(reference, location);
|
||
button.append(headRow, preview);
|
||
occurrenceList.appendChild(button);
|
||
});
|
||
|
||
occurrenceSection.appendChild(occurrenceList);
|
||
}
|
||
}
|
||
|
||
lexiconPopupBodyEl.appendChild(occurrenceSection);
|
||
}
|
||
}
|
||
|
||
lexiconPopupEl.hidden = false;
|
||
lexiconPopupEl.setAttribute("aria-hidden", "false");
|
||
|
||
if (wasHidden && lexiconPopupCloseEl instanceof HTMLButtonElement) {
|
||
requestAnimationFrame(() => {
|
||
lexiconPopupCloseEl.focus();
|
||
});
|
||
}
|
||
}
|
||
|
||
async function loadLexiconEntry(lexiconId, entryId, triggerElement) {
|
||
if (!lexiconId || !entryId) {
|
||
return;
|
||
}
|
||
|
||
if (triggerElement instanceof HTMLElement) {
|
||
lexiconReturnFocusEl = triggerElement;
|
||
}
|
||
|
||
const requestId = state.lexiconRequestId + 1;
|
||
state.lexiconRequestId = requestId;
|
||
clearLexiconOccurrenceState();
|
||
state.lexiconEntry = {
|
||
loading: true,
|
||
lexiconId,
|
||
entryId: String(entryId).toUpperCase()
|
||
};
|
||
renderDetail();
|
||
|
||
try {
|
||
const payload = await dataService.loadTextLexiconEntry?.(lexiconId, entryId);
|
||
if (requestId !== state.lexiconRequestId) {
|
||
return;
|
||
}
|
||
state.lexiconEntry = payload;
|
||
renderDetail();
|
||
} catch (error) {
|
||
if (requestId !== state.lexiconRequestId) {
|
||
return;
|
||
}
|
||
state.lexiconEntry = {
|
||
error: error?.message || "Unable to load lexicon entry.",
|
||
lexiconId,
|
||
entryId: String(entryId).toUpperCase()
|
||
};
|
||
renderDetail();
|
||
}
|
||
}
|
||
|
||
function createMetaGrid(passage) {
|
||
const source = passage?.source || getSelectedSource();
|
||
const work = passage?.work || getSelectedWork(source);
|
||
const section = passage?.section || getSelectedSection(source, work);
|
||
const displayPreferences = getSourceDisplayPreferences(source, passage);
|
||
const metaGrid = document.createElement("div");
|
||
metaGrid.className = "alpha-text-meta-grid";
|
||
|
||
const overviewCard = createCard("Source Overview");
|
||
overviewCard.innerHTML += `
|
||
<dl class="alpha-dl">
|
||
<dt>Source</dt><dd>${source?.title || "--"}</dd>
|
||
<dt>Tradition</dt><dd>${source?.tradition || "--"}</dd>
|
||
<dt>Language</dt><dd>${source?.language || "--"}</dd>
|
||
<dt>Script</dt><dd>${source?.script || "--"}</dd>
|
||
<dt>${source?.workLabel || "Work"}</dt><dd>${work?.title || "--"}</dd>
|
||
<dt>${source?.sectionLabel || "Section"}</dt><dd>${section?.label || "--"}</dd>
|
||
</dl>
|
||
`;
|
||
metaGrid.appendChild(overviewCard);
|
||
|
||
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) {
|
||
const source = getSelectedSource();
|
||
const displayPreferences = getSourceDisplayPreferences(source, state.currentPassage);
|
||
const translationText = verse.text || "";
|
||
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
||
const article = document.createElement("article");
|
||
article.className = "alpha-text-verse";
|
||
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "alpha-text-verse-head";
|
||
|
||
const reference = document.createElement("span");
|
||
reference.className = "alpha-text-verse-reference";
|
||
reference.textContent = verse.reference || (verse.number ? `Verse ${verse.number}` : "");
|
||
|
||
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);
|
||
return article;
|
||
}
|
||
|
||
function buildTokenTranslationText(tokens, fallbackText) {
|
||
const glossText = (Array.isArray(tokens) ? tokens : [])
|
||
.map((token) => String(token?.gloss || "").trim())
|
||
.filter(Boolean)
|
||
.join(" ")
|
||
.replace(/\s+([,.;:!?])/g, "$1")
|
||
.trim();
|
||
|
||
return glossText || String(fallbackText || "").trim();
|
||
}
|
||
|
||
function appendVerseTextLines(target, verse, source, displayPreferences, translationText) {
|
||
if (!(target instanceof HTMLElement)) {
|
||
return;
|
||
}
|
||
|
||
const mode = displayPreferences?.textMode || "translation";
|
||
const originalText = normalizeTextValue(verse?.originalText);
|
||
const transliterationText = getVerseTransliteration(verse, source);
|
||
const lines = [];
|
||
|
||
const appendLine = (text, variant) => {
|
||
const normalizedText = normalizeTextValue(text);
|
||
if (!normalizedText || lines.some((entry) => entry.text === normalizedText)) {
|
||
return;
|
||
}
|
||
lines.push({ text: normalizedText, variant });
|
||
};
|
||
|
||
if (mode === "all") {
|
||
appendLine(translationText, "translation");
|
||
appendLine(originalText, "original");
|
||
appendLine(transliterationText, "transliteration");
|
||
} else if (mode === "original") {
|
||
appendLine(originalText || translationText, originalText ? "original" : "translation");
|
||
} else if (mode === "transliteration") {
|
||
appendLine(transliterationText || translationText, transliterationText ? "transliteration" : "translation");
|
||
} else {
|
||
appendLine(translationText, "translation");
|
||
}
|
||
|
||
if (!lines.length) {
|
||
appendLine(translationText, "translation");
|
||
}
|
||
|
||
lines.forEach((line) => {
|
||
const text = document.createElement("p");
|
||
text.className = `alpha-text-verse-text alpha-text-verse-text--${line.variant}`;
|
||
appendHighlightedText(text, line.text, isHighlightedVerse(verse) ? state.searchQuery : "");
|
||
target.appendChild(text);
|
||
});
|
||
}
|
||
|
||
function createTokenVerse(verse, lexiconId, displayPreferences, source) {
|
||
const translationText = buildTokenTranslationText(verse?.tokens, verse?.text);
|
||
const verseCounts = getTextCounts(extractVerseCountText(verse, source, displayPreferences, translationText));
|
||
const article = document.createElement("article");
|
||
article.className = "alpha-text-verse";
|
||
article.classList.toggle("alpha-text-verse--interlinear", Boolean(displayPreferences?.showInterlinear));
|
||
article.classList.toggle("is-highlighted", isHighlightedVerse(verse));
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "alpha-text-verse-head";
|
||
|
||
const reference = document.createElement("span");
|
||
reference.className = "alpha-text-verse-reference";
|
||
reference.textContent = verse.reference || (verse.number ? `Verse ${verse.number}` : "");
|
||
|
||
const 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);
|
||
if (displayPreferences?.showInterlinear) {
|
||
article.appendChild(tokenGrid);
|
||
}
|
||
return article;
|
||
}
|
||
|
||
function createReaderCard(passage) {
|
||
const source = passage?.source || getSelectedSource();
|
||
const displayPreferences = getSourceDisplayPreferences(source, passage);
|
||
const card = createCard(getPassageLocationLabel(passage));
|
||
card.classList.add("alpha-text-reader-card");
|
||
const reader = document.createElement("div");
|
||
reader.className = "alpha-text-reader";
|
||
|
||
if (passage?.errorMessage) {
|
||
reader.appendChild(createEmptyMessage(passage.errorMessage));
|
||
card.appendChild(reader);
|
||
return card;
|
||
}
|
||
|
||
const verses = Array.isArray(passage?.verses) ? passage.verses : [];
|
||
if (!verses.length) {
|
||
reader.appendChild(createEmptyMessage("No verses were found for this section."));
|
||
card.appendChild(reader);
|
||
return card;
|
||
}
|
||
|
||
verses.forEach((verse) => {
|
||
const verseEl = source?.features?.hasTokenAnnotations
|
||
? createTokenVerse(verse, source.features.lexiconIds?.[0] || "", displayPreferences, source)
|
||
: createPlainVerse(verse);
|
||
reader.appendChild(verseEl);
|
||
});
|
||
|
||
card.appendChild(reader);
|
||
|
||
const navigation = document.createElement("div");
|
||
navigation.className = "alpha-text-reader-navigation";
|
||
|
||
if (passage?.navigation?.previous) {
|
||
const previousButton = document.createElement("button");
|
||
previousButton.type = "button";
|
||
previousButton.className = "alpha-nav-btn alpha-text-reader-nav-btn";
|
||
previousButton.textContent = "← Previous";
|
||
previousButton.addEventListener("click", () => {
|
||
navigateToPassageTarget(passage.navigation.previous);
|
||
});
|
||
navigation.appendChild(previousButton);
|
||
}
|
||
|
||
if (passage?.navigation?.next) {
|
||
const nextButton = document.createElement("button");
|
||
nextButton.type = "button";
|
||
nextButton.className = "alpha-nav-btn alpha-text-reader-nav-btn alpha-text-reader-nav-btn--next";
|
||
nextButton.textContent = "Next →";
|
||
nextButton.addEventListener("click", () => {
|
||
navigateToPassageTarget(passage.navigation.next);
|
||
});
|
||
navigation.appendChild(nextButton);
|
||
}
|
||
|
||
if (navigation.childElementCount) {
|
||
card.appendChild(navigation);
|
||
}
|
||
|
||
return card;
|
||
}
|
||
|
||
function createSearchCard() {
|
||
const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery;
|
||
if (!hasSearchState) {
|
||
return null;
|
||
}
|
||
|
||
const card = createCard("Search Results");
|
||
const scopeLabel = state.activeSearchScope === "global"
|
||
? "all texts"
|
||
: (state.searchResults?.scope?.source?.title || getSelectedSource()?.title || "current source");
|
||
|
||
const summary = document.createElement("p");
|
||
summary.className = "alpha-text-search-summary";
|
||
|
||
if (state.searchLoading) {
|
||
summary.textContent = `Searching ${scopeLabel} for \"${state.searchQuery}\"...`;
|
||
card.appendChild(summary);
|
||
return card;
|
||
}
|
||
|
||
if (state.searchError) {
|
||
summary.textContent = `Search scope: ${scopeLabel}`;
|
||
card.append(summary, createEmptyMessage(state.searchError));
|
||
return card;
|
||
}
|
||
|
||
const payload = state.searchResults;
|
||
const totalMatches = Number(payload?.totalMatches) || 0;
|
||
const truncatedNote = payload?.truncated ? ` Showing the first ${payload.resultCount} results.` : "";
|
||
summary.textContent = `${totalMatches} matches in ${scopeLabel}.${truncatedNote}`;
|
||
card.appendChild(summary);
|
||
|
||
if (!Array.isArray(payload?.results) || !payload.results.length) {
|
||
card.appendChild(createEmptyMessage(`No matches found for \"${state.searchQuery}\".`));
|
||
return card;
|
||
}
|
||
|
||
const resultsEl = document.createElement("div");
|
||
resultsEl.className = "alpha-text-search-results";
|
||
|
||
payload.results.forEach((result) => {
|
||
const button = document.createElement("button");
|
||
button.type = "button";
|
||
button.className = "alpha-text-search-result";
|
||
button.classList.toggle(
|
||
"is-active",
|
||
normalizeId(result?.sourceId) === normalizeId(state.selectedSourceId)
|
||
&& normalizeId(result?.workId) === normalizeId(state.selectedWorkId)
|
||
&& normalizeId(result?.sectionId) === normalizeId(state.selectedSectionId)
|
||
&& normalizeId(result?.verseId) === normalizeId(state.highlightedVerseId)
|
||
);
|
||
|
||
const head = document.createElement("div");
|
||
head.className = "alpha-text-search-result-head";
|
||
|
||
const reference = document.createElement("span");
|
||
reference.className = "alpha-text-search-reference";
|
||
reference.textContent = result.reference || `${result.workTitle} ${result.sectionLabel}:${result.verseNumber}`;
|
||
|
||
const location = document.createElement("span");
|
||
location.className = "alpha-text-search-location";
|
||
location.textContent = state.activeSearchScope === "global"
|
||
? `${result.sourceShortTitle || result.sourceTitle} · ${result.workTitle} · ${result.sectionLabel}`
|
||
: `${result.workTitle} · ${result.sectionLabel}`;
|
||
|
||
const preview = document.createElement("p");
|
||
preview.className = "alpha-text-search-preview";
|
||
appendHighlightedText(preview, result.preview || result.reference || "", state.searchQuery);
|
||
|
||
button.addEventListener("click", () => {
|
||
void openSearchResult(result);
|
||
});
|
||
|
||
head.append(reference, location);
|
||
button.append(head, preview);
|
||
resultsEl.appendChild(button);
|
||
});
|
||
|
||
card.appendChild(resultsEl);
|
||
return card;
|
||
}
|
||
|
||
function isGlobalSearchOnlyMode() {
|
||
return (state.activeSearchScope === "global" || state.activeSearchScope === "source")
|
||
&& Boolean(state.searchQuery)
|
||
&& !state.highlightedVerseId;
|
||
}
|
||
|
||
function renderDetail() {
|
||
const source = getSelectedSource();
|
||
const work = getSelectedWork(source);
|
||
const section = getSelectedSection(source, work);
|
||
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"
|
||
: `${source.title} · ${work.title}`;
|
||
}
|
||
if (!detailBodyEl) {
|
||
return;
|
||
}
|
||
|
||
detailBodyEl.replaceChildren();
|
||
|
||
const searchCard = createSearchCard();
|
||
if (searchCard) {
|
||
detailBodyEl.appendChild(searchCard);
|
||
}
|
||
|
||
if (globalSearchOnlyMode) {
|
||
renderLexiconPopup();
|
||
return;
|
||
}
|
||
|
||
if (!state.currentPassage) {
|
||
const loadingCard = createCard("Text Reader");
|
||
loadingCard.appendChild(createEmptyMessage("Loading section…"));
|
||
detailBodyEl.appendChild(loadingCard);
|
||
renderLexiconPopup();
|
||
return;
|
||
}
|
||
|
||
detailBodyEl.appendChild(createMetaGrid(state.currentPassage));
|
||
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
|
||
renderLexiconPopup();
|
||
}
|
||
|
||
async function loadSelectedPassage() {
|
||
const source = getSelectedSource();
|
||
const work = getSelectedWork(source);
|
||
const section = getSelectedSection(source, work);
|
||
if (!source || !work || !section) {
|
||
state.currentPassage = null;
|
||
renderDetail();
|
||
return;
|
||
}
|
||
|
||
state.currentPassage = null;
|
||
renderDetail();
|
||
|
||
try {
|
||
state.currentPassage = await dataService.loadTextSection?.(source.id, work.id, section.id);
|
||
renderDetail();
|
||
if (state.highlightedVerseId) {
|
||
requestAnimationFrame(scrollHighlightedVerseIntoView);
|
||
}
|
||
} catch (error) {
|
||
state.currentPassage = {
|
||
source,
|
||
work,
|
||
section,
|
||
verses: [],
|
||
errorMessage: error?.message || "Unable to load this section."
|
||
};
|
||
renderDetail();
|
||
}
|
||
}
|
||
|
||
async function runSearch(scope, forceRefresh = false) {
|
||
const searchFn = dataService.searchTextLibrary;
|
||
if (typeof searchFn !== "function") {
|
||
state.searchError = "Text search is unavailable.";
|
||
state.searchLoading = false;
|
||
state.searchResults = null;
|
||
renderDetail();
|
||
return;
|
||
}
|
||
|
||
const normalizedScope = scope === "source" ? "source" : "global";
|
||
const query = String(getSearchInput(normalizedScope)?.value || getStoredSearchQuery(normalizedScope) || "").trim();
|
||
setStoredSearchQuery(normalizedScope, query);
|
||
state.activeSearchScope = normalizedScope;
|
||
state.searchQuery = query;
|
||
state.searchError = "";
|
||
state.searchResults = null;
|
||
state.highlightedVerseId = "";
|
||
updateSearchControls();
|
||
|
||
if (!query) {
|
||
clearSearchState();
|
||
renderDetail();
|
||
return;
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
state.selectedSourceId = result.sourceId;
|
||
state.selectedWorkId = result.workId;
|
||
state.selectedSectionId = result.sectionId;
|
||
state.highlightedVerseId = result.verseId;
|
||
dismissLexiconEntry({ restoreFocus: false });
|
||
syncSelectionForSource(getSelectedSource());
|
||
renderSourceList();
|
||
renderSelectors();
|
||
showDetailOnlyMode();
|
||
await loadSelectedPassage();
|
||
clearActiveSearchUi({ preserveHighlight: true });
|
||
renderDetail();
|
||
}
|
||
|
||
function bindControls() {
|
||
if (state.initialized) {
|
||
return;
|
||
}
|
||
|
||
if (globalSearchFormEl instanceof HTMLFormElement) {
|
||
globalSearchFormEl.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
void runSearch("global");
|
||
});
|
||
}
|
||
|
||
if (globalSearchInputEl instanceof HTMLInputElement) {
|
||
globalSearchInputEl.addEventListener("input", () => {
|
||
state.globalSearchQuery = String(globalSearchInputEl.value || "").trim();
|
||
updateSearchControls();
|
||
if (!state.globalSearchQuery && state.activeSearchScope === "global" && state.searchQuery) {
|
||
clearSearchState();
|
||
renderDetail();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (localSearchFormEl instanceof HTMLFormElement) {
|
||
localSearchFormEl.addEventListener("submit", (event) => {
|
||
event.preventDefault();
|
||
void runSearch("source");
|
||
});
|
||
}
|
||
|
||
if (localSearchInputEl instanceof HTMLInputElement) {
|
||
localSearchInputEl.addEventListener("input", () => {
|
||
state.localSearchQuery = String(localSearchInputEl.value || "").trim();
|
||
updateSearchControls();
|
||
if (!state.localSearchQuery && state.activeSearchScope === "source" && state.searchQuery) {
|
||
clearSearchState();
|
||
renderDetail();
|
||
}
|
||
});
|
||
}
|
||
|
||
if (workSelectEl) {
|
||
workSelectEl.addEventListener("change", () => {
|
||
state.selectedWorkId = String(workSelectEl.value || "");
|
||
const source = getSelectedSource();
|
||
syncSelectionForSource(source);
|
||
state.currentPassage = null;
|
||
state.lexiconEntry = null;
|
||
state.highlightedVerseId = "";
|
||
renderSelectors();
|
||
void loadSelectedPassage();
|
||
});
|
||
}
|
||
|
||
if (sectionSelectEl) {
|
||
sectionSelectEl.addEventListener("change", () => {
|
||
state.selectedSectionId = String(sectionSelectEl.value || "");
|
||
state.currentPassage = null;
|
||
state.lexiconEntry = null;
|
||
state.highlightedVerseId = "";
|
||
void loadSelectedPassage();
|
||
});
|
||
}
|
||
|
||
document.addEventListener("keydown", (event) => {
|
||
if (event.key === "Escape" && state.lexiconEntry) {
|
||
closeLexiconEntry();
|
||
}
|
||
});
|
||
|
||
state.initialized = true;
|
||
}
|
||
|
||
async function ensureAlphabetTextSection() {
|
||
getElements();
|
||
bindControls();
|
||
window.TarotChromeUi?.initializeSidebarPopouts?.();
|
||
window.TarotChromeUi?.initializeDetailPopouts?.();
|
||
|
||
if (!sourceListEl || !detailBodyEl) {
|
||
return;
|
||
}
|
||
|
||
await ensureCatalogLoaded();
|
||
renderSourceList();
|
||
renderSelectors();
|
||
updateSearchControls();
|
||
|
||
if (!state.currentPassage) {
|
||
await loadSelectedPassage();
|
||
return;
|
||
}
|
||
|
||
renderDetail();
|
||
}
|
||
|
||
function resetState() {
|
||
state.catalog = null;
|
||
state.currentPassage = null;
|
||
state.lexiconEntry = null;
|
||
state.selectedSourceId = "";
|
||
state.selectedWorkId = "";
|
||
state.selectedSectionId = "";
|
||
state.lexiconRequestId = 0;
|
||
state.lexiconOccurrenceResults = null;
|
||
state.lexiconOccurrenceLoading = false;
|
||
state.lexiconOccurrenceError = "";
|
||
state.lexiconOccurrenceVisible = false;
|
||
state.lexiconOccurrenceRequestId = 0;
|
||
state.globalSearchQuery = "";
|
||
state.localSearchQuery = "";
|
||
state.activeSearchScope = "global";
|
||
state.searchQuery = "";
|
||
state.searchResults = null;
|
||
state.searchLoading = false;
|
||
state.searchError = "";
|
||
state.searchRequestId = 0;
|
||
state.highlightedVerseId = "";
|
||
lexiconReturnFocusEl = null;
|
||
|
||
if (globalSearchInputEl instanceof HTMLInputElement) {
|
||
globalSearchInputEl.value = "";
|
||
}
|
||
|
||
if (localSearchInputEl instanceof HTMLInputElement) {
|
||
localSearchInputEl.value = "";
|
||
}
|
||
|
||
updateSearchControls();
|
||
renderLexiconPopup();
|
||
}
|
||
|
||
document.addEventListener("connection:updated", resetState);
|
||
|
||
window.AlphabetTextUi = {
|
||
ensureAlphabetTextSection
|
||
};
|
||
})(); |