Move word-study helpers into utils/wordStudy

Strong's dictionary loaders, lexicon lookup, gloss helpers, cross-ref
formatting, and speech move out of App. Dictionaries are now cached at
module level (still one download per visit), and a failed download is no
longer cached forever, so the next lookup retries.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Opus 5.5 committed 2026-09-22 14:49:21 -04:00
1 parent 8c55765ae6
commit d7bbbe60de
3 files changed
+213 -230

No files matched your search

+14 -230
View File
@@ -3,6 +3,20 @@ import { AppContext, useApp } from './context/AppContext.js';
import { useAudio } from './hooks/useAudio.js';
import { useAuth } from './hooks/useAuth.js';
import { useBibleReader } from './hooks/useBibleReader.js';
import {
loadNtGloss,
loadNtConcordance,
loadHebrewDict,
loadGreekDict,
GREEK_FUNCTION_WORDS,
shortHebrewGloss,
ENGLISH_STOP_WORDS,
externalLookupLinks,
buildGreekDefinitionHtml,
fetchBollsDefinition,
formatCrossRef,
speakOriginalWord,
} from './utils/wordStudy.js';
import HomePage from './pages/HomePage.jsx';
import {
@@ -235,27 +249,6 @@ const App = () => {
setCurrentPage('reader');
};
const speakOriginalWord = (word, strongsNum) => {
if (!window.speechSynthesis) return;
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
const voices = window.speechSynthesis.getVoices();
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
const utt = new SpeechSynthesisUtterance(word + '.');
utt.lang = lang;
utt.rate = 0.85;
if (match) utt.voice = match;
// Chain a silent tail so the audio session fades rather than clicks shut
utt.onend = () => {
const tail = new SpeechSynthesisUtterance('     ');
tail.volume = 0;
tail.lang = lang;
if (match) tail.voice = match;
window.speechSynthesis.speak(tail);
};
window.speechSynthesis.speak(utt);
};
const copyVerse = (bookName, chapterNum, verseNum, text) => {
const citation = `${bookName} ${chapterNum}:${verseNum} BSB — ${text}`;
navigator.clipboard?.writeText(citation);
@@ -987,15 +980,6 @@ const App = () => {
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
const _crossRefDatasetCacheRef = useRef({});
const formatCrossRef = (ref) => {
const book = bookOptions.find((b) => b.abbrev === ref.book);
const bookName = book?.name ?? ref.book;
const verseRange = ref.endVerse && ref.endVerse !== ref.verse
? `${ref.verse}-${ref.endVerse}`
: `${ref.verse}`;
return `${bookName} ${ref.chapter}:${verseRange}`;
};
const suggestCrossRefsForChunk = async (chunkId) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const chapterIndex = project?.chapters.findIndex((ch) =>
@@ -1139,76 +1123,6 @@ const App = () => {
}));
};
// Cache for the NT Strong's concordance (verse → Strong's numbers), loaded once
const _concordanceRef = useRef(null);
const _concordanceLoadingRef = useRef(null);
// Cache for the NT Strong's→English gloss map (from macula-greek dataset), loaded once
const _glossRef = useRef(null);
const _glossLoadingRef = useRef(null);
// Cache for the Hebrew Strong's dictionary (loaded once)
const _hebrewDictRef = useRef(null);
const _hebrewDictLoadingRef = useRef(null);
const loadNtGloss = async () => {
if (_glossRef.current) return _glossRef.current;
if (_glossLoadingRef.current) return _glossLoadingRef.current;
_glossLoadingRef.current = fetch('/nt-strongs-gloss.json')
.then((res) => res.json())
.then((data) => {
_glossRef.current = data;
_glossLoadingRef.current = null;
return data;
});
return _glossLoadingRef.current;
};
const loadNtConcordance = async () => {
if (_concordanceRef.current) return _concordanceRef.current;
if (_concordanceLoadingRef.current) return _concordanceLoadingRef.current;
_concordanceLoadingRef.current = fetch('/nt-strongs-concordance.json')
.then((res) => res.json())
.then((data) => {
_concordanceRef.current = data;
_concordanceLoadingRef.current = null;
return data;
});
return _concordanceLoadingRef.current;
};
const loadHebrewDict = async () => {
if (_hebrewDictRef.current) return _hebrewDictRef.current;
if (_hebrewDictLoadingRef.current) return _hebrewDictLoadingRef.current;
_hebrewDictLoadingRef.current = fetch(
'https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/hebrew/strongs-hebrew-dictionary.js',
)
.then((res) => res.text())
.then((text) => {
const start = text.indexOf('{');
const end = text.lastIndexOf('}') + 1;
const dict = JSON.parse(text.slice(start, end));
_hebrewDictRef.current = dict;
_hebrewDictLoadingRef.current = null;
return dict;
});
return _hebrewDictLoadingRef.current;
};
// Common Greek NT function words to pre-uncheck in the picker
// (articles, prepositions, conjunctions, particles, common pronouns)
const GREEK_FUNCTION_WORDS = new Set([
'G3588','G3739','G3748', // ὁ (the), ὅς (who/which), ὅστις
'G2532','G1161','G1063','G235','G3767','G2443','G5037','G3303', // καί, δέ, γάρ, ἀλλά, οὖν, ἵνα, τέ, μέν
'G1487','G1437','G3754','G5613','G2531','G5618', // εἰ, ἐάν, ὅτι, ὡς, καθώς, ὥσπερ
'G1722','G1519','G1537','G575','G4314','G3326','G2596','G1223', // ἐν, εἰς, ἐκ, ἀπό, πρός, μετά, κατά, διά
'G1909','G5228','G5259','G4862','G3844','G4253','G4012','G1722', // ἐπί, ὑπέρ, ὑπό, σύν, παρά, πρό, περί
'G3756','G3361','G3780','G3762','G3763', // οὐ, μή, οὐχί, οὐδείς, οὐδέποτε
'G846','G1473','G4771','G2249','G5210', // αὐτός, ἐγώ, σύ, ἡμεῖς, ὑμεῖς
'G3778','G1565','G3588', // οὗτος, ἐκεῖνος
'G5100','G5101','G3956','G3745','G3748', // τις, τίς, πᾶς, ὅσος
'G3779','G3568','G5119','G1161','G3767', // οὕτως, νῦν, τότε
]);
const suggestGreekWordsForChunk = async (chunkId) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const chapterIndex = project?.chapters.findIndex((ch) =>
@@ -1290,29 +1204,6 @@ const App = () => {
}
};
// Extract a single short English gloss from a verbose kjv_def string.
// e.g. "[idiom] burn (incense), be mindful..." → "burn"
// "another, [idiom] (blood-) thirsty..." → "another"
const shortHebrewGloss = (kjvDef) => {
if (!kjvDef) return '';
let s = kjvDef.trim();
// strip leading [tag] tokens like [idiom], [phrase]
s = s.replace(/^(\[[^\]]+\]\s*)+/, '').trim();
// take first chunk before comma, semicolon, or opening paren
s = s.split(/[,;(]/)[0].trim();
// strip trailing punctuation
s = s.replace(/[.\-]+$/, '').trim();
return s;
};
const ENGLISH_STOP_WORDS = new Set([
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'were', 'was', 'have', 'has',
'had', 'are', 'but', 'not', 'you', 'your', 'his', 'her', 'their', 'they', 'them',
'our', 'out', 'into', 'over', 'under', 'upon', 'then', 'than', 'who', 'what', 'when',
'where', 'why', 'how', 'also', 'there', 'here', 'all', 'any', 'one', 'two', 'three',
'he', 'she', 'it', 'we', 'i', 'me', 'my', 'mine', 'ours', 'theirs', 'its',
]);
const suggestHebrewWordsForChunk = async (chunkId) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const chapterIndex = project?.chapters.findIndex((ch) =>
@@ -1434,113 +1325,6 @@ const App = () => {
setSuggestSelection(new Set());
};
const isGreekStrongNumber = (query) => /^G\d+$/i.test(query.trim());
const isHebrewStrongNumber = (query) => /^H\d+$/i.test(query.trim());
const externalLookupLinks = (query) => {
const raw = query.trim();
const normalized = raw.toUpperCase();
const isGreek = /^G\d+$/.test(normalized);
const isHebrew = /^H\d+$/.test(normalized);
const num = (isGreek || isHebrew) ? normalized.slice(1) : null;
if (isGreek && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/greek/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/greek/${num}.html` },
];
}
if (isHebrew && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/hebrew/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/h${num}/kjv/wlc/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/hebrew/${num}.html` },
];
}
const enc = encodeURIComponent(raw);
return [
{ label: 'BibleHub', url: `https://biblehub.com/search.php?q=${enc}` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/search/search.cfm?Criteria=${enc}&t=KJV#s=s_lexiconc` },
];
};
// Module-level cache for the Greek Strong's dictionary (loaded once, ~1.2 MB)
const _greekDictRef = useRef(null);
const _greekDictLoadingRef = useRef(null);
const loadGreekDict = async () => {
if (_greekDictRef.current) return _greekDictRef.current;
if (_greekDictLoadingRef.current) return _greekDictLoadingRef.current;
_greekDictLoadingRef.current = fetch(
'https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/greek/strongs-greek-dictionary.js',
)
.then((res) => res.text())
.then((text) => {
const start = text.indexOf('{');
const end = text.lastIndexOf('}') + 1;
const dict = JSON.parse(text.slice(start, end));
_greekDictRef.current = dict;
_greekDictLoadingRef.current = null;
return dict;
});
return _greekDictLoadingRef.current;
};
const buildGreekDefinitionHtml = (key, entry) => {
let html = '';
if (entry.strongs_def) html += `<p>${entry.strongs_def.trim()}</p>`;
if (entry.derivation) html += `<p><em>Derivation:</em> ${entry.derivation}</p>`;
if (entry.kjv_def) html += `<p><em>KJV uses:</em> ${entry.kjv_def}</p>`;
return html;
};
const fetchBollsDefinition = async (query) => {
const encoded = encodeURIComponent(query.trim());
if (isHebrewStrongNumber(query)) {
const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${encoded}/`);
if (!response.ok) return null;
let defs;
try { defs = await response.json(); } catch { return null; }
return Array.isArray(defs) && defs.length > 0 ? defs : null;
}
// Greek — look up in the cached OpenScriptures Strong's dictionary.
const dict = await loadGreekDict();
const key = isGreekStrongNumber(query) ? query.trim().toUpperCase() : null;
if (key) {
const entry = dict[key];
if (!entry) return null;
return [{
topic: key,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(key, entry),
}];
}
// English word search — scan kjv_def and strongs_def for the query term.
const lowerQ = query.trim().toLowerCase();
const match = Object.entries(dict).find(([, entry]) =>
(entry.kjv_def || '').toLowerCase().split(/[,\s]+/).some((w) => w === lowerQ) ||
(entry.strongs_def || '').toLowerCase().includes(lowerQ),
);
if (match) {
const [matchKey, entry] = match;
return [{
topic: matchKey,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(matchKey, entry),
}];
}
return null;
};
const lookupWord = async (chunkId, wordId, language = 'greek') => {
const chunk = allChunks.find((c) => c.id === chunkId);
const word = chunk?.greekWords.find((w) => w.id === wordId);
+2
View File
@@ -2,6 +2,7 @@ import { render, screen, fireEvent, waitFor, within } from '@testing-library/rea
import userEvent from '@testing-library/user-event';
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
import App from './App';
import { resetLexiconCaches } from './utils/wordStudy.js';
// ---------------------------------------------------------------------------
// Shared mock data
@@ -49,6 +50,7 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
// ---------------------------------------------------------------------------
beforeEach(() => {
localStorage.clear();
resetLexiconCaches();
vi.stubGlobal('fetch', buildFetchMock());
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
+197
View File
@@ -0,0 +1,197 @@
// Word-study helpers: Strong's dictionaries, lexicon lookups, and speech.
// Dictionaries are cached at module level so they download once per visit,
// no matter how often the study page is opened.
import { bookOptions } from './bibleUtils.jsx';
// Loads a JSON-ish resource once. Concurrent callers share one request; a
// failed request is forgotten so the next call can retry.
function cachedLoader(load) {
let value = null;
let pending = null;
const get = () => {
if (value) return Promise.resolve(value);
if (!pending) {
pending = load()
.then((data) => { value = data; return data; })
.finally(() => { pending = null; });
}
return pending;
};
get.reset = () => { value = null; pending = null; };
return get;
}
// The OpenScriptures dictionaries are JS files wrapping one big object literal.
const parseStrongsJs = (text) => JSON.parse(text.slice(text.indexOf('{'), text.lastIndexOf('}') + 1));
// NT Strong's→English gloss map (from the macula-greek dataset)
export const loadNtGloss = cachedLoader(() => fetch('/nt-strongs-gloss.json').then((res) => res.json()));
// NT Strong's concordance (verse → Strong's numbers)
export const loadNtConcordance = cachedLoader(() => fetch('/nt-strongs-concordance.json').then((res) => res.json()));
export const loadHebrewDict = cachedLoader(() =>
fetch('https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/hebrew/strongs-hebrew-dictionary.js')
.then((res) => res.text())
.then(parseStrongsJs));
// Greek Strong's dictionary (~1.2 MB)
export const loadGreekDict = cachedLoader(() =>
fetch('https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/greek/strongs-greek-dictionary.js')
.then((res) => res.text())
.then(parseStrongsJs));
// Tests mock fetch differently per case, so they need a clean cache each time.
export function resetLexiconCaches() {
[loadNtGloss, loadNtConcordance, loadHebrewDict, loadGreekDict].forEach((load) => load.reset());
}
// Common Greek NT function words to pre-uncheck in the picker
// (articles, prepositions, conjunctions, particles, common pronouns)
export const GREEK_FUNCTION_WORDS = new Set([
'G3588','G3739','G3748', // ὁ (the), ὅς (who/which), ὅστις
'G2532','G1161','G1063','G235','G3767','G2443','G5037','G3303', // καί, δέ, γάρ, ἀλλά, οὖν, ἵνα, τέ, μέν
'G1487','G1437','G3754','G5613','G2531','G5618', // εἰ, ἐάν, ὅτι, ὡς, καθώς, ὥσπερ
'G1722','G1519','G1537','G575','G4314','G3326','G2596','G1223', // ἐν, εἰς, ἐκ, ἀπό, πρός, μετά, κατά, διά
'G1909','G5228','G5259','G4862','G3844','G4253','G4012','G1722', // ἐπί, ὑπέρ, ὑπό, σύν, παρά, πρό, περί
'G3756','G3361','G3780','G3762','G3763', // οὐ, μή, οὐχί, οὐδείς, οὐδέποτε
'G846','G1473','G4771','G2249','G5210', // αὐτός, ἐγώ, σύ, ἡμεῖς, ὑμεῖς
'G3778','G1565','G3588', // οὗτος, ἐκεῖνος
'G5100','G5101','G3956','G3745','G3748', // τις, τίς, πᾶς, ὅσος
'G3779','G3568','G5119','G1161','G3767', // οὕτως, νῦν, τότε
]);
// Extract a single short English gloss from a verbose kjv_def string.
// e.g. "[idiom] burn (incense), be mindful..." → "burn"
// "another, [idiom] (blood-) thirsty..." → "another"
export const shortHebrewGloss = (kjvDef) => {
if (!kjvDef) return '';
let s = kjvDef.trim();
// strip leading [tag] tokens like [idiom], [phrase]
s = s.replace(/^(\[[^\]]+\]\s*)+/, '').trim();
// take first chunk before comma, semicolon, or opening paren
s = s.split(/[,;(]/)[0].trim();
// strip trailing punctuation
s = s.replace(/[.\-]+$/, '').trim();
return s;
};
export const ENGLISH_STOP_WORDS = new Set([
'the', 'and', 'for', 'that', 'with', 'this', 'from', 'were', 'was', 'have', 'has',
'had', 'are', 'but', 'not', 'you', 'your', 'his', 'her', 'their', 'they', 'them',
'our', 'out', 'into', 'over', 'under', 'upon', 'then', 'than', 'who', 'what', 'when',
'where', 'why', 'how', 'also', 'there', 'here', 'all', 'any', 'one', 'two', 'three',
'he', 'she', 'it', 'we', 'i', 'me', 'my', 'mine', 'ours', 'theirs', 'its',
]);
export const isGreekStrongNumber = (query) => /^G\d+$/i.test(query.trim());
export const isHebrewStrongNumber = (query) => /^H\d+$/i.test(query.trim());
export const externalLookupLinks = (query) => {
const raw = query.trim();
const normalized = raw.toUpperCase();
const isGreek = /^G\d+$/.test(normalized);
const isHebrew = /^H\d+$/.test(normalized);
const num = (isGreek || isHebrew) ? normalized.slice(1) : null;
if (isGreek && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/greek/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/greek/${num}.html` },
];
}
if (isHebrew && num) {
return [
{ label: 'BibleHub', url: `https://biblehub.com/hebrew/${num}.htm` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/h${num}/kjv/wlc/0-1/` },
{ label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/hebrew/${num}.html` },
];
}
const enc = encodeURIComponent(raw);
return [
{ label: 'BibleHub', url: `https://biblehub.com/search.php?q=${enc}` },
{ label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/search/search.cfm?Criteria=${enc}&t=KJV#s=s_lexiconc` },
];
};
export const buildGreekDefinitionHtml = (key, entry) => {
let html = '';
if (entry.strongs_def) html += `<p>${entry.strongs_def.trim()}</p>`;
if (entry.derivation) html += `<p><em>Derivation:</em> ${entry.derivation}</p>`;
if (entry.kjv_def) html += `<p><em>KJV uses:</em> ${entry.kjv_def}</p>`;
return html;
};
export const fetchBollsDefinition = async (query) => {
const encoded = encodeURIComponent(query.trim());
if (isHebrewStrongNumber(query)) {
const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${encoded}/`);
if (!response.ok) return null;
let defs;
try { defs = await response.json(); } catch { return null; }
return Array.isArray(defs) && defs.length > 0 ? defs : null;
}
// Greek — look up in the cached OpenScriptures Strong's dictionary.
const dict = await loadGreekDict();
const key = isGreekStrongNumber(query) ? query.trim().toUpperCase() : null;
if (key) {
const entry = dict[key];
if (!entry) return null;
return [{
topic: key,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(key, entry),
}];
}
// English word search — scan kjv_def and strongs_def for the query term.
const lowerQ = query.trim().toLowerCase();
const match = Object.entries(dict).find(([, entry]) =>
(entry.kjv_def || '').toLowerCase().split(/[,\s]+/).some((w) => w === lowerQ) ||
(entry.strongs_def || '').toLowerCase().includes(lowerQ),
);
if (match) {
const [matchKey, entry] = match;
return [{
topic: matchKey,
lexeme: entry.lemma || '',
transliteration: entry.translit || '',
short_definition: entry.kjv_def || '',
definition: buildGreekDefinitionHtml(matchKey, entry),
}];
}
return null;
};
export const formatCrossRef = (ref) => {
const book = bookOptions.find((b) => b.abbrev === ref.book);
const bookName = book?.name ?? ref.book;
const verseRange = ref.endVerse && ref.endVerse !== ref.verse
? `${ref.verse}-${ref.endVerse}`
: `${ref.verse}`;
return `${bookName} ${ref.chapter}:${verseRange}`;
};
export const speakOriginalWord = (word, strongsNum) => {
if (!window.speechSynthesis) return;
if (window.speechSynthesis.speaking) window.speechSynthesis.cancel();
const lang = strongsNum?.startsWith('H') ? 'he-IL' : 'el-GR';
const voices = window.speechSynthesis.getVoices();
const match = voices.find((v) => v.lang.startsWith(lang.split('-')[0]));
const utt = new SpeechSynthesisUtterance(word + '.');
utt.lang = lang;
utt.rate = 0.85;
if (match) utt.voice = match;
// Chain a silent tail so the audio session fades rather than clicks shut
utt.onend = () => {
const tail = new SpeechSynthesisUtterance('     ');
tail.volume = 0;
tail.lang = lang;
if (match) tail.voice = match;
window.speechSynthesis.speak(tail);
};
window.speechSynthesis.speak(utt);
};