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
+14
-230
@@ -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,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(() => {});
|
||||
|
||||
@@ -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