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/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
)}
diff --git a/src/utils/family.js b/src/utils/family.js
index ceadf7e..d0f716b 100644
--- a/src/utils/family.js
+++ b/src/utils/family.js
@@ -21,7 +21,7 @@ export async function walk(load, id, { direction = 'down', depth = 2, max = 40 }
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((l) => budget > 0 && (budget -= 1) >= 0);
+ 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)));
diff --git a/src/utils/passageRef.js b/src/utils/passageRef.js
index 6c9be44..2a3616f 100644
--- a/src/utils/passageRef.js
+++ b/src/utils/passageRef.js
@@ -33,6 +33,14 @@ export function matchBooks(typed) {
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")
// give several. A bare book ("romans") opens chapter 1. Chapters past the end are rejected.
export function parsePassageRef(text) {
diff --git a/src/utils/scriptureRefs.js b/src/utils/scriptureRefs.js
new file mode 100644
index 0000000..aef60a5
--- /dev/null
+++ b/src/utils/scriptureRefs.js
@@ -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}` : ''}` : ''}`;
diff --git a/src/utils/scriptureRefs.test.js b/src/utils/scriptureRefs.test.js
new file mode 100644
index 0000000..2182f94
--- /dev/null
+++ b/src/utils/scriptureRefs.test.js
@@ -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();
+});