Link Scripture references in notes and scripts to the verse (chips with a BSB preview)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-14 15:23:09 -04:00
1 parent f05d38ecde
commit 79d9b091f9
8 files changed
+220 -1

No files matched your search

+63
View File
@@ -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>
);
}
+47
View File
@@ -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();
});
+3
View File
@@ -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
View File
@@ -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>
+1 -1
View File
@@ -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 next = links(person).filter((l) => !seen.has(l.id));
const node = { id: personId, name: person?.name ?? name, more: false, kids: [] }; const node = { id: personId, name: person?.name ?? name, more: false, kids: [] };
if (level >= depth) { node.more = next.length > 0; return node; } 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)); taking.forEach((l) => seen.add(l.id));
node.more = taking.length < next.length; node.more = taking.length < next.length;
node.kids = await Promise.all(taking.map((l) => visit(l.id, l.name, level + 1))); node.kids = await Promise.all(taking.map((l) => visit(l.id, l.name, level + 1)));
+8
View File
@@ -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) {
+51
View File
@@ -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}` : ''}` : ''}`;
+45
View File
@@ -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();
});