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:
1 parent
8c55765ae6
commit
d7bbbe60de
3 files changed
+213
-230
No files matched your search
@@ -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);
|
||||
};
|
||||
Reference in new issue
Block a user