Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c271f49694 | ||
|
|
79d9b091f9 | ||
|
|
f05d38ecde | ||
|
|
fdb7f16d8a | ||
|
|
fa9e825444 | ||
|
|
d5cc93f9bd | ||
|
|
6a89ee058f | ||
|
|
5e80d307a7 | ||
|
|
1bc515164a |
No files matched your search
@@ -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 (
|
||||||
|
<li>
|
||||||
|
<button type="button" onClick={() => onOpen(node.id)} className="text-[var(--r-accent)] hover:underline">{node.name || node.id}</button>
|
||||||
|
{node.more && <span className="ml-1 text-[var(--r-sub)]" title="More people beyond this point">…</span>}
|
||||||
|
{node.kids.length > 0 && <ul className="ml-4 border-l border-[var(--r-line)] pl-3">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <p className="mt-1 text-[var(--r-sub)]">Loading the family…</p>;
|
||||||
|
if (state.error) return <p className="mt-1 text-[var(--r-sub)]">The family tree couldn’t be loaded right now.</p>;
|
||||||
|
const block = (title, node, empty) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-[var(--r-text)]">{title}</p>
|
||||||
|
{node.kids.length ? <ul className="mt-1 space-y-0.5">{node.kids.map((k) => <Branch key={k.id} node={k} onOpen={onOpen} />)}</ul> : <p className="text-[var(--r-sub)]">{empty}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
return <div className="mt-2 grid gap-3 sm:grid-cols-2">{block('Ancestors', state.up, 'None recorded.')}{block('Descendants', state.down, 'None recorded.')}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `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 (
|
||||||
|
<div className="mt-2 space-y-1.5 border-t border-[var(--r-line)] pt-2" aria-label="Family">
|
||||||
|
{groups.map((g) => (
|
||||||
|
<p key={g.label} className="flex flex-wrap items-baseline gap-x-1.5">
|
||||||
|
<span className="w-16 shrink-0 text-[11px] uppercase tracking-wide">{g.label}</span>
|
||||||
|
{g.people.map((p) => (
|
||||||
|
<button key={p.id} type="button" onClick={() => setShown(shown === p.id ? null : p.id)} aria-pressed={shown === p.id}
|
||||||
|
className={`rounded-full border px-2 py-0.5 ${shown === p.id ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{p.name}</button>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
<details onToggle={(e) => setTreeOpen(e.currentTarget.open)}>
|
||||||
|
<summary className="cursor-pointer text-[var(--r-accent)]">Family tree</summary>
|
||||||
|
{treeOpen && <Tree id={person.id} onOpen={(id) => setShown(id)} />}
|
||||||
|
</details>
|
||||||
|
{shown && <div className="rounded-lg bg-[var(--r-bar)] p-2">{renderPerson(shown)}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||||
|
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(<Family person={RECORDS.fam_seth} renderPerson={(id) => <div>details for {id}</div>} />);
|
||||||
|
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(<Family person={{ id: 'x', name: 'X' }} renderPerson={() => null} />);
|
||||||
|
expect(container).toBeEmptyDOMElement();
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className={`rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-2.5 font-sans text-xs ${className}`} aria-label="Grammar">
|
||||||
|
<p className="text-[11px] uppercase tracking-wide text-[var(--r-sub)]">Grammar · {label}</p>
|
||||||
|
{notes.length > 0 && (
|
||||||
|
<ul className="mt-1.5 space-y-1.5">
|
||||||
|
{notes.map((n) => (
|
||||||
|
<li key={n.term}><span className="font-medium">{n.term}.</span> <span className="text-[var(--r-sub)]">{n.meaning}</span></li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div role="region" aria-label={`${labelFor(r)} preview`} className="mt-1.5 rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3 text-[13px]">
|
||||||
|
{state.loading && <p className="text-xs text-[var(--r-sub)]">Loading…</p>}
|
||||||
|
{state.error && <p className="text-xs text-[var(--r-sub)]">{state.error} You can still open it in the Bible.</p>}
|
||||||
|
{state.verses && (state.verses.length === 0
|
||||||
|
? <p className="text-xs text-[var(--r-sub)]">That verse isn’t in the BSB.</p>
|
||||||
|
: <div className="space-y-1 font-serif leading-relaxed">{state.verses.map((v) => <p key={v.number}><sup className="mr-1 font-sans text-[var(--r-accent)]">{v.number}</sup>{v.text}</p>)}{state.more && <p className="text-xs text-[var(--r-sub)]">…</p>}</div>)}
|
||||||
|
<div className="mt-2 flex items-center gap-3 font-sans text-xs">
|
||||||
|
<button type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse ?? 1); openBibleReader(); }} className="text-[var(--r-accent)] hover:underline">Read in the Bible</button>
|
||||||
|
<span className="text-[var(--r-sub)]">Berean Standard Bible</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="mt-1.5" aria-label="References in this note">
|
||||||
|
<p className="flex flex-wrap items-center gap-1.5 text-xs">
|
||||||
|
<span className="text-[var(--r-sub)]">Verses mentioned:</span>
|
||||||
|
{refs.map((r) => {
|
||||||
|
const label = labelFor(r);
|
||||||
|
return (
|
||||||
|
<button key={label} type="button" aria-pressed={open === label} onClick={() => setOpen(open === label ? null : label)}
|
||||||
|
className={`rounded-full border px-2 py-0.5 ${open === label ? 'border-[var(--r-accent)] text-[var(--r-text)]' : 'border-[var(--r-line)] text-[var(--r-accent)] hover:bg-black/5'}`}>{label}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
{openRef && <Preview r={openRef} />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><ScriptureRefs text={text} /></AppContext.Provider>);
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
|||||||
import { useApp } from '../context/AppContext.js';
|
import { useApp } from '../context/AppContext.js';
|
||||||
import { useIqAvailable } from '../hooks/useIq.js';
|
import { useIqAvailable } from '../hooks/useIq.js';
|
||||||
import IqCommentary from './iq/IqCommentary.jsx';
|
import IqCommentary from './iq/IqCommentary.jsx';
|
||||||
|
import Family from './FamilyTree.jsx';
|
||||||
import {
|
import {
|
||||||
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
COMMENTARIES, loadCommentary, loadTheographic, loadPerson, pickCommentaryEntry, useAsync,
|
||||||
} from '../hooks/useVerseStudy.js';
|
} from '../hooks/useVerseStudy.js';
|
||||||
@@ -71,6 +72,7 @@ function PersonDetails({ id }) {
|
|||||||
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
<div className="mt-1 space-y-1 text-xs leading-relaxed text-[var(--r-sub,#64748b)]">
|
||||||
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
{(data.description ?? []).map((d, i) => <p key={i}>{d}</p>)}
|
||||||
{!data.description?.length && <p>No description available.</p>}
|
{!data.description?.length && <p>No description available.</p>}
|
||||||
|
<Family person={data} renderPerson={(pid) => <PersonDetails key={pid} id={pid} />} />
|
||||||
{data.minYear != null && (
|
{data.minYear != null && (
|
||||||
<p className="text-[var(--r-sub,#64748b)]">
|
<p className="text-[var(--r-sub,#64748b)]">
|
||||||
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
Lived approx. {era(data.minYear)}{data.maxYear != null && data.maxYear !== data.minYear ? ` – ${era(data.maxYear)}` : ''}
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<article className="rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] p-3">
|
||||||
|
<h4 className="font-medium">{entry.term}</h4>
|
||||||
|
<p className="mt-1 whitespace-pre-line text-[13px] leading-relaxed">{open || !p.cut ? entry.definition : p.text}</p>
|
||||||
|
{p.cut && <button type="button" onClick={() => setOpen((o) => !o)} className="mt-1 text-xs text-[var(--r-accent)] hover:underline">{open ? 'Show less' : 'Read the whole entry'}</button>}
|
||||||
|
{refs.length > 0 && (
|
||||||
|
<p className="mt-2 flex flex-wrap gap-1.5">
|
||||||
|
{refs.map((r) => (
|
||||||
|
<button key={r.label} type="button" onClick={() => { jumpToReaderVerse(r.book, r.chapter, r.verse); openBibleReader(); }} title="Read it in the Bible"
|
||||||
|
className="rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-accent)] hover:bg-black/5">{r.label}</button>
|
||||||
|
))}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="mt-1.5 text-[10px] text-[var(--r-sub)]">{credit}</p>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<details className="mb-3 rounded-xl border border-[var(--r-line)] p-3" aria-label="Bible dictionary">
|
||||||
|
<summary className="cursor-pointer text-sm font-medium">Bible dictionary</summary>
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input type="text" aria-label="Look up a word" value={typed} onChange={(e) => 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`} />
|
||||||
|
<select aria-label="Dictionary" value={current?.id ?? dictId} onChange={(e) => choose(e.target.value)} className={field}>
|
||||||
|
{dictionaries.length === 0 && <option value={dictId}>{dictId}</option>}
|
||||||
|
{dictionaries.map((d) => <option key={d.id} value={d.id}>{d.name.replace(/ Bible Dictionary| Dictionary of the Bible| Bible Names Dictionary/, '')} ({d.entry_count})</option>)}
|
||||||
|
</select>
|
||||||
|
<button type="button" onClick={ask} disabled={!typed.trim()} className="rounded-lg border border-[var(--r-line)] px-3 py-1.5 text-xs hover:bg-black/5 disabled:opacity-50">Look up</button>
|
||||||
|
</div>
|
||||||
|
{result.status === 'loading' && <p className="text-xs text-[var(--r-sub)]">Looking up…</p>}
|
||||||
|
{result.status === 'error' && <p role="alert" className="text-xs text-[var(--r-danger,#b42318)]">{result.error || 'The dictionary could not be reached.'}</p>}
|
||||||
|
{asked && (result.status === 'ready' || result.status === 'empty') && !exact && others.length === 0 && (
|
||||||
|
<p className="text-xs text-[var(--r-sub)]">Nothing for “{asked}” in this dictionary. Try another dictionary or the singular of the word.</p>
|
||||||
|
)}
|
||||||
|
{exact && <Entry entry={exact} credit={credit} open={false} />}
|
||||||
|
{others.length > 0 && (
|
||||||
|
<details className="text-xs">
|
||||||
|
<summary className="cursor-pointer text-[var(--r-sub)]">{exact ? 'Other entries that mention' : 'Entries that mention'} “{asked}” ({others.length})</summary>
|
||||||
|
<div className="mt-2 space-y-2">{others.slice(0, 8).map((e) => <Entry key={e.slug ?? e.term} entry={e} credit={credit} open={false} />)}</div>
|
||||||
|
</details>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||||
|
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(<AppContext.Provider value={{ jumpToReaderVerse: jump, openBibleReader: open }}><IqDictionary /></AppContext.Provider>);
|
||||||
|
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);
|
||||||
|
});
|
||||||
@@ -81,7 +81,7 @@ export default function DataSection() {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button type="button" onClick={download} className={btn}>Download backup</button>
|
<button type="button" onClick={download} className={btn}>Download backup</button>
|
||||||
{authUser && (
|
{authUser && (
|
||||||
<a href="/api/account/export" download className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
<a href="/api/account/export" download onClick={() => markBackedUp()} className={`${btn} inline-block`} title="Everything saved to your account on the server, including studies from other devices">Download from my account</a>
|
||||||
)}
|
)}
|
||||||
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
<button type="button" onClick={() => fileRef.current?.click()} className={btn}>Restore from a backup…</button>
|
||||||
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
<input ref={fileRef} type="file" accept="application/json,.json" onChange={choose} className="hidden" aria-label="Backup file" />
|
||||||
@@ -106,7 +106,7 @@ export default function DataSection() {
|
|||||||
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">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.</p>
|
<p className="mb-3 mt-1 text-sm text-[var(--r-sub)]">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.</p>
|
||||||
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
{offline.job ? <DownloadProgress job={offline.job} onCancel={offline.cancel} /> : (
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
{['BSB', 'NET'].map((t) => (
|
{['BSB', 'NET', 'KJV', 'ASV', 'LSV'].map((t) => (
|
||||||
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
<button key={t} type="button" onClick={() => offline.saveBible(t)} className={btn}>
|
||||||
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
{(offline.saved[t] ?? []).length >= 66 ? `✓ The whole ${t} is saved` : `Save the whole ${t}`}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { render, screen } 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 { ToastProvider } from '../Toasts.jsx';
|
||||||
|
import { lastBackupAt } from '../../utils/backup.js';
|
||||||
|
import DataSection from './DataSection.jsx';
|
||||||
|
|
||||||
|
const value = {
|
||||||
|
authUser: { email: 'a@b.test' }, readerBookmarks: {}, readerBookmarkNotes: {}, readerTextHighlights: [], readerColorLabels: {}, readerInkByPage: {},
|
||||||
|
readingPlansState: { progress: [], customPlans: [], activity: [] }, importReaderMarks: vi.fn(), importReadingPlans: vi.fn(), refreshProjectIndex: vi.fn(), prayers: [], importPrayers: vi.fn(),
|
||||||
|
};
|
||||||
|
const setup = () => {
|
||||||
|
render(<AppContext.Provider value={value}><ToastProvider><DataSection /></ToastProvider></AppContext.Provider>);
|
||||||
|
return userEvent.setup();
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
URL.createObjectURL = vi.fn(() => 'blob:x');
|
||||||
|
URL.revokeObjectURL = vi.fn();
|
||||||
|
document.addEventListener('click', (e) => { if (e.target.closest?.('a[download]')) e.preventDefault(); }); // no real navigation in the test
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloading the backup file is remembered, so Home stops asking', async () => {
|
||||||
|
const user = setup();
|
||||||
|
expect(lastBackupAt()).toBeNull();
|
||||||
|
await user.click(screen.getByRole('button', { name: 'Download backup' }));
|
||||||
|
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('downloading from the account is remembered too', async () => {
|
||||||
|
const user = setup();
|
||||||
|
expect(lastBackupAt()).toBeNull();
|
||||||
|
await user.click(screen.getByRole('link', { name: 'Download from my account' }));
|
||||||
|
expect(lastBackupAt()).toBeGreaterThan(Date.now() - 5000);
|
||||||
|
});
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import SessionNotes from './SessionNotes.jsx';
|
import SessionNotes from './SessionNotes.jsx';
|
||||||
import FirstDraft from './FirstDraft.jsx';
|
import FirstDraft from './FirstDraft.jsx';
|
||||||
|
import ScriptureRefs from '../ScriptureRefs.jsx';
|
||||||
import { usePasteCheck } from './PastePrompt.jsx';
|
import { usePasteCheck } from './PastePrompt.jsx';
|
||||||
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
|
import { listNoteVersions, recordNoteVersion } from '../../utils/noteHistory.js';
|
||||||
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../DictateButton.jsx';
|
||||||
@@ -108,6 +109,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<div className="px-4 pb-4">
|
<div className="px-4 pb-4">
|
||||||
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
<textarea ref={(el) => { areas.current[field] = el; }} aria-label={label} value={chunk[field] ?? ''} rows={7} placeholder={templatePrompt(templateId, field) ?? placeholder}
|
||||||
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
onFocus={() => keepVersion(field)} onBlur={() => keepVersion(field)} onPaste={pasteCheck.onPaste(field)} onChange={(e) => updateChunk(chunk.id, { [field]: e.target.value })} className={areaCls} />
|
||||||
|
<ScriptureRefs text={chunk[field]} />
|
||||||
{pasteCheck.prompt(field)}
|
{pasteCheck.prompt(field)}
|
||||||
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
|
<button type="button" onClick={() => openHistory(field)} aria-expanded={historyField === field} className="mt-1.5 mr-3 text-xs text-[var(--r-sub)] hover:text-[var(--r-text)]">History</button>
|
||||||
{historyField === field && (
|
{historyField === field && (
|
||||||
@@ -150,6 +152,7 @@ export default function NotesPane({ chunk, updateChunk, allTags, templateId, sav
|
|||||||
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
<textarea value={chunk.generalNotes ?? ''} rows={5} onChange={(e) => updateChunk(chunk.id, { generalNotes: e.target.value })}
|
||||||
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
placeholder="e.g. author, date, audience, historical context, key themes…" className={`mt-1 ${areaCls}`} />
|
||||||
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
|
<span className="mt-1 block"><DictateButton label="Dictate background" onText={(said) => updateChunk(chunk.id, { generalNotes: appendSpoken(chunk.generalNotes, said) })} /></span>
|
||||||
|
<ScriptureRefs text={chunk.generalNotes} />
|
||||||
</label>
|
</label>
|
||||||
<label className="block text-xs text-[var(--r-sub)]">
|
<label className="block text-xs text-[var(--r-sub)]">
|
||||||
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
||||||
|
|||||||
@@ -2,10 +2,13 @@
|
|||||||
// interlinear words; click an original word to add it to the chunk's word study.
|
// interlinear words; click an original word to add it to the chunk's word study.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { NT_BOOK_NUMBER } from '../../utils/bibleUtils.jsx';
|
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 }) {
|
export default function ScripturePane({ verses, chapter, interlinear, interlinearLoading, addedStrongs, onPickWord, verseNotes = {}, onVerseNote }) {
|
||||||
const [openVerse, setOpenVerse] = useState(null);
|
const [openVerse, setOpenVerse] = useState(null);
|
||||||
const [editingNote, setEditingNote] = 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];
|
const isHebrew = chapter && !NT_BOOK_NUMBER[chapter.bookAbbrev];
|
||||||
return (
|
return (
|
||||||
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
<section aria-label="Scripture" className="rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] px-5 py-4">
|
||||||
@@ -20,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' : ''}`}>
|
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.chapter !== chapter?.chapter ? `${v.chapter}:` : ''}{v.number}
|
||||||
</button>
|
</button>
|
||||||
<span>{v.text}</span>
|
<span>{markUnits(v.text).map((part, i) => (part.note
|
||||||
|
? <abbr key={i} title={part.note} tabIndex={0} className="cursor-help underline decoration-dotted decoration-[var(--r-accent)] underline-offset-4">{part.text}</abbr>
|
||||||
|
: <span key={i}>{part.text}</span>))}</span>
|
||||||
{onVerseNote && (
|
{onVerseNote && (
|
||||||
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
<button type="button" onClick={() => setEditingNote(editingNote === `${v.chapter}:${v.number}` ? null : `${v.chapter}:${v.number}`)}
|
||||||
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
aria-label={`Note on verse ${v.number}`} title="Note on this verse"
|
||||||
@@ -38,6 +43,7 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
const added = w.s && addedStrongs.has(w.s.toUpperCase());
|
||||||
return (
|
return (
|
||||||
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
<button key={i} type="button" disabled={!w.s} onClick={() => onPickWord(w)}
|
||||||
|
onMouseEnter={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })} onFocus={() => setInspect({ key: `${v.chapter}:${v.number}`, index: i })}
|
||||||
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
title={[w.t, w.s, w.p].filter(Boolean).join(' · ') + (w.s ? (added ? ' — already in word study' : ' — click to add') : '')}
|
||||||
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
className={`flex flex-col items-center rounded-lg border px-2 py-1 text-center ${added ? 'border-[var(--r-accent)]' : 'border-[var(--r-line)] hover:bg-black/5'} disabled:cursor-default`}>
|
||||||
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
<span className={`text-base ${isHebrew ? 'font-serif' : ''}`}>{w.o}</span>
|
||||||
@@ -45,6 +51,9 @@ export default function ScripturePane({ verses, chapter, interlinear, interlinea
|
|||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{inspect?.key === `${v.chapter}:${v.number}` && words[inspect.index]?.p && (
|
||||||
|
<GrammarPanel label={words[inspect.index].p} className="basis-full" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import { externalLookupLinks, speakOriginalWord } from '../../utils/wordStudy.js
|
|||||||
import { useIqAvailable } from '../../hooks/useIq.js';
|
import { useIqAvailable } from '../../hooks/useIq.js';
|
||||||
import { normalizeStrongs } from '../../utils/iqbible.js';
|
import { normalizeStrongs } from '../../utils/iqbible.js';
|
||||||
import IqLexicon from '../iq/IqLexicon.jsx';
|
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 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';
|
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 +52,7 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
{field('Part of speech', 'partOfSpeech', 'e.g. Noun')}
|
||||||
{field('English word', 'englishGloss', 'e.g. grace')}
|
{field('English word', 'englishGloss', 'e.g. grace')}
|
||||||
</div>
|
</div>
|
||||||
|
{word.parsing && <GrammarPanel label={word.parsing} />}
|
||||||
{field('Short definition', 'shortDefinition')}
|
{field('Short definition', 'shortDefinition')}
|
||||||
{word.definitionHtml && (
|
{word.definitionHtml && (
|
||||||
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
<details className="rounded-lg border border-[var(--r-line)] p-2.5">
|
||||||
@@ -76,8 +79,10 @@ function WordCard({ word, open, onToggle, chunkId, wordStudy }) {
|
|||||||
|
|
||||||
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
export default function WordStudyTab({ wordStudy, chunk, openWordId, setOpenWordId }) {
|
||||||
const { suggestingGreekForChunkId, suggestingHebrewForChunkId, suggestGreekWordsForChunk, suggestHebrewWordsForChunk, addGreekWord } = wordStudy;
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
|
{iq && <IqDictionary />}
|
||||||
<div className="mb-3 flex flex-wrap gap-2">
|
<div className="mb-3 flex flex-wrap gap-2">
|
||||||
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
<button type="button" onClick={() => suggestGreekWordsForChunk(chunk.id)} disabled={suggestingGreekForChunkId === chunk.id} className={btn}
|
||||||
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
title="Auto-populate Greek words that actually appear in this passage">{suggestingGreekForChunkId === chunk.id ? 'Suggesting…' : 'Suggest Greek'}</button>
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
|
|||||||
lexeme: w.o || entry?.lemma || '',
|
lexeme: w.o || entry?.lemma || '',
|
||||||
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
transliteration: w.t || entry?.translit || entry?.xlit || '',
|
||||||
partOfSpeech: '',
|
partOfSpeech: '',
|
||||||
|
parsing: w.p || '', // the grammar label from the interlinear, explained in the word's card
|
||||||
shortDefinition: entry?.kjv_def || w.g || '',
|
shortDefinition: entry?.kjv_def || w.g || '',
|
||||||
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
|
||||||
loading: false,
|
loading: false,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// write the final script beside your study notes. Claude prompts and the
|
// write the final script beside your study notes. Claude prompts and the
|
||||||
// pronunciation guide live here because this is where they're needed.
|
// pronunciation guide live here because this is where they're needed.
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import ScriptureRefs from '../components/ScriptureRefs.jsx';
|
||||||
import PresentMode from '../components/PresentMode.jsx';
|
import PresentMode from '../components/PresentMode.jsx';
|
||||||
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
|
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
|
||||||
import { useIqAvailable } from '../hooks/useIq.js';
|
import { useIqAvailable } from '../hooks/useIq.js';
|
||||||
@@ -174,6 +175,7 @@ export default function ScriptPage() {
|
|||||||
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
<textarea aria-label="Final script" value={script} onChange={(e) => set({ finalScript: e.target.value })}
|
||||||
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
placeholder="Write or paste the final script here. “Build outline from notes” starts you from your study notes."
|
||||||
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
className="min-h-[24rem] flex-1 resize-y rounded-2xl border border-[var(--r-line)] bg-[var(--r-bg)] px-5 py-4 font-serif text-[16px] leading-8 text-[var(--r-text)] placeholder:font-sans placeholder:text-sm placeholder:text-[var(--r-sub)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]" />
|
||||||
|
<ScriptureRefs text={script} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -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.' },
|
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',
|
{ 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.' },
|
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,
|
{ id: 'NLT', name: 'New Living Translation', licensed: true,
|
||||||
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
|
notice: 'Holy Bible, New Living Translation, Copyright © 2014, Tyndale House Publishers. All rights reserved. tyndale.com' },
|
||||||
{ id: 'CSB', name: 'Christian Standard Bible', licensed: true,
|
{ 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' },
|
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 translationName = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.name ?? id;
|
||||||
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
|
export const isLicensed = (id) => READER_TRANSLATIONS.find((t) => t.id === id)?.licensed === true;
|
||||||
|
|
||||||
|
|||||||
@@ -485,7 +485,7 @@ export function parseBibleChapter(data) {
|
|||||||
.filter((item) => item?.type === 'verse')
|
.filter((item) => item?.type === 'verse')
|
||||||
.map((item) => ({
|
.map((item) => ({
|
||||||
number: item.number,
|
number: item.number,
|
||||||
text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(),
|
text: renderVerseContent(item.content).replace(/¶\s*/g, '').replace(/\s+/g, ' ').trim(),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Ancient weights, measures and money, with a modern guide. Hover a unit in the passage to see it. The sizes are the
|
||||||
|
// usual round figures; the real ones varied by place and period, so every note says "about".
|
||||||
|
const UNITS = [
|
||||||
|
// length
|
||||||
|
{ words: ['cubit', 'cubits'], note: 'A cubit: about 18 inches (45 cm), the length from elbow to fingertip.' },
|
||||||
|
{ words: ['span', 'spans'], note: 'A span: about 9 inches (23 cm), a hand stretched wide. Half a cubit.' },
|
||||||
|
{ words: ['handbreadth', 'handbreadths', 'handsbreadth'], note: 'A handbreadth: about 3 inches (7.5 cm), the width of four fingers.' },
|
||||||
|
{ words: ['fathom', 'fathoms'], note: 'A fathom: about 6 feet (1.8 m), the span of outstretched arms.' },
|
||||||
|
{ words: ['furlong', 'furlongs', 'stadia', 'stadion'], note: 'A stadion (furlong): about 607 feet (185 m), an eighth of a Roman mile.' },
|
||||||
|
{ words: ['mile', 'miles'], note: 'A Roman mile: about 4,860 feet (1.48 km), a little shorter than a modern mile.' },
|
||||||
|
// weight
|
||||||
|
{ words: ['talent', 'talents'], note: 'A talent: about 75 pounds (34 kg) of silver or gold. Worth around 6,000 days of a labourer’s wages.' },
|
||||||
|
{ words: ['mina', 'minas', 'pound', 'pounds'], note: 'A mina: about 1¼ pounds (0.57 kg). A hundred days’ wages. (Older versions call it a "pound".)', only: ['mina', 'minas'] },
|
||||||
|
{ words: ['shekel', 'shekels'], note: 'A shekel: about 0.4 ounce (11 g). The usual weight of silver for payments.' },
|
||||||
|
{ words: ['beka'], note: 'A beka: about 0.2 ounce (5.7 g). Half a shekel.' },
|
||||||
|
{ words: ['gerah', 'gerahs'], note: 'A gerah: about 0.02 ounce (0.6 g). A twentieth of a shekel.' },
|
||||||
|
// money
|
||||||
|
{ words: ['denarius', 'denarii'], note: 'A denarius: a silver Roman coin, about a day’s wage for a labourer.' },
|
||||||
|
{ words: ['drachma', 'drachmas'], note: 'A drachma: a Greek silver coin, about a day’s wage.' },
|
||||||
|
{ words: ['penny', 'pennies', 'pence'], note: 'In older versions, a "penny" is usually a denarius, about a day’s wage.' },
|
||||||
|
{ words: ['farthing', 'farthings'], note: 'A farthing: a small Roman copper coin, a fraction of a day’s wage.' },
|
||||||
|
{ words: ['lepton', 'mite', 'mites'], note: 'A lepton ("mite"): the smallest Greek coin, a fraction of a day’s wage.' },
|
||||||
|
{ words: ['quadrans'], note: 'A quadrans: a small Roman copper coin worth a fraction of a day’s wage.' },
|
||||||
|
// volume
|
||||||
|
{ words: ['ephah', 'ephahs'], note: 'An ephah: about 22 liters (⅗ bushel) of grain. Ten omers.' },
|
||||||
|
{ words: ['omer', 'omers'], note: 'An omer: about 2.2 liters (2 quarts) of grain. A tenth of an ephah.' },
|
||||||
|
{ words: ['homer', 'homers'], note: 'A homer: about 220 liters (6 bushels), a donkey load. Ten ephahs.' },
|
||||||
|
{ words: ['seah', 'seahs'], note: 'A seah: about 7 liters (7½ quarts) of grain. A third of an ephah.' },
|
||||||
|
{ words: ['bath', 'baths'], note: 'A bath: about 22 liters (6 gallons) of liquid. The same size as an ephah.' },
|
||||||
|
{ words: ['hin'], note: 'A hin: about 3.7 liters (4 quarts) of liquid.' },
|
||||||
|
{ words: ['cor', 'cors', 'kor'], note: 'A cor: about 220 liters (6 bushels). The same as a homer.' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// word -> note, for the words that are safe to mark in running text ("pound" is left out: it is a common word and only the mina is meant in a few verses)
|
||||||
|
const INDEX = new Map();
|
||||||
|
for (const u of UNITS) for (const w of u.words) {
|
||||||
|
if (u.only && !u.only.includes(w)) continue; // "pounds" and "penny" are common words with other meanings; "bath" too
|
||||||
|
INDEX.set(w, u.note);
|
||||||
|
}
|
||||||
|
const RE = new RegExp(`\\b(${[...INDEX.keys()].sort((a, b) => b.length - a.length).join('|')})\\b`, 'gi');
|
||||||
|
|
||||||
|
export const unitNote = (word) => INDEX.get(String(word).toLowerCase()) ?? null;
|
||||||
|
|
||||||
|
// Splits a verse into plain pieces and unit words: [{ text, note? }]. Text that has no unit is a single piece.
|
||||||
|
export function markUnits(text) {
|
||||||
|
const parts = [];
|
||||||
|
let at = 0;
|
||||||
|
for (const m of String(text ?? '').matchAll(RE)) {
|
||||||
|
if (m.index > at) parts.push({ text: text.slice(at, m.index) });
|
||||||
|
parts.push({ text: m[0], note: INDEX.get(m[0].toLowerCase()) });
|
||||||
|
at = m.index + m[0].length;
|
||||||
|
}
|
||||||
|
if (at < text.length) parts.push({ text: text.slice(at) });
|
||||||
|
return parts.length ? parts : [{ text: text ?? '' }];
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { markUnits, unitNote } from './biblicalUnits.js';
|
||||||
|
|
||||||
|
const noted = (text) => markUnits(text).filter((p) => p.note).map((p) => p.text);
|
||||||
|
|
||||||
|
test('units in a verse are found with their meaning, in any form', () => {
|
||||||
|
expect(noted('The ark was two and a half cubits long and a handbreadth thick.')).toEqual(['cubits', 'handbreadth']);
|
||||||
|
expect(noted('He gave him five talents, and she lost one drachma; it cost a denarius.')).toEqual(['talents', 'drachma', 'denarius']);
|
||||||
|
expect(noted('An ephah of flour, an omer of manna, and a hin of oil.')).toEqual(['ephah', 'omer', 'hin']);
|
||||||
|
expect(noted('about twenty-five or thirty stadia')).toEqual(['stadia']);
|
||||||
|
expect(unitNote('Talent')).toMatch(/75 pounds/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the pieces rejoin to the original text, and a verse with no units is one piece', () => {
|
||||||
|
const text = 'five talents of silver (a talent is heavy)';
|
||||||
|
expect(markUnits(text).map((p) => p.text).join('')).toBe(text);
|
||||||
|
expect(markUnits('For God so loved the world')).toEqual([{ text: 'For God so loved the world' }]);
|
||||||
|
expect(markUnits('')).toEqual([{ text: '' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary words are not mistaken for units', () => {
|
||||||
|
expect(noted('He took a pound of flesh; a span of the river; the mile stone')).toEqual(['span', 'mile']);
|
||||||
|
expect(noted('Bathsheba and Cornelius, the stadium crowd, a corner and a hinge')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a mina is a mina, and the older word pound is left alone', () => {
|
||||||
|
expect(noted('ten minas and a mina')).toEqual(['minas', 'mina']);
|
||||||
|
expect(unitNote('pound')).toBeNull();
|
||||||
|
});
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Family connections from the Theographic person records (father, mother, siblings, partners, children).
|
||||||
|
const GROUPS = [['father', 'Father'], ['mother', 'Mother'], ['partners', 'Partner'], ['siblings', 'Siblings'], ['children', 'Children']];
|
||||||
|
|
||||||
|
const asList = (v) => (Array.isArray(v) ? v : v ? [v] : []).filter((p) => p && p.id && p.name);
|
||||||
|
|
||||||
|
// -> [{ label: 'Children', people: [{ id, name }] }] for the relations this person has
|
||||||
|
export function relatives(person) {
|
||||||
|
return GROUPS.map(([key, label]) => ({ label: key === 'partners' && asList(person?.partners).length > 1 ? 'Partners' : label, people: asList(person?.[key]).map(({ id, name }) => ({ id, name })) }))
|
||||||
|
.filter((g) => g.people.length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Walks up (parents) or down (children) from one person, `depth` generations, never visiting anyone twice and
|
||||||
|
// stopping after `max` people so a long line like Adam's doesn't fetch the whole Bible.
|
||||||
|
// load(id) -> person record or null. -> { id, name, more: bool, kids: [node…] } (kids are the parents when going up)
|
||||||
|
export async function walk(load, id, { direction = 'down', depth = 2, max = 40 } = {}) {
|
||||||
|
const seen = new Set([id]);
|
||||||
|
let budget = max;
|
||||||
|
const links = (p) => (direction === 'down' ? asList(p?.children) : [...asList(p?.father), ...asList(p?.mother)]);
|
||||||
|
async function visit(personId, name, level) {
|
||||||
|
const person = await load(personId);
|
||||||
|
const next = links(person).filter((l) => !seen.has(l.id));
|
||||||
|
const node = { id: personId, name: person?.name ?? name, more: false, kids: [] };
|
||||||
|
if (level >= depth) { node.more = next.length > 0; return node; }
|
||||||
|
const taking = next.filter(() => budget > 0 && (budget -= 1) >= 0);
|
||||||
|
taking.forEach((l) => seen.add(l.id));
|
||||||
|
node.more = taking.length < next.length;
|
||||||
|
node.kids = await Promise.all(taking.map((l) => visit(l.id, l.name, level + 1)));
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
const root = await load(id);
|
||||||
|
return visit(id, root?.name ?? '', 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Counts the people below a node (not counting it)
|
||||||
|
export const countKids = (node) => node.kids.reduce((n, k) => n + 1 + countKids(k), 0);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { countKids, relatives, walk } from './family.js';
|
||||||
|
|
||||||
|
const p = (id, name, extra = {}) => ({ id, name, ...extra });
|
||||||
|
const ref = (id, name) => ({ id, name, type: 'people' });
|
||||||
|
const people = {
|
||||||
|
adam: p('adam', 'Adam', { partners: [ref('eve', 'Eve')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||||
|
eve: p('eve', 'Eve', { partners: [ref('adam', 'Adam')], children: [ref('cain', 'Cain'), ref('abel', 'Abel'), ref('seth', 'Seth')] }),
|
||||||
|
cain: p('cain', 'Cain', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enoch', 'Enoch')] }),
|
||||||
|
abel: p('abel', 'Abel', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')] }),
|
||||||
|
seth: p('seth', 'Seth', { father: [ref('adam', 'Adam')], mother: [ref('eve', 'Eve')], children: [ref('enos', 'Enos')], siblings: [ref('cain', 'Cain'), ref('abel', 'Abel')] }),
|
||||||
|
enoch: p('enoch', 'Enoch', { father: [ref('cain', 'Cain')], children: [ref('irad', 'Irad')] }),
|
||||||
|
enos: p('enos', 'Enos', { father: [ref('seth', 'Seth')] }),
|
||||||
|
};
|
||||||
|
const load = async (id) => people[id] ?? null;
|
||||||
|
const names = (n) => ({ [n.name]: n.kids.map(names) });
|
||||||
|
|
||||||
|
test('relations are grouped and named, and empty ones are left out', () => {
|
||||||
|
expect(relatives(people.seth).map((g) => [g.label, g.people.map((x) => x.name)])).toEqual([
|
||||||
|
['Father', ['Adam']], ['Mother', ['Eve']], ['Siblings', ['Cain', 'Abel']], ['Children', ['Enos']],
|
||||||
|
]);
|
||||||
|
expect(relatives(people.adam).map((g) => g.label)).toEqual(['Partner', 'Children']);
|
||||||
|
expect(relatives({ id: 'x', name: 'X', partners: [ref('a', 'A'), ref('b', 'B')] })[0].label).toBe('Partners');
|
||||||
|
expect(relatives(null)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('descendants are walked a few generations down', async () => {
|
||||||
|
const tree = await walk(load, 'adam', { direction: 'down', depth: 2 });
|
||||||
|
expect(names(tree)).toEqual({ Adam: [{ Cain: [{ Enoch: [] }] }, { Abel: [] }, { Seth: [{ Enos: [] }] }] });
|
||||||
|
expect(countKids(tree)).toBe(5);
|
||||||
|
const shallow = await walk(load, 'adam', { direction: 'down', depth: 1 });
|
||||||
|
expect(shallow.kids.find((k) => k.name === 'Cain').more).toBe(true); // Cain has children not shown
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ancestors go up through both parents, without visiting anyone twice', async () => {
|
||||||
|
const up = await walk(load, 'enoch', { direction: 'up', depth: 3 });
|
||||||
|
expect(names(up)).toEqual({ Enoch: [{ Cain: [{ Adam: [] }, { Eve: [] }] }] });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a long line stops at the limit and says there is more', async () => {
|
||||||
|
const tree = await walk(load, 'adam', { direction: 'down', depth: 3, max: 2 });
|
||||||
|
expect(countKids(tree)).toBeLessThanOrEqual(2 + 1); // two taken at the top, each may take nothing more
|
||||||
|
expect(tree.more).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('someone missing from the data still appears by name', async () => {
|
||||||
|
const tree = await walk(load, 'adam', { direction: 'down', depth: 3 });
|
||||||
|
const enoch = tree.kids[0].kids[0];
|
||||||
|
expect(enoch.kids[0].name).toBe('Irad'); // Enoch's child Irad has no record but is named by the link
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
});
|
||||||
@@ -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', () => {
|
test('licensed translations cannot be saved offline', () => {
|
||||||
expect(offlineStatus({ translation: 'ESV', chapters: [{ book: 'John', bookAbbrev: 'JHN' }] }, {}).state).toBe('licensed');
|
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');
|
||||||
|
});
|
||||||
@@ -33,6 +33,14 @@ export function matchBooks(typed) {
|
|||||||
return BOOKS.filter((b) => b.key.startsWith(key));
|
return BOOKS.filter((b) => b.key.startsWith(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The one book a typed name means when it is the book's full name or a known short form ("Rom", "1 Cor", "Jn"), never a
|
||||||
|
// mere beginning of a name: "Number" is not Numbers, "Act" is not Acts. For finding references inside prose.
|
||||||
|
export function exactBook(typed) {
|
||||||
|
const key = squash(typed);
|
||||||
|
if (!key) return null;
|
||||||
|
return BOOKS.find((b) => b.key === key) ?? (ALIASES[key] ? BOOKS.find((b) => b.abbrev === ALIASES[key]) : null) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
|
// "John 3:16" -> [{ book: 'JHN', bookName: 'John', chapter: 3, verse: 16 }]; ambiguous names ("jo")
|
||||||
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
|
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
|
||||||
export function parsePassageRef(text) {
|
export function parsePassageRef(text) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// Finds Scripture references in your own writing ("see Rom 8:28-30", "Psalm 23", "1 Cor 13:4") so they can be
|
||||||
|
// linked to the verse. Only full book names and the usual short forms count, and the chapter must exist.
|
||||||
|
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
|
||||||
|
import { exactBook } from './passageRef.js';
|
||||||
|
|
||||||
|
// Standard short forms that passageRef doesn't list because it matches the start of a name (strict matching can't).
|
||||||
|
// Left out on purpose: "Mar" (March), "Act" and "Number" (ordinary words).
|
||||||
|
const EXTRA = {
|
||||||
|
gen: 'GEN', exo: 'EXO', lev: 'LEV', num: 'NUM', deu: 'DEU', jos: 'JOS', jdg: 'JDG', rut: 'RUT', '1sa': '1SA', '2sa': '2SA', '1sam': '1SA', '2sam': '2SA',
|
||||||
|
'1ki': '1KI', '2ki': '2KI', '1kin': '1KI', '2kin': '2KI', '1ch': '1CH', '2ch': '2CH', ezr: 'EZR', neh: 'NEH', est: 'EST', pro: 'PRO', ecc: 'ECC',
|
||||||
|
isa: 'ISA', lam: 'LAM', eze: 'EZK', ezk: 'EZK', dan: 'DAN', hos: 'HOS', amo: 'AMO', oba: 'OBA', mic: 'MIC', nah: 'NAM', nam: 'NAM', hab: 'HAB',
|
||||||
|
zep: 'ZEP', hag: 'HAG', zec: 'ZEC', mal: 'MAL', luk: 'LUK', joh: 'JHN', '1co': '1CO', '2co': '2CO', gal: 'GAL', eph: 'EPH', col: 'COL',
|
||||||
|
'1th': '1TH', '2th': '2TH', '1ti': '1TI', '2ti': '2TI', tit: 'TIT', phm: 'PHM', heb: 'HEB', jas: 'JAS', '1pe': '1PE', '2pe': '2PE', '1jo': '1JN', '2jo': '2JN', '3jo': '3JN', rev: 'REV',
|
||||||
|
};
|
||||||
|
const squash = (s) => String(s).toLowerCase().replace(/[^a-z0-9]/g, '');
|
||||||
|
const bookFor = (name) => exactBook(name) ?? (EXTRA[squash(name)] ? bookOptions.find((b) => b.abbrev === EXTRA[squash(name)]) ?? null : null);
|
||||||
|
|
||||||
|
// A capitalised name (up to three words, "Song of Solomon") then a chapter, then optionally :verse and -endVerse
|
||||||
|
const RE = /\b((?:[1-3]\s?)?[A-Z][A-Za-z]*\.?(?:\s(?:of\s)?[A-Z][a-z]*\.?){0,2})\s+(\d{1,3})(?::(\d{1,3})(?:\s?[-–—]\s?(\d{1,3}))?)?/g;
|
||||||
|
|
||||||
|
// -> [{ text, start, end, book, bookName, chapter, verse, endVerse }] in the order they appear (verse/endVerse may be null)
|
||||||
|
export function findReferences(text) {
|
||||||
|
const found = [];
|
||||||
|
const source = String(text ?? '');
|
||||||
|
for (const m of source.matchAll(RE)) {
|
||||||
|
const words = m[1].split(/\s+/);
|
||||||
|
// "See Romans 8" captures "See Romans": try the name without its first words until a book matches
|
||||||
|
for (let drop = 0; drop < words.length; drop += 1) {
|
||||||
|
const name = words.slice(drop).join(' ').replace(/\.$/, '');
|
||||||
|
const book = bookFor(name);
|
||||||
|
if (!book) continue;
|
||||||
|
const chapter = Number(m[2]);
|
||||||
|
if (chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 0)) break;
|
||||||
|
const lead = m[1].length - words.slice(drop).join(' ').length;
|
||||||
|
const start = m.index + lead;
|
||||||
|
const verse = m[3] ? Number(m[3]) : null;
|
||||||
|
const endVerse = m[4] && Number(m[4]) > verse ? Number(m[4]) : null;
|
||||||
|
found.push({ text: source.slice(start, m.index + m[0].length), start, end: m.index + m[0].length, book: book.abbrev, bookName: book.name, chapter, verse, endVerse });
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The references with repeats collapsed, by what they point to
|
||||||
|
export function uniqueReferences(text) {
|
||||||
|
const seen = new Set();
|
||||||
|
return findReferences(text).filter((r) => { const k = `${r.book}.${r.chapter}.${r.verse}.${r.endVerse}`; if (seen.has(k)) return false; seen.add(k); return true; });
|
||||||
|
}
|
||||||
|
|
||||||
|
export const labelFor = (r) => `${r.bookName} ${r.chapter}${r.verse ? `:${r.verse}${r.endVerse ? `–${r.endVerse}` : ''}` : ''}`;
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { test, expect } from 'vitest';
|
||||||
|
import { findReferences, labelFor, uniqueReferences } from './scriptureRefs.js';
|
||||||
|
import { exactBook } from './passageRef.js';
|
||||||
|
|
||||||
|
const refs = (text) => findReferences(text).map((r) => labelFor(r));
|
||||||
|
|
||||||
|
test('references of every common shape are found', () => {
|
||||||
|
expect(refs('See Romans 8:28 and Psalm 23, then 1 Cor 13:4-7.')).toEqual(['Romans 8:28', 'Psalms 23', '1 Corinthians 13:4–7']);
|
||||||
|
expect(refs('Compare Jn 3:16; Gen. 1:1 and Song of Solomon 2:4.')).toEqual(['John 3:16', 'Genesis 1:1', 'Song 2:4']);
|
||||||
|
expect(refs('Phil 2:5-11 shows it, as does Heb 11')).toEqual(['Philippians 2:5–11', 'Hebrews 11']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('standard short forms work, and look-alike words do not', () => {
|
||||||
|
expect(refs('Gen 1:1; Exod 3; Deut 6:4; 1 Sam 17; 2 Kgs 5; Zech 9:9; Rev 21:4')).toEqual(['Genesis 1:1', 'Exodus 3', 'Deuteronomy 6:4', '1 Samuel 17', '2 Kings 5', 'Zechariah 9:9', 'Revelation 21:4']);
|
||||||
|
expect(refs('Mar 5 is the date')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the matched text and its position are exact, even when a word like "See" comes first', () => {
|
||||||
|
const text = 'Look: See Romans 8:28 here';
|
||||||
|
const [r] = findReferences(text);
|
||||||
|
expect(r.text).toBe('Romans 8:28');
|
||||||
|
expect(text.slice(r.start, r.end)).toBe('Romans 8:28');
|
||||||
|
expect(r).toMatchObject({ book: 'ROM', chapter: 8, verse: 28, endVerse: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ordinary words and impossible chapters are not references', () => {
|
||||||
|
expect(refs('Number 5 on the list, Act 3 of the play, Chapter 4, Session 2, Day 12')).toEqual([]);
|
||||||
|
expect(refs('Romans 99:1 and John 40')).toEqual([]);
|
||||||
|
expect(refs('in 1999 and 3 days, Thanks 5')).toEqual([]);
|
||||||
|
expect(refs('')).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('repeats collapse and a reversed range is kept as a single verse', () => {
|
||||||
|
expect(uniqueReferences('Rom 8:28 ... Romans 8:28 ... Rom 8:29').map(labelFor)).toEqual(['Romans 8:28', 'Romans 8:29']);
|
||||||
|
expect(findReferences('Rom 8:30-28')[0]).toMatchObject({ verse: 30, endVerse: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('only a full book name or a known short form is a book', () => {
|
||||||
|
expect(exactBook('Romans')?.abbrev).toBe('ROM');
|
||||||
|
expect(exactBook('1 Cor')?.abbrev).toBe('1CO');
|
||||||
|
expect(exactBook('Jn')?.abbrev).toBe('JHN');
|
||||||
|
expect(exactBook('Number')).toBeNull();
|
||||||
|
expect(exactBook('Act')).toBeNull();
|
||||||
|
expect(exactBook('')).toBeNull();
|
||||||
|
});
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
// Fetches a chapter's raw text for building a study (the free Bible source). The Reader's "Save for
|
// 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.
|
// 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.
|
// 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 } = {}) {
|
export async function fetchStudyChapter(translation, bookAbbrev, chapter, { signal } = {}) {
|
||||||
const code = translation === 'NET' ? 'eng_net' : (translation || 'BSB');
|
const code = helloaoCode(translation || 'BSB');
|
||||||
let res;
|
let res;
|
||||||
try {
|
try {
|
||||||
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
|
res = await fetch(`https://bible.helloao.org/api/${code}/${bookAbbrev}/${chapter}.json`, { signal });
|
||||||
|
|||||||
Reference in new issue
Block a user