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:
1 parent
f05d38ecde
commit
79d9b091f9
8 files changed
+220
-1
No files matched your search
@@ -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();
|
||||
});
|
||||
@@ -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
|
||||
<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}
|
||||
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)}
|
||||
<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 && (
|
||||
@@ -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 })}
|
||||
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>
|
||||
<ScriptureRefs text={chunk.generalNotes} />
|
||||
</label>
|
||||
<label className="block text-xs text-[var(--r-sub)]">
|
||||
Discussion questions <span className="opacity-70">(one per line; printed on the lesson handout)</span>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// write the final script beside your study notes. Claude prompts and the
|
||||
// pronunciation guide live here because this is where they're needed.
|
||||
import { useState } from 'react';
|
||||
import ScriptureRefs from '../components/ScriptureRefs.jsx';
|
||||
import PresentMode from '../components/PresentMode.jsx';
|
||||
import DictateButton, { appendSpoken } from '../components/DictateButton.jsx';
|
||||
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 })}
|
||||
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)]" />
|
||||
<ScriptureRefs text={script} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
+1
-1
@@ -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)));
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
Reference in new issue
Block a user