From d5cc93f9bd14b90a6a6648a9486b9c9553ac502e Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:14:25 -0400 Subject: [PATCH 1/6] Add the KJV, ASV and LSV (free helloao translations; savable offline) Co-Authored-By: Claude Sonnet 5.5 --- src/components/settings/DataSection.jsx | 2 +- src/utils/bibleText.js | 5 +++++ src/utils/bibleUtils.jsx | 2 +- src/utils/offline.test.js | 18 ++++++++++++++++++ src/utils/studyText.js | 4 +++- 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/components/settings/DataSection.jsx b/src/components/settings/DataSection.jsx index f23386d..2d0c840 100644 --- a/src/components/settings/DataSection.jsx +++ b/src/components/settings/DataSection.jsx @@ -106,7 +106,7 @@ export default function DataSection() {

Chapters you read are kept automatically. To read anywhere, save a whole Bible while you’re connected (about 4 MB). The ESV, NLT, CSB and NASB can’t be saved: their licences don’t allow it.

{offline.job ? : (
- {['BSB', 'NET'].map((t) => ( + {['BSB', 'NET', 'KJV', 'ASV', 'LSV'].map((t) => ( diff --git a/src/utils/bibleText.js b/src/utils/bibleText.js index 3b2ba90..d491971 100644 --- a/src/utils/bibleText.js +++ b/src/utils/bibleText.js @@ -8,6 +8,9 @@ export const READER_TRANSLATIONS = [ notice: 'Scripture quotations are from the ESV® Bible (The Holy Bible, English Standard Version®), © 2001 by Crossway, a publishing ministry of Good News Publishers. Used by permission. All rights reserved.' }, { id: 'NET', name: 'NET Bible', helloao: 'eng_net', notice: 'Scripture quoted by permission. Quotations designated (NET) are from the NET Bible® copyright ©1996-2017 by Biblical Studies Press, L.L.C. http://netbible.com All rights reserved.' }, + { id: 'KJV', name: 'King James Version', helloao: 'eng_kjv', notice: 'The King James Version is in the public domain.' }, + { id: 'ASV', name: 'American Standard Version (1901)', helloao: 'eng_asv', notice: 'The American Standard Version (1901) is in the public domain.' }, + { id: 'LSV', name: 'Literal Standard Version', helloao: 'eng_lsv', notice: 'Literal Standard Version © 2020 Covenant Press and the Covenant Christian Coalition. Free to read and share. lsvbible.com' }, { id: 'NLT', name: 'New Living Translation', licensed: true, notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' }, { id: 'CSB', name: 'Christian Standard Bible', licensed: true, @@ -16,6 +19,8 @@ export const READER_TRANSLATIONS = [ notice: 'New American Standard Bible®, Copyright © 1995, The Lockman Foundation. All rights reserved. lockman.org' }, ]; +// The address code helloao uses for a translation (NET is 'eng_net', the KJV 'eng_kjv'; BSB is its own) +export const helloaoCode = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.helloao ?? id ?? 'BSB'; export const translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id; export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true; diff --git a/src/utils/bibleUtils.jsx b/src/utils/bibleUtils.jsx index 922ede7..61ca529 100644 --- a/src/utils/bibleUtils.jsx +++ b/src/utils/bibleUtils.jsx @@ -485,7 +485,7 @@ export function parseBibleChapter(data) { .filter((item) => item?.type === 'verse') .map((item) => ({ number: item.number, - text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(), + text: renderVerseContent(item.content).replace(/¶\s*/g, '').replace(/\s+/g, ' ').trim(), })); } diff --git a/src/utils/offline.test.js b/src/utils/offline.test.js index 6631345..0225b1d 100644 --- a/src/utils/offline.test.js +++ b/src/utils/offline.test.js @@ -57,3 +57,21 @@ test('a study works offline once every book in it is saved in its translation', test('licensed translations cannot be saved offline', () => { expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed'); }); + +import { READER_TRANSLATIONS, helloaoCode } from './bibleText.js'; +import { parseBibleChapter } from './bibleUtils.jsx'; + +test('the KJV, ASV and LSV are free translations that can be saved offline and are fetched by their helloao code', () => { + for (const [id, code] of [['KJV', 'eng_kjv'], ['ASV', 'eng_asv'], ['LSV', 'eng_lsv'], ['NET', 'eng_net'], ['BSB', 'BSB']]) { + expect(READER_TRANSLATIONS.some((t) => t.id === id)).toBe(true); + expect(helloaoCode(id)).toBe(code); + expect(canSaveOffline(id)).toBe(true); + } + expect(canSaveOffline('ESV')).toBe(false); + expect(READER_TRANSLATIONS.find((t) => t.id === 'LSV').notice).toMatch(/Covenant Press/); +}); + +test('a paragraph mark in the KJV text is dropped from the verse', () => { + const verses = parseBibleChapter({ chapter: { content: [{ type: 'verse', number: 16, content: [{ text: '¶ For God so loved the world' }] }] } }); + expect(verses[0].text).toBe('For God so loved the world'); +}); diff --git a/src/utils/studyText.js b/src/utils/studyText.js index 5aaa818..7b6b42e 100644 --- a/src/utils/studyText.js +++ b/src/utils/studyText.js @@ -1,8 +1,10 @@ // Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for // offline" downloads go through the same address, so a saved book builds a study with no connection. // Failures say what's wrong and what to do, instead of a generic error. +import { helloaoCode } from './bibleText.js'; + export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) { - const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB'); + const code = helloaoCode(translation || 'BSB'); let res; try { res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal }); From fa9e825444b345164742fe244331036029b23518 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:17:02 -0400 Subject: [PATCH 2/6] Explain grammar labels in plain English on original words and in word-study cards Co-Authored-By: Claude Sonnet 5.5 --- src/components/GrammarPanel.jsx | 19 +++++ src/components/study/ScripturePane.jsx | 6 ++ src/components/study/WordStudyTab.jsx | 2 + src/hooks/useWordStudy.js | 1 + src/utils/grammar.js | 111 +++++++++++++++++++++++++ src/utils/grammar.test.js | 41 +++++++++ 6 files changed, 180 insertions(+) create mode 100644 src/components/GrammarPanel.jsx create mode 100644 src/utils/grammar.js create mode 100644 src/utils/grammar.test.js diff --git a/src/components/GrammarPanel.jsx b/src/components/GrammarPanel.jsx new file mode 100644 index 0000000..d877652 --- /dev/null +++ b/src/components/GrammarPanel.jsx @@ -0,0 +1,19 @@ +// What an original-language word's grammar label means, in plain English. +import { grammarNotes } from '../utils/grammar.js'; + +export default function GrammarPanel({ label, className = '' }) { + const notes = grammarNotes(label); + if (!label) return null; + return ( +
+

Grammar · {label}

+ {notes.length > 0 && ( +
+ {notes.map((n) => ( +
{n.term}
{n.meaning}
+ ))} +
+ )} +
+ ); +} diff --git a/src/components/study/ScripturePane.jsx b/src/components/study/ScripturePane.jsx index a5af161..c453382 100644 --- a/src/components/study/ScripturePane.jsx +++ b/src/components/study/ScripturePane.jsx @@ -2,10 +2,12 @@ // interlinear words; click an original word to add it to the chunk's word study. import { useState } from 'react'; import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx'; +import GrammarPanel from '../GrammarPanel.jsx'; export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) { const [openVerse, setOpenVerse] = useState(null); const [editingNote, setEditingNote] = useState(null); + const [inspect, setInspect] = useState(null); // the original word whose grammar is showing: { key, index } const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev]; return (
@@ -38,6 +40,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea const added = w.s && addedStrongs.has(w.s.toUpperCase()); return ( ); })} + {inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && ( + + )}
)} diff --git a/src/components/study/WordStudyTab.jsx b/src/components/study/WordStudyTab.jsx index 0c5525a..ff669fb 100644 --- a/src/components/study/WordStudyTab.jsx +++ b/src/components/study/WordStudyTab.jsx @@ -6,6 +6,7 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js import { useIqAvailable } from '../../hooks/useIq.js'; import { normalizeStrongs } from '../../utils/iqbible.js'; import IqLexicon from '../iq/IqLexicon.jsx'; +import GrammarPanel from '../GrammarPanel.jsx'; const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50'; @@ -50,6 +51,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) { {field('Part of speech', 'partOfSpeech', 'e.g. Noun')} {field('English word', 'englishGloss', 'e.g. grace')} + {word.parsing && } {field('Short definition', 'shortDefinition')} {word.definitionHtml && (
diff --git a/src/hooks/useWordStudy.js b/src/hooks/useWordStudy.js index 8e3c050..c72fd4a 100644 --- a/src/hooks/useWordStudy.js +++ b/src/hooks/useWordStudy.js @@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) { lexeme: w.o || entry?.lemma || '', transliteration: w.t || entry?.translit || entry?.xlit || '', partOfSpeech: '', + parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card shortDefinition: entry?.kjv_def || w.g || '', definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '', loading: false, diff --git a/src/utils/grammar.js b/src/utils/grammar.js new file mode 100644 index 0000000..cedd6a8 --- /dev/null +++ b/src/utils/grammar.js @@ -0,0 +1,111 @@ +// Plain-English meanings for the grammar labels on the original-language words ("Verb - Aorist Indicative Active - +// 3rd Person Singular"). The labels come with the interlinear text; this only explains them, so a reader who never +// studied Greek or Hebrew can see what "aorist" or "Hiphil" tells them. Meanings are the usual textbook ones and +// are a guide, not a rule: the context decides. + +// Hebrew verbs name a "stem" (how the action is done); when one is present, Perfect, Imperfect and the rest +// mean the Hebrew thing, not the Greek one. +const HEBREW_STEMS = [ + ['qal', 'The basic, simple action ("he kept").'], + ['niphal', 'Often passive or reflexive ("it was kept", "he kept himself").'], + ['piel', 'Intensive or repeated action ("he kept carefully").'], + ['pual', 'The passive of the Piel ("it was kept carefully").'], + ['hithpael', 'Reflexive or repeated action ("he kept himself busy").'], + ['hiphil', 'Causing the action ("he made someone keep").'], + ['hophal', 'The passive of the Hiphil ("he was made to keep").'], +]; +const HEBREW_FORMS = [ + ['perfect', 'Action seen as complete, often past ("he kept").'], + ['imperfect', 'Action seen as incomplete: ongoing, repeated or still to come ("he keeps", "he will keep").'], + ['imperative', 'A command ("keep!").'], + ['infinitive construct', 'The verb as a noun, linked to what follows ("keeping of…").'], + ['infinitive absolute', 'The verb as a noun on its own, often to stress the action ("keeping, keep").'], + ['participle', 'A verb used like an adjective or noun ("keeping", "the one who keeps").'], +]; +const GREEK_TENSES = [ + ['present', 'Ongoing or repeated action ("is doing").'], + ['imperfect', 'Past action that was ongoing or repeated ("was doing").'], + ['future', 'Action that will happen ("will do").'], + ['aorist', 'Action seen as a whole, with no focus on how long it took. Often just "did".'], + ['perfect', 'A finished action whose results still stand ("has done").'], + ['pluperfect', 'A finished action whose results stood at some past time ("had done").'], +]; +const MOODS = [ + ['indicative', 'States something as a fact or asks a question.'], + ['subjunctive', 'Possibility, purpose or doubt ("may", "might", "let us").'], + ['imperative', 'A command or request.'], + ['optative', 'A wish or a remote possibility ("may it be").'], + ['infinitive', 'The verb as a noun ("to do").'], + ['participle', 'A verb used like an adjective ("-ing", "the one who…").'], +]; +const VOICES = [ + ['middle or passive', 'The form can mean the subject acts on itself, or receives the action; context decides.'], + ['middle', 'The subject acts on itself or for its own benefit.'], + ['passive', 'The subject receives the action ("was done").'], + ['active', 'The subject does the action.'], +]; +const CASES = [ + ['nominative', 'The subject of the sentence, or a name for something.'], + ['genitive', 'Belonging, source or description. Often "of".'], + ['dative', 'The one the action is to, for or by. Often "to", "for", "in", "with".'], + ['accusative', 'The direct object, the thing acted on.'], + ['vocative', 'The one being spoken to.'], +]; +const PARTS = [ + ['noun', 'A person, place or thing.'], + ['verb', 'An action or state.'], + ['adjective', 'Describes a noun.'], + ['adverb', 'Describes a verb, adjective or another adverb.'], + ['article', 'The word "the".'], + ['conjunction', 'Joins words or clauses ("and", "but", "for").'], + ['preposition', 'Shows how a noun relates to something else ("in", "to", "from").'], + ['personal pronoun', 'Stands in for a person ("he", "you").'], + ['relative pronoun', 'Links a clause back to a noun ("who", "which").'], + ['demonstrative pronoun', 'Points something out ("this", "that").'], + ['reflexive pronoun', 'Points back to the subject ("himself").'], + ['interrogative pronoun', 'Asks a question ("who?", "what?").'], + ['pronoun', 'Stands in for a noun.'], + ['particle', 'A small word that adds emphasis or links ideas.'], + ['interjection', 'An exclamation.'], + ['numeral', 'A number.'], +]; +const PREFIXES = [ + ['conjunctive waw', '"And" (or "but", "then") joined to the front of the next word.'], + ['preposition-b', '"In", "with" or "by", joined to the front of the next word.'], + ['preposition-l', '"To" or "for", joined to the front of the next word.'], + ['preposition-k', '"Like" or "as", joined to the front of the next word.'], + ['preposition-m', '"From", joined to the front of the next word.'], + ['construct', 'Linked to the next word, usually "of".'], +]; +const PERSONS = { '1st': 'I / we', first: 'I / we', '2nd': 'you', second: 'you', '3rd': 'he / she / it / they', third: 'he / she / it / they' }; + +const NUMBER_WORDS = { singular: 'one', plural: 'more than one', dual: 'two' }; + +// -> [{ term: 'Aorist', meaning: '…' }] in the order the terms appear in the label; [] when there is nothing to say. +export function grammarNotes(label) { + const text = String(label ?? '').toLowerCase(); + if (!text.trim()) return []; + const isHebrewVerb = HEBREW_STEMS.some(([t]) => new RegExp(`\\b${t}\\b`).test(text)); + const found = []; + const add = (table, tableName) => { + for (const [term, meaning] of table) { + const m = new RegExp(`(?:^|[^a-z-])${term.replace(/[-]/g, '[- ]?')}(?![a-z])`).exec(text); + if (m) found.push({ at: m.index, term, meaning, tableName }); + } + }; + add(PARTS, 'part'); + if (isHebrewVerb) { add(HEBREW_STEMS, 'stem'); add(HEBREW_FORMS, 'form'); } else { add(GREEK_TENSES, 'tense'); } + add(MOODS.filter(([t]) => !isHebrewVerb || (t !== 'indicative' && t !== 'subjunctive' && t !== 'optative' && !['imperative', 'infinitive', 'participle'].includes(t))), 'mood'); + if (!isHebrewVerb) add(VOICES, 'voice'); else add([['active', VOICES[3][1]], ['passive', VOICES[2][1]]], 'voice'); + add(CASES, 'case'); + add(PREFIXES, 'prefix'); + // the same label can match a short term inside a longer one ("middle" inside "middle or passive", "pronoun" inside "personal pronoun") + const covered = (a, b) => b !== a && b.term.length > a.term.length && b.term.replace(/-/g, ' ').includes(a.term.replace(/-/g, ' ')) && a.at >= b.at - 1 && a.at < b.at + b.term.length + 1; + const kept = found.filter((a) => !found.some((b) => covered(a, b) && (b.tableName === a.tableName || b.tableName === 'prefix'))); + const person = text.match(/\b(1st|2nd|3rd|first|second|third)\s+person\b/); + const number = text.match(/\b(singular|plural|dual)\b/); + const first = (t) => t.charAt(0).toUpperCase() + t.slice(1); + const notes = kept.sort((a, b) => a.at - b.at).map(({ term, meaning }) => ({ term: first(term), meaning })); + if (person) notes.push({ term: `${person[1]} person${number ? ` ${number[1]}` : ''}`.replace(/^\w/, (c) => c.toUpperCase()), meaning: `${PERSONS[person[1]]}${number ? ` (${NUMBER_WORDS[number[1]]})` : ''}.` }); + return notes; +} diff --git a/src/utils/grammar.test.js b/src/utils/grammar.test.js new file mode 100644 index 0000000..e1448bf --- /dev/null +++ b/src/utils/grammar.test.js @@ -0,0 +1,41 @@ +import { test, expect } from 'vitest'; +import { grammarNotes } from './grammar.js'; + +const terms = (label) => grammarNotes(label).map((n) => n.term); + +test('a Greek verb is explained part by part', () => { + const notes = grammarNotes('Verb - Aorist Indicative Active - 3rd Person Singular'); + expect(notes.map((n) => n.term)).toEqual(['Verb', 'Aorist', 'Indicative', 'Active', '3rd person singular']); + expect(notes.find((n) => n.term === 'Aorist').meaning).toMatch(/as a whole/); + expect(notes.at(-1).meaning).toBe('he / she / it / they (one).'); +}); + +test('nouns and articles give their case', () => { + expect(terms('Noun - Nominative Masculine Singular')).toEqual(['Noun', 'Nominative']); + expect(terms('Article - Genitive Neuter Plural')).toEqual(['Article', 'Genitive']); + expect(terms('Personal / Relative Pronoun - Accusative 1st Person Singular')).toContain('Accusative'); +}); + +test('moods, participles and voices that share words are not double counted', () => { + expect(terms('Verb - Present Participle Middle or Passive - Nominative Masculine Singular')).toEqual(['Verb', 'Present', 'Participle', 'Middle or passive', 'Nominative']); + expect(terms('Verb - Perfect Infinitive Active')).toEqual(['Verb', 'Perfect', 'Infinitive', 'Active']); +}); + +test('a Hebrew verb is read with Hebrew meanings, not Greek ones', () => { + const notes = grammarNotes('Verb - Qal - Perfect - third person masculine singular'); + expect(notes.map((n) => n.term)).toEqual(['Verb', 'Qal', 'Perfect', 'Third person singular']); + expect(notes.find((n) => n.term === 'Perfect').meaning).toMatch(/complete/); + expect(grammarNotes('Verb - Hiphil - Imperfect - third person masculine singular').find((n) => n.term === 'Hiphil').meaning).toMatch(/Causing/); +}); + +test('Hebrew words with attached prefixes explain the prefix', () => { + const notes = grammarNotes('Preposition-b | Noun - feminine singular'); + expect(notes.map((n) => n.term)).toEqual(expect.arrayContaining(['Noun', 'Preposition-b'])); + expect(notes.find((n) => n.term === 'Preposition-b').meaning).toMatch(/joined to the front/); +}); + +test('nothing to explain gives an empty list', () => { + expect(grammarNotes('')).toEqual([]); + expect(grammarNotes(null)).toEqual([]); + expect(grammarNotes('???')).toEqual([]); +}); From fdb7f16d8a3e648c989786a3d34f45df0b9d2844 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:18:01 -0400 Subject: [PATCH 3/6] Bible dictionary lookup in the Word study tab (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith) Co-Authored-By: Claude Sonnet 5.5 --- src/components/iq/IqDictionary.jsx | 75 +++++++++++++++++++++++++ src/components/iq/IqDictionary.test.jsx | 52 +++++++++++++++++ src/components/study/WordStudyTab.jsx | 3 + src/utils/dictionary.js | 30 ++++++++++ src/utils/dictionary.test.js | 35 ++++++++++++ 5 files changed, 195 insertions(+) create mode 100644 src/components/iq/IqDictionary.jsx create mode 100644 src/components/iq/IqDictionary.test.jsx create mode 100644 src/utils/dictionary.js create mode 100644 src/utils/dictionary.test.js diff --git a/src/components/iq/IqDictionary.jsx b/src/components/iq/IqDictionary.jsx new file mode 100644 index 0000000..53e8797 --- /dev/null +++ b/src/components/iq/IqDictionary.jsx @@ -0,0 +1,75 @@ +// A Bible dictionary lookup (Easton, Hastings, Hitchcock, Schaff, Smith: all public domain, from IQ Bible). +// Type a word, pick a dictionary; the entry named for the word comes first and the entries that only mention it +// are folded below. Scripture references in an entry open in the Reader. +import { useState } from 'react'; +import { useApp } from '../../context/AppContext.js'; +import { useIq } from '../../hooks/useIq.js'; +import { pickEntries, preview, refTarget } from '../../utils/dictionary.js'; + +const field = 'rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; + +function Entry({ entry, credit, open: startOpen }) { + const { jumpToReaderVerse, openBibleReader } = useApp(); + const [open, setOpen] = useState(startOpen); + const p = preview(entry.definition); + const refs = [...new Set(entry.scripture_refs ?? [])].map(refTarget).filter(Boolean).slice(0, 14); + return ( +
+

{entry.term}

+

{open || !p.cut ? entry.definition : p.text}

+ {p.cut && } + {refs.length > 0 && ( +

+ {refs.map((r) => ( + + ))} +

+ )} +

{credit}

+
+ ); +} + +export default function IqDictionary() { + const [dictId, setDictId] = useState(() => { try { return localStorage.getItem('dictionary') || 'easton'; } catch { return 'easton'; } }); + const [typed, setTyped] = useState(''); + const [asked, setAsked] = useState(''); + const list = useIq('dictionaries'); + const dictionaries = list.data?.data ?? []; + const current = dictionaries.find((d) => d.id === dictId) ?? dictionaries[0]; + const result = useIq(asked && current ? `dictionaries/${current.id}` : null, { q: asked }); + const { exact, others } = pickEntries(result.data?.data, asked); + const credit = current ? `${current.name}, ${current.author}, ${current.year}. ${current.license}.` : ''; + const choose = (id) => { setDictId(id); try { localStorage.setItem('dictionary', id); } catch { /* storage unavailable */ } }; + const ask = () => setAsked(typed.trim().slice(0, 100)); + + return ( +
+ Bible dictionary +
+
+ setTyped(e.target.value)} placeholder="A name, place or word, e.g. cubit" + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); ask(); } }} className={`${field} min-w-0 flex-1`} /> + + +
+ {result.status === 'loading' &&

Looking up…

} + {result.status === 'error' &&

{result.error || 'The dictionary could not be reached.'}

} + {asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && ( +

Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.

+ )} + {exact && } + {others.length > 0 && ( +
+ {exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length}) +
{others.slice(0, 8).map((e) => )}
+
+ )} +
+
+ ); +} diff --git a/src/components/iq/IqDictionary.test.jsx b/src/components/iq/IqDictionary.test.jsx new file mode 100644 index 0000000..44f1dc1 --- /dev/null +++ b/src/components/iq/IqDictionary.test.jsx @@ -0,0 +1,52 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; +import { AppContext } from '../../context/AppContext.js'; +import IqDictionary from './IqDictionary.jsx'; + +const LIST = { count: 2, data: [ + { id: 'easton', name: "Easton's Bible Dictionary", author: 'Matthew George Easton', year: '1897', license: 'Public domain', entry_count: 3962 }, + { id: 'smith', name: "Smith's Bible Dictionary", author: 'William Smith', year: '1863', license: 'Public domain', entry_count: 4488 }, +] }; +const ANSWER = { count: 2, data: [ + { term: 'Alabaster', slug: 'alabaster', definition: 'A stone box. It mentions a talent.', scripture_refs: ['Matthew 26:7'] }, + { term: 'Talent', slug: 'talent', definition: 'The largest Hebrew weight, about 75 pounds.', scripture_refs: ['Exodus 38:25', 'Matthew 25:15'] }, +] }; + +const jump = vi.fn(); +const open = vi.fn(); +const asked = []; +beforeEach(() => { + asked.length = 0; jump.mockClear(); open.mockClear(); localStorage.clear(); + vi.stubGlobal('fetch', vi.fn(async (url) => { + asked.push(String(url)); + const body = String(url).includes('/api/iq/dictionaries/') ? ANSWER : LIST; + return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) }; + })); +}); + +test('look up a word: the entry named for it comes first, its Scripture references open in the Reader', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Bible dictionary')); + await user.type(await screen.findByRole('textbox', { name: 'Look up a word' }), 'talent{Enter}'); + const entry = (await screen.findByRole('heading', { name: 'Talent' })).closest('article'); + expect(within(entry).getByText(/about 75 pounds/)).toBeInTheDocument(); + expect(asked.some((u) => /dictionaries\/easton\?q=talent/.test(u))).toBe(true); + await user.click(within(entry).getByRole('button', { name: 'Matthew 25:15' })); + expect(jump).toHaveBeenCalledWith('MAT', 25, 15); + expect(open).toHaveBeenCalled(); + expect(within(entry).getByText(/Easton's Bible Dictionary, Matthew George Easton, 1897\. Public domain\./)).toBeInTheDocument(); + expect(screen.getByText(/Entries that mention|Other entries that mention/)).toBeInTheDocument(); // Alabaster only mentions it +}); + +test('the chosen dictionary is remembered and used for the next lookup', async () => { + const user = userEvent.setup(); + render(); + await user.click(screen.getByText('Bible dictionary')); + await user.selectOptions(await screen.findByRole('combobox', { name: 'Dictionary' }), 'smith'); + expect(localStorage.getItem('dictionary')).toBe('smith'); + await user.type(screen.getByRole('textbox', { name: 'Look up a word' }), 'cubit{Enter}'); + await screen.findByRole('heading', { name: 'Talent' }); + expect(asked.some((u) => /dictionaries\/smith\?q=cubit/.test(u))).toBe(true); +}); diff --git a/src/components/study/WordStudyTab.jsx b/src/components/study/WordStudyTab.jsx index ff669fb..69228cb 100644 --- a/src/components/study/WordStudyTab.jsx +++ b/src/components/study/WordStudyTab.jsx @@ -7,6 +7,7 @@ import { useIqAvailable } from '../../hooks/useIq.js'; import { normalizeStrongs } from '../../utils/iqbible.js'; import IqLexicon from '../iq/IqLexicon.jsx'; import GrammarPanel from '../GrammarPanel.jsx'; +import IqDictionary from '../iq/IqDictionary.jsx'; const inputCls = 'mt-1 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2.5 py-1.5 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; const btn = 'rounded-lg border border-[var(--r-line)] px-2.5 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50'; @@ -78,8 +79,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) { export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) { const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy; + const iq = useIqAvailable(); // the dictionaries come from IQ Bible, so they appear only when the server has a key return (
+ {iq && }
diff --git a/src/utils/dictionary.js b/src/utils/dictionary.js new file mode 100644 index 0000000..0e7b07d --- /dev/null +++ b/src/utils/dictionary.js @@ -0,0 +1,30 @@ +// Bible dictionary lookups (IQ Bible: Easton, Hastings, Hitchcock, Schaff, Smith). The route is +// /dictionaries/{id}?q=term and answers { count, data: [{ term, slug, definition, scripture_refs?, citations? }] }, +// matching the word anywhere in an entry, so the entry actually named for the word is put first. +import { parsePassageRef } from './passageRef.js'; + +const norm = (s) => String(s ?? '').trim().toLowerCase().replace(/[^a-z0-9 ]/g, ''); + +// rows -> { exact: the entry whose heading is the word (or null), others: the rest } +export function pickEntries(rows, q) { + const list = Array.isArray(rows) ? rows.filter((r) => r && typeof r.definition === 'string') : []; + const want = norm(q); + const exact = list.find((r) => norm(r.term) === want) ?? null; + return { exact, others: list.filter((r) => r !== exact) }; +} + +// "Exodus 4:27-30", "Psalms 106:19", "Leviticus 8" -> { book, chapter, verse } for the Reader, or null +export function refTarget(text) { + const cleaned = String(text ?? '').replace(/\s*[-–,].*$/, '').trim(); + const hit = parsePassageRef(cleaned)[0]; + return hit ? { book: hit.book, chapter: hit.chapter, verse: hit.verse ?? 1, label: String(text).trim() } : null; +} + +// A long definition is cut at a sentence end near `max` characters for the preview; the full text stays one click away. +export function preview(text, max = 420) { + const t = String(text ?? '').trim(); + if (t.length <= max) return { text: t, cut: false }; + const upTo = t.slice(0, max); + const end = Math.max(upTo.lastIndexOf('. '), upTo.lastIndexOf('; ')); + return { text: `${end > max * 0.5 ? upTo.slice(0, end + 1) : upTo.replace(/\s+\S*$/, '')}…`, cut: true }; +} diff --git a/src/utils/dictionary.test.js b/src/utils/dictionary.test.js new file mode 100644 index 0000000..773e8d8 --- /dev/null +++ b/src/utils/dictionary.test.js @@ -0,0 +1,35 @@ +import { test, expect } from 'vitest'; +import { pickEntries, preview, refTarget } from './dictionary.js'; + +const rows = [ + { term: 'Alabaster', slug: 'alabaster', definition: 'A stone… a denarius…' }, + { term: 'Denarius', slug: 'denarius', definition: 'A Roman silver coin.', scripture_refs: ['Matthew 20:2'] }, + { term: 'Farthing', slug: 'farthing', definition: 'mentions the denarius' }, +]; + +test('the entry named for the word comes first, the entries that only mention it follow', () => { + const { exact, others } = pickEntries(rows, ' denarius '); + expect(exact.term).toBe('Denarius'); + expect(others.map((o) => o.term)).toEqual(['Alabaster', 'Farthing']); + expect(pickEntries(rows, 'penny').exact).toBeNull(); + expect(pickEntries(rows, 'penny').others).toHaveLength(3); + expect(pickEntries(null, 'x')).toEqual({ exact: null, others: [] }); + expect(pickEntries([{ term: 'Bad' }], 'bad').exact).toBeNull(); // an entry without a definition is ignored +}); + +test('Scripture references in an entry are turned into places in the Reader', () => { + expect(refTarget('Exodus 4:27-30')).toMatchObject({ book: 'EXO', chapter: 4, verse: 27, label: 'Exodus 4:27-30' }); + expect(refTarget('Psalms 106:19')).toMatchObject({ book: 'PSA', chapter: 106, verse: 19 }); + expect(refTarget('Leviticus 8')).toMatchObject({ book: 'LEV', chapter: 8, verse: 1 }); + expect(refTarget('1 Chronicles 2:10')).toMatchObject({ book: '1CH', chapter: 2, verse: 10 }); + expect(refTarget('nonsense')).toBeNull(); +}); + +test('a long definition is previewed up to a sentence end', () => { + const long = `${'First sentence is here. '.repeat(30)}`; + const p = preview(long, 100); + expect(p.cut).toBe(true); + expect(p.text.endsWith('…')).toBe(true); + expect(p.text.length).toBeLessThan(110); + expect(preview('Short.', 100)).toEqual({ text: 'Short.', cut: false }); +}); From f05d38ecded30ce463f21ed7ab9dbf6e7c244dc3 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:20:20 -0400 Subject: [PATCH 4/6] Units hover notes and a family tree for people (Theographic) Co-Authored-By: Claude Sonnet 5.5 --- src/components/FamilyTree.jsx | 61 ++++++++++++++++++++++++++ src/components/FamilyTree.test.jsx | 46 +++++++++++++++++++ src/components/StudyPanel.jsx | 2 + src/components/study/ScripturePane.jsx | 5 ++- src/utils/biblicalUnits.js | 55 +++++++++++++++++++++++ src/utils/biblicalUnits.test.js | 29 ++++++++++++ src/utils/family.js | 35 +++++++++++++++ src/utils/family.test.js | 50 +++++++++++++++++++++ 8 files changed, 282 insertions(+), 1 deletion(-) create mode 100644 src/components/FamilyTree.jsx create mode 100644 src/components/FamilyTree.test.jsx create mode 100644 src/utils/biblicalUnits.js create mode 100644 src/utils/biblicalUnits.test.js create mode 100644 src/utils/family.js create mode 100644 src/utils/family.test.js diff --git a/src/components/FamilyTree.jsx b/src/components/FamilyTree.jsx new file mode 100644 index 0000000..c2dd902 --- /dev/null +++ b/src/components/FamilyTree.jsx @@ -0,0 +1,61 @@ +// A person's family: the relatives named in their record (click one to read about them), and a tree of ancestors and +// descendants a few generations out. From Theographic Bible Metadata. +import { useEffect, useState } from 'react'; +import { loadPerson } from '../hooks/useVerseStudy.js'; +import { relatives, walk } from '../utils/family.js'; + +function Branch({ node, onOpen }) { + return ( +
  • + + {node.more && …} + {node.kids.length > 0 &&
      {node.kids.map((k) => )}
    } +
  • + ); +} + +function Tree({ id, onOpen }) { + const [state, setState] = useState({ loading: true }); + useEffect(() => { + let live = true; + Promise.all([walk(loadPerson, id, { direction: 'up', depth: 3, max: 30 }), walk(loadPerson, id, { direction: 'down', depth: 2, max: 40 })]) + .then(([up, down]) => { if (live) setState({ up, down }); }) + .catch(() => { if (live) setState({ error: true }); }); + return () => { live = false; }; + }, [id]); + if (state.loading) return

    Loading the family…

    ; + if (state.error) return

    The family tree couldn’t be loaded right now.

    ; + const block = (title, node, empty) => ( +
    +

    {title}

    + {node.kids.length ?
      {node.kids.map((k) => )}
    :

    {empty}

    } +
    + ); + return
    {block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}
    ; +} + +// `person` is the record; `renderPerson(id)` shows another person's details inside this one when a name is clicked. +export default function Family({ person, renderPerson }) { + const groups = relatives(person); + const [shown, setShown] = useState(null); // a relative opened from a name or the tree + const [treeOpen, setTreeOpen] = useState(false); // the tree's records are only fetched once it is opened + if (groups.length === 0) return null; + return ( +
    + {groups.map((g) => ( +

    + {g.label} + {g.people.map((p) => ( + + ))} +

    + ))} +
    setTreeOpen(e.currentTarget.open)}> + Family tree + {treeOpen && setShown(id)} />} +
    + {shown &&
    {renderPerson(shown)}
    } +
    + ); +} diff --git a/src/components/FamilyTree.test.jsx b/src/components/FamilyTree.test.jsx new file mode 100644 index 0000000..170a96a --- /dev/null +++ b/src/components/FamilyTree.test.jsx @@ -0,0 +1,46 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; +import Family from './FamilyTree.jsx'; + +const ref = (id, name) => ({ id, name, type: 'people' }); +const RECORDS = { + fam_seth: { id: 'fam_seth', name: 'Seth', father: [ref('fam_adam', 'Adam')], mother: [ref('fam_eve', 'Eve')], children: [ref('fam_enos', 'Enos')], siblings: [ref('fam_abel', 'Abel')] }, + fam_adam: { id: 'fam_adam', name: 'Adam', children: [ref('fam_seth', 'Seth')] }, + fam_eve: { id: 'fam_eve', name: 'Eve', children: [ref('fam_seth', 'Seth')] }, + fam_enos: { id: 'fam_enos', name: 'Enos', father: [ref('fam_seth', 'Seth')] }, + fam_abel: { id: 'fam_abel', name: 'Abel' }, +}; + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async (url) => { + const id = String(url).match(/people\/(.+)\.json/)?.[1]; + return RECORDS[id] ? { ok: true, json: async () => ({ person: RECORDS[id] }) } : { ok: false, json: async () => ({}) }; + })); +}); + +test('the relatives named in the record are listed, and clicking one opens them', async () => { + const user = userEvent.setup(); + render(
    details for {id}
    } />); + const family = screen.getByLabelText('Family'); + for (const name of ['Adam', 'Eve', 'Abel', 'Enos']) expect(within(family).getByRole('button', { name })).toBeInTheDocument(); + await user.click(within(family).getByRole('button', { name: 'Adam' })); + expect(screen.getByText('details for fam_adam')).toBeInTheDocument(); + await user.click(within(family).getByRole('button', { name: 'Adam' })); + expect(screen.queryByText('details for fam_adam')).not.toBeInTheDocument(); +}); + +test('the family tree shows ancestors and descendants', async () => { + const user = userEvent.setup(); + render(
    details for {id}
    } />); + await user.click(screen.getByText('Family tree')); + expect(await screen.findByText('Ancestors')).toBeInTheDocument(); + const tree = screen.getByText('Ancestors').closest('div').parentElement; + expect(within(tree).getAllByRole('button', { name: 'Adam' }).length).toBeGreaterThan(0); + expect(within(tree).getAllByRole('button', { name: 'Enos' }).length).toBeGreaterThan(0); +}); + +test('a person with no recorded family shows nothing', () => { + const { container } = render( null} />); + expect(container).toBeEmptyDOMElement(); +}); diff --git a/src/components/StudyPanel.jsx b/src/components/StudyPanel.jsx index 52f83ba..738e24a 100644 --- a/src/components/StudyPanel.jsx +++ b/src/components/StudyPanel.jsx @@ -4,6 +4,7 @@ import { useState } from 'react'; import { useApp } from '../context/AppContext.js'; import { useIqAvailable } from '../hooks/useIq.js'; import IqCommentary from './iq/IqCommentary.jsx'; +import Family from './FamilyTree.jsx'; import { COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync, } from '../hooks/useVerseStudy.js'; @@ -71,6 +72,7 @@ function PersonDetails({ id }) {
    {(data.description ?? []).map((d, i) =>

    {d}

    )} {!data.description?.length &&

    No description available.

    } + } /> {data.minYear != null && (

    Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''} diff --git a/src/components/study/ScripturePane.jsx b/src/components/study/ScripturePane.jsx index c453382..e3f2176 100644 --- a/src/components/study/ScripturePane.jsx +++ b/src/components/study/ScripturePane.jsx @@ -3,6 +3,7 @@ import { useState } from 'react'; import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx'; import GrammarPanel from '../GrammarPanel.jsx'; +import { markUnits } from '../../utils/biblicalUnits.js'; export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) { const [openVerse, setOpenVerse] = useState(null); @@ -22,7 +23,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea className={`mr-1.5 align-super font-sans text-[0.6em] ${words ? 'text-[var(--r-accent)] hover:underline' : 'cursor-default text-[var(--r-sub)]'} ${open ? 'font-bold' : ''}`}> {v.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number} - {v.text} + {markUnits(v.text).map((part, i) => (part.note + ? {part.text} + : {part.text}))} {onVerseNote && ( + Berean Standard Bible +

    +
    + ); +} + +export default function ScriptureRefs({ text }) { + const refs = useMemo(() => uniqueReferences(text), [text]); + const [open, setOpen] = useState(null); + if (refs.length === 0) return null; + const openRef = refs.find((r) => labelFor(r) === open); + return ( +
    +

    + Verses mentioned: + {refs.map((r) => { + const label = labelFor(r); + return ( + + ); + })} +

    + {openRef && } +
    + ); +} diff --git a/src/components/ScriptureRefs.test.jsx b/src/components/ScriptureRefs.test.jsx new file mode 100644 index 0000000..ed30231 --- /dev/null +++ b/src/components/ScriptureRefs.test.jsx @@ -0,0 +1,47 @@ +import { render, screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, test, expect, beforeEach } from 'vitest'; +import { AppContext } from '../context/AppContext.js'; +import ScriptureRefs from './ScriptureRefs.jsx'; + +const jump = vi.fn(); +const open = vi.fn(); +beforeEach(() => { + jump.mockClear(); open.mockClear(); + vi.stubGlobal('fetch', vi.fn(async () => ({ + ok: true, status: 200, + json: async () => ({ book: { numberOfChapters: 16 }, verses: [28, 29, 30].map((n) => ({ number: n, text: `Verse ${n} text.` })) }), + }))); +}); +const setup = (text) => render(); + +test('references in a note become chips, and a note with none shows nothing', () => { + const { container } = setup('Nothing to see here.'); + expect(container).toBeEmptyDOMElement(); +}); + +test('clicking a reference shows the verse, and "Read in the Bible" opens the reader there', async () => { + const user = userEvent.setup(); + setup('God works all things together, Rom 8:28-29. See also Romans 8:28.'); + expect(screen.getAllByRole('button', { name: /Romans 8/ })).toHaveLength(2); // 8:28–29 and 8:28 are different references + await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' })); + const preview = await screen.findByRole('region', { name: /Romans 8:28–29 preview/ }); + expect(await within(preview).findByText('Verse 28 text.')).toBeInTheDocument(); + expect(within(preview).getByText('Verse 29 text.')).toBeInTheDocument(); + expect(within(preview).queryByText('Verse 30 text.')).not.toBeInTheDocument(); + await user.click(within(preview).getByRole('button', { name: 'Read in the Bible' })); + expect(jump).toHaveBeenCalledWith('ROM', 8, 28); + expect(open).toHaveBeenCalled(); + await user.click(screen.getByRole('button', { name: 'Romans 8:28–29' })); + expect(screen.queryByRole('region')).not.toBeInTheDocument(); +}); + +test('a failure to load the verse still leaves the link to the Bible', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new Error('offline'); })); + const user = userEvent.setup(); + setup('Psalm 23:1'); + await user.click(screen.getByRole('button', { name: 'Psalms 23:1' })); + const preview = await screen.findByRole('region'); + expect(await within(preview).findByText(/You can still open it in the Bible/)).toBeInTheDocument(); + expect(within(preview).getByRole('button', { name: 'Read in the Bible' })).toBeInTheDocument(); +}); diff --git a/src/components/study/NotesPane.jsx b/src/components/study/NotesPane.jsx index 67c644e..b567876 100644 --- a/src/components/study/NotesPane.jsx +++ b/src/components/study/NotesPane.jsx @@ -3,6 +3,7 @@ import { useEffect, useRef, useState } from 'react'; import SessionNotes from './SessionNotes.jsx'; import FirstDraft from './FirstDraft.jsx'; +import ScriptureRefs from '../ScriptureRefs.jsx'; import { usePasteCheck } from './PastePrompt.jsx'; import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js'; import DictateButton, { appendSpoken } from '../DictateButton.jsx'; @@ -108,6 +109,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav