From d7bbbe60de541b142ad315856c3108ea535aebbb Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:49:21 -0400 Subject: [PATCH] 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 --- src/App.jsx | 244 +++-------------------------------------- src/App.test.jsx | 2 + src/utils/wordStudy.js | 197 +++++++++++++++++++++++++++++++++ 3 files changed, 213 insertions(+), 230 deletions(-) create mode 100644 src/utils/wordStudy.js diff --git a/src/App.jsx b/src/App.jsx index 868f80d..884e46b 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 += `

${entry.strongs_def.trim()}

`; - if (entry.derivation) html += `

Derivation: ${entry.derivation}

`; - if (entry.kjv_def) html += `

KJV uses: ${entry.kjv_def}

`; - 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); diff --git a/src/App.test.jsx b/src/App.test.jsx index ada1852..d397c42 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -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(() => {}); diff --git a/src/utils/wordStudy.js b/src/utils/wordStudy.js new file mode 100644 index 0000000..81ff022 --- /dev/null +++ b/src/utils/wordStudy.js @@ -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 += `

${entry.strongs_def.trim()}

`; + if (entry.derivation) html += `

Derivation: ${entry.derivation}

`; + if (entry.kjv_def) html += `

KJV uses: ${entry.kjv_def}

`; + 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); +};