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 && } +
  • + ); +} + +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 ? :

    {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/GrammarPanel.jsx b/src/components/GrammarPanel.jsx new file mode 100644 index 0000000..99557ed --- /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/ScriptureRefs.jsx b/src/components/ScriptureRefs.jsx new file mode 100644 index 0000000..fe2803e --- /dev/null +++ b/src/components/ScriptureRefs.jsx @@ -0,0 +1,63 @@ +// Under a note: each Scripture reference written in it (Rom 8:28, Psalm 23…) as a chip. Click one to read the verse +// (BSB) right there, or open it in the Bible reader. The text itself is never changed. +import { useEffect, useMemo, useState } from 'react'; +import { useApp } from '../context/AppContext.js'; +import { fetchChapter } from '../utils/bibleText.js'; +import { labelFor, uniqueReferences } from '../utils/scriptureRefs.js'; + +const MAX_VERSES = 12; +const CHAPTER_PREVIEW = 6; + +function Preview({ r }) { + const { jumpToReaderVerse, openBibleReader } = useApp(); + const [state, setState] = useState({ loading: true }); + useEffect(() => { + let live = true; + setState({ loading: true }); + fetchChapter('BSB', r.book, r.chapter) + .then(({ verses }) => { + const from = r.verse ?? 1; + const to = r.verse ? (r.endVerse ?? r.verse) : from + CHAPTER_PREVIEW - 1; + const picked = verses.filter((v) => v.number >= from && v.number <= to).slice(0, MAX_VERSES); + if (live) setState({ verses: picked, more: !r.verse && verses.length > picked.length }); + }) + .catch((err) => { if (live) setState({ error: err.message || 'Could not load the verse.' }); }); + return () => { live = false; }; + }, [r.book, r.chapter, r.verse, r.endVerse]); + + return ( +
    + {state.loading &&

    Loading…

    } + {state.error &&

    {state.error} You can still open it in the Bible.

    } + {state.verses && (state.verses.length === 0 + ?

    That verse isn’t in the BSB.

    + :
    {state.verses.map((v) =>

    {v.number}{v.text}

    )}{state.more &&

    …

    }
    )} +
    + + 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/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/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/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/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