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 { useAudio } from './hooks/useAudio.js';
|
||||||
import { useAuth } from './hooks/useAuth.js';
|
import { useAuth } from './hooks/useAuth.js';
|
||||||
import { useBibleReader } from './hooks/useBibleReader.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 HomePage from './pages/HomePage.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -235,27 +249,6 @@ const App = () => {
|
|||||||
setCurrentPage('reader');
|
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 copyVerse = (bookName, chapterNum, verseNum, text) => {
|
||||||
const citation = `${bookName} ${chapterNum}:${verseNum} BSB — ${text}`;
|
const citation = `${bookName} ${chapterNum}:${verseNum} BSB — ${text}`;
|
||||||
navigator.clipboard?.writeText(citation);
|
navigator.clipboard?.writeText(citation);
|
||||||
@@ -987,15 +980,6 @@ const App = () => {
|
|||||||
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
|
const [crossRefSuggestions, setCrossRefSuggestions] = useState([]);
|
||||||
const _crossRefDatasetCacheRef = useRef({});
|
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 suggestCrossRefsForChunk = async (chunkId) => {
|
||||||
const chunk = allChunks.find((c) => c.id === chunkId);
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
const chapterIndex = project?.chapters.findIndex((ch) =>
|
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 suggestGreekWordsForChunk = async (chunkId) => {
|
||||||
const chunk = allChunks.find((c) => c.id === chunkId);
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
const chapterIndex = project?.chapters.findIndex((ch) =>
|
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 suggestHebrewWordsForChunk = async (chunkId) => {
|
||||||
const chunk = allChunks.find((c) => c.id === chunkId);
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
const chapterIndex = project?.chapters.findIndex((ch) =>
|
const chapterIndex = project?.chapters.findIndex((ch) =>
|
||||||
@@ -1434,113 +1325,6 @@ const App = () => {
|
|||||||
setSuggestSelection(new Set());
|
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 lookupWord = async (chunkId, wordId, language = 'greek') => {
|
||||||
const chunk = allChunks.find((c) => c.id === chunkId);
|
const chunk = allChunks.find((c) => c.id === chunkId);
|
||||||
const word = chunk?.greekWords.find((w) => w.id === wordId);
|
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 userEvent from '@testing-library/user-event';
|
||||||
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
import { vi, beforeEach, afterEach, describe, test, expect } from 'vitest';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import { resetLexiconCaches } from './utils/wordStudy.js';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared mock data
|
// Shared mock data
|
||||||
@@ -49,6 +50,7 @@ function buildFetchMock({ chapterData = mockChapterData, greekData = null } = {}
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
resetLexiconCaches();
|
||||||
vi.stubGlobal('fetch', buildFetchMock());
|
vi.stubGlobal('fetch', buildFetchMock());
|
||||||
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:mock');
|
||||||
vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {});
|
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