Files
TaroTime/app/ui-alphabet-text.js
2026-03-09 23:27:03 -07:00

1322 lines
43 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: ""
};
let sourceListEl;
let sourceCountEl;
let globalSearchFormEl;
let globalSearchInputEl;
let globalSearchClearEl;
let localSearchFormEl;
let localSearchInputEl;
let localSearchClearEl;
let workSelectEl;
let sectionSelectEl;
let detailNameEl;
let detailSubEl;
let detailBodyEl;
let lexiconPopupEl;
let lexiconPopupTitleEl;
let lexiconPopupSubtitleEl;
let lexiconPopupBodyEl;
let lexiconPopupCloseEl;
let lexiconReturnFocusEl = null;
function normalizeId(value) {
return String(value || "")
.trim()
.toLowerCase();
}
function getElements() {
sourceListEl = document.getElementById("alpha-text-source-list");
sourceCountEl = document.getElementById("alpha-text-source-count");
globalSearchFormEl = document.getElementById("alpha-text-global-search-form");
globalSearchInputEl = document.getElementById("alpha-text-global-search-input");
globalSearchClearEl = document.getElementById("alpha-text-global-search-clear");
localSearchFormEl = document.getElementById("alpha-text-local-search-form");
localSearchInputEl = document.getElementById("alpha-text-local-search-input");
localSearchClearEl = document.getElementById("alpha-text-local-search-clear");
workSelectEl = document.getElementById("alpha-text-work-select");
sectionSelectEl = document.getElementById("alpha-text-section-select");
detailNameEl = document.getElementById("alpha-text-detail-name");
detailSubEl = document.getElementById("alpha-text-detail-sub");
detailBodyEl = document.getElementById("alpha-text-detail-body");
ensureLexiconPopup();
}
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 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() {
if (globalSearchClearEl instanceof HTMLButtonElement) {
globalSearchClearEl.disabled = !String(globalSearchInputEl?.value || state.globalSearchQuery || "").trim();
}
if (localSearchClearEl instanceof HTMLButtonElement) {
localSearchClearEl.disabled = !String(localSearchInputEl?.value || state.localSearchQuery || "").trim();
}
}
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 = "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 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)) {
return;
}
state.selectedSourceId = source.id;
state.currentPassage = null;
state.lexiconEntry = null;
state.highlightedVerseId = "";
syncSelectionForSource(getSelectedSource());
renderSourceList();
renderSelectors();
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 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 navigationCard = createCard("Navigation");
const toolbar = document.createElement("div");
toolbar.className = "alpha-text-toolbar";
const previousButton = document.createElement("button");
previousButton.type = "button";
previousButton.className = "alpha-nav-btn";
previousButton.textContent = "← Previous";
previousButton.disabled = !passage?.navigation?.previous;
previousButton.addEventListener("click", () => {
if (!passage?.navigation?.previous) {
return;
}
state.selectedWorkId = passage.navigation.previous.workId;
state.selectedSectionId = passage.navigation.previous.sectionId;
state.lexiconEntry = null;
renderSelectors();
void loadSelectedPassage();
});
const nextButton = document.createElement("button");
nextButton.type = "button";
nextButton.className = "alpha-nav-btn";
nextButton.textContent = "Next →";
nextButton.disabled = !passage?.navigation?.next;
nextButton.addEventListener("click", () => {
if (!passage?.navigation?.next) {
return;
}
state.selectedWorkId = passage.navigation.next.workId;
state.selectedSectionId = passage.navigation.next.sectionId;
state.lexiconEntry = null;
renderSelectors();
void loadSelectedPassage();
});
const location = document.createElement("div");
location.className = "planet-text";
location.textContent = `${work?.title || "--"} · ${section?.title || "--"}`;
toolbar.append(previousButton, nextButton);
navigationCard.append(toolbar, location);
metaGrid.appendChild(navigationCard);
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 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 text = document.createElement("p");
text.className = "alpha-text-verse-text";
appendHighlightedText(text, verse.text || "", isHighlightedVerse(verse) ? state.searchQuery : "");
head.append(reference);
article.append(head, text);
return article;
}
function buildTokenTranslationText(tokens, fallbackText) {
const glossText = (Array.isArray(tokens) ? tokens : [])
.map((token) => String(token?.gloss || "").trim())
.filter(Boolean)
.join(" ")
.replace(/\s+([,.;:!?])/g, "$1")
.trim();
return glossText || String(fallbackText || "").trim();
}
function createTokenVerse(verse, lexiconId) {
const article = document.createElement("article");
article.className = "alpha-text-verse alpha-text-verse--interlinear";
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 gloss = document.createElement("p");
gloss.className = "alpha-text-verse-text";
appendHighlightedText(
gloss,
buildTokenTranslationText(verse?.tokens, verse?.text),
isHighlightedVerse(verse) ? state.searchQuery : ""
);
const tokenGrid = document.createElement("div");
tokenGrid.className = "alpha-text-token-grid";
(Array.isArray(verse?.tokens) ? verse.tokens : []).forEach((token) => {
const strongId = Array.isArray(token?.strongs) ? token.strongs[0] : "";
const tokenEl = document.createElement(strongId ? "button" : "div");
tokenEl.className = `alpha-text-token${strongId ? " alpha-text-token--interactive" : ""}`;
if (tokenEl instanceof HTMLButtonElement) {
tokenEl.type = "button";
tokenEl.addEventListener("click", () => {
void loadLexiconEntry(lexiconId, strongId, tokenEl);
});
}
const glossEl = document.createElement("span");
glossEl.className = "alpha-text-token-gloss";
glossEl.textContent = token?.gloss || "—";
const originalEl = document.createElement("span");
originalEl.className = "alpha-text-token-original";
originalEl.textContent = token?.original || "—";
tokenEl.append(glossEl, originalEl);
if (strongId) {
const strongsEl = document.createElement("span");
strongsEl.className = "alpha-text-token-strongs";
strongsEl.textContent = Array.isArray(token.strongs) ? token.strongs.join(" · ") : strongId;
tokenEl.appendChild(strongsEl);
}
tokenGrid.appendChild(tokenEl);
});
head.append(reference);
article.append(head, gloss, tokenGrid);
return article;
}
function createReaderCard(passage) {
const source = passage?.source || getSelectedSource();
const card = createCard(`${source?.title || "Text"} Reader`);
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] || "")
: createPlainVerse(verse);
reader.appendChild(verseEl);
});
card.appendChild(reader);
return card;
}
function createSearchCard() {
const hasSearchState = state.searchLoading || state.searchError || state.searchResults || state.searchQuery;
if (!hasSearchState) {
return null;
}
const card = createCard("Search Results");
const scopeLabel = state.activeSearchScope === "global"
? "all texts"
: (state.searchResults?.scope?.source?.title || getSelectedSource()?.title || "current source");
const summary = document.createElement("p");
summary.className = "alpha-text-search-summary";
if (state.searchLoading) {
summary.textContent = `Searching ${scopeLabel} for \"${state.searchQuery}\"...`;
card.appendChild(summary);
return card;
}
if (state.searchError) {
summary.textContent = `Search scope: ${scopeLabel}`;
card.append(summary, createEmptyMessage(state.searchError));
return card;
}
const payload = state.searchResults;
const totalMatches = Number(payload?.totalMatches) || 0;
const truncatedNote = payload?.truncated ? ` Showing the first ${payload.resultCount} results.` : "";
summary.textContent = `${totalMatches} matches in ${scopeLabel}.${truncatedNote}`;
card.appendChild(summary);
if (!Array.isArray(payload?.results) || !payload.results.length) {
card.appendChild(createEmptyMessage(`No matches found for \"${state.searchQuery}\".`));
return card;
}
const resultsEl = document.createElement("div");
resultsEl.className = "alpha-text-search-results";
payload.results.forEach((result) => {
const button = document.createElement("button");
button.type = "button";
button.className = "alpha-text-search-result";
button.classList.toggle(
"is-active",
normalizeId(result?.sourceId) === normalizeId(state.selectedSourceId)
&& normalizeId(result?.workId) === normalizeId(state.selectedWorkId)
&& normalizeId(result?.sectionId) === normalizeId(state.selectedSectionId)
&& normalizeId(result?.verseId) === normalizeId(state.highlightedVerseId)
);
const head = document.createElement("div");
head.className = "alpha-text-search-result-head";
const reference = document.createElement("span");
reference.className = "alpha-text-search-reference";
reference.textContent = result.reference || `${result.workTitle} ${result.sectionLabel}:${result.verseNumber}`;
const location = document.createElement("span");
location.className = "alpha-text-search-location";
location.textContent = state.activeSearchScope === "global"
? `${result.sourceShortTitle || result.sourceTitle} · ${result.workTitle} · ${result.sectionLabel}`
: `${result.workTitle} · ${result.sectionLabel}`;
const preview = document.createElement("p");
preview.className = "alpha-text-search-preview";
appendHighlightedText(preview, result.preview || result.reference || "", state.searchQuery);
button.addEventListener("click", () => {
void openSearchResult(result);
});
head.append(reference, location);
button.append(head, preview);
resultsEl.appendChild(button);
});
card.appendChild(resultsEl);
return card;
}
function isGlobalSearchOnlyMode() {
return state.activeSearchScope === "global"
&& Boolean(state.searchQuery)
&& !state.highlightedVerseId;
}
function renderDetail() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
const globalSearchOnlyMode = isGlobalSearchOnlyMode();
if (!source || !work || !section) {
renderPlaceholder("Text Reader", "Select a source to begin", "Choose a text source and section from the left panel.");
renderLexiconPopup();
return;
}
if (detailNameEl) {
detailNameEl.textContent = globalSearchOnlyMode
? `Global Search${state.searchQuery ? `: ${state.searchQuery}` : ""}`
: (state.currentPassage?.section?.title || section.title);
}
if (detailSubEl) {
detailSubEl.textContent = globalSearchOnlyMode
? "All text sources"
: `${source.title} · ${work.title}`;
}
if (!detailBodyEl) {
return;
}
detailBodyEl.replaceChildren();
const searchCard = createSearchCard();
if (searchCard) {
detailBodyEl.appendChild(searchCard);
}
if (globalSearchOnlyMode) {
renderLexiconPopup();
return;
}
if (!state.currentPassage) {
const loadingCard = createCard("Text Reader");
loadingCard.appendChild(createEmptyMessage("Loading section…"));
detailBodyEl.appendChild(loadingCard);
renderLexiconPopup();
return;
}
detailBodyEl.appendChild(createMetaGrid(state.currentPassage));
detailBodyEl.appendChild(createReaderCard(state.currentPassage));
renderLexiconPopup();
}
async function loadSelectedPassage() {
const source = getSelectedSource();
const work = getSelectedWork(source);
const section = getSelectedSection(source, work);
if (!source || !work || !section) {
state.currentPassage = null;
renderDetail();
return;
}
state.currentPassage = null;
renderDetail();
try {
state.currentPassage = await dataService.loadTextSection?.(source.id, work.id, section.id);
renderDetail();
if (state.highlightedVerseId) {
requestAnimationFrame(scrollHighlightedVerseIntoView);
}
} catch (error) {
state.currentPassage = {
source,
work,
section,
verses: [],
errorMessage: error?.message || "Unable to load this section."
};
renderDetail();
}
}
async function runSearch(scope, forceRefresh = false) {
const searchFn = dataService.searchTextLibrary;
if (typeof searchFn !== "function") {
state.searchError = "Text search is unavailable.";
state.searchLoading = false;
state.searchResults = null;
renderDetail();
return;
}
const normalizedScope = scope === "source" ? "source" : "global";
const query = String(getSearchInput(normalizedScope)?.value || getStoredSearchQuery(normalizedScope) || "").trim();
setStoredSearchQuery(normalizedScope, query);
state.activeSearchScope = normalizedScope;
state.searchQuery = query;
state.searchError = "";
state.searchResults = null;
state.highlightedVerseId = "";
updateSearchControls();
if (!query) {
clearSearchState();
renderDetail();
return;
}
const requestId = state.searchRequestId + 1;
state.searchRequestId = requestId;
state.searchLoading = true;
renderDetail();
try {
const payload = await searchFn(query, {
sourceId: normalizedScope === "source" ? state.selectedSourceId : "",
limit: 50
}, forceRefresh);
if (requestId !== state.searchRequestId) {
return;
}
state.searchResults = payload;
state.searchLoading = false;
renderDetail();
} catch (error) {
if (requestId !== state.searchRequestId) {
return;
}
state.searchLoading = false;
state.searchError = error?.message || "Unable to search this text library.";
renderDetail();
}
}
async function openSearchResult(result) {
if (!result) {
return;
}
state.selectedSourceId = result.sourceId;
state.selectedWorkId = result.workId;
state.selectedSectionId = result.sectionId;
state.highlightedVerseId = result.verseId;
dismissLexiconEntry({ restoreFocus: false });
syncSelectionForSource(getSelectedSource());
renderSourceList();
renderSelectors();
await loadSelectedPassage();
}
function bindControls() {
if (state.initialized) {
return;
}
if (globalSearchFormEl instanceof HTMLFormElement) {
globalSearchFormEl.addEventListener("submit", (event) => {
event.preventDefault();
void runSearch("global");
});
}
if (globalSearchInputEl instanceof HTMLInputElement) {
globalSearchInputEl.addEventListener("input", () => {
state.globalSearchQuery = String(globalSearchInputEl.value || "").trim();
updateSearchControls();
if (!state.globalSearchQuery && state.activeSearchScope === "global" && state.searchQuery) {
clearSearchState();
renderDetail();
}
});
}
if (globalSearchClearEl instanceof HTMLButtonElement) {
globalSearchClearEl.addEventListener("click", () => {
clearScopedSearch("global");
renderDetail();
});
}
if (localSearchFormEl instanceof HTMLFormElement) {
localSearchFormEl.addEventListener("submit", (event) => {
event.preventDefault();
void runSearch("source");
});
}
if (localSearchInputEl instanceof HTMLInputElement) {
localSearchInputEl.addEventListener("input", () => {
state.localSearchQuery = String(localSearchInputEl.value || "").trim();
updateSearchControls();
if (!state.localSearchQuery && state.activeSearchScope === "source" && state.searchQuery) {
clearSearchState();
renderDetail();
}
});
}
if (localSearchClearEl instanceof HTMLButtonElement) {
localSearchClearEl.addEventListener("click", () => {
clearScopedSearch("source");
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();
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
};
})();