Compare commits

..
Author SHA1 Message Date
nmemmertandClaude Sonnet 5.5 5ab416930b Reader: compare button next to Aa
A button beside Aa in the reader's top bar opens a Compare translations
sheet (pick the Bible to compare with, show/hide the differing-word
highlights). It shows as active while a comparison is on. The controls
moved out of the Aa sheet; "C" opens it from the keyboard.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:40:24 -04:00
nmemmertandClaude Sonnet 5.5 aa27a5bb18 Reader: show how much another translation differs from the one being read
- Word-level comparison (ignores case and punctuation): differing words are
  marked in both columns of the side-by-side view
- Chapter summary ("NET is 18% different from BSB · 37 of 39 verses differ")
  with links to the most different verses, and a percentage on each verse
- Compare tab on a verse shows each translation's difference from the one
  being read, with the differing words marked
- "Highlight differences" setting; your own highlights and search matches
  always win where they overlap a difference mark

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:36:46 -04:00
nmemmertandClaude Sonnet 5.5 7101be393e Merge outline-richer: per-chunk outlines, richer points, print and share
Build and Push Docker Image / build-and-push (push) Failing after 20s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:49 -04:00
nmemmertandClaude Sonnet 5.5 820886e3ab Outline: per-chunk outlines, richer points, print and share
- One outline for the whole study plus one per chunk (picker at the top);
  chunk outlines can start from the chunk's verses, each carrying its words
- Points can have a type (illustration, application, quote, question,
  transition), minutes (with a running total), a longer note, verse text
  fetched from a passage, and cross-references / word studies attached from
  the chunk
- Share (system share sheet, or copy), Print / PDF, Copy as text, Word and
  Markdown downloads for any one outline
- All of it included in the Markdown, HTML/PDF and Word study exports,
  with each chunk's outline under that chunk

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:24:28 -04:00
nmemmertandClaude Sonnet 5.5 d273c8cb3e Merge ui-features-flow: Outline stage, parallel reading, offline, backup, sync conflicts
Build and Push Docker Image / build-and-push (push) Failing after 21s
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-01 14:09:07 -04:00
27 changed files with 1087 additions and 127 deletions

No files matched your search

+71
View File
@@ -224,6 +224,77 @@ describe('Outline stage', () => {
});
});
describe('Outline: per chunk, details, print and share', () => {
async function openOutline() {
vi.stubGlobal('fetch', vi.fn((url) => (url.includes('bible.helloao.org') ? json(200, chapterData) : Promise.resolve({ ok: false }))));
const user = userEvent.setup();
await renderApp();
await user.click((await screen.findAllByRole('button', { name: /new project/i }))[0]);
await user.click(await screen.findByRole('button', { name: /load chapter/i }));
await screen.findByText(/Scripture & Chunks/i);
const verses = within(screen.getByTestId('verse-list'));
fireEvent.click(verses.getByRole('button', { name: /In the beginning/ }));
fireEvent.click(verses.getByRole('button', { name: /without form/ }), { shiftKey: true });
await screen.findByText('1 created');
await user.click(screen.getByRole('button', { name: /^outline$/i }));
await screen.findByRole('heading', { name: 'Outline' });
return user;
}
test('each chunk has its own outline, separate from the whole study\'s', async () => {
const user = await openOutline();
const picker = screen.getByRole('combobox', { name: 'Outline for' });
await user.selectOptions(picker, picker.querySelectorAll('option')[1]); // the chunk
expect(await screen.findByText(/no outline yet for/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /start from the verses/i }));
const list = await screen.findByRole('list', { name: 'Outline' });
expect(within(list).getAllByRole('textbox')).toHaveLength(2); // one point per verse
expect(within(list).getByText(/In the beginning God created/)).toBeInTheDocument(); // with its words
await user.selectOptions(screen.getByRole('combobox', { name: 'Outline for' }), 'study');
expect(await screen.findByText(/^No outline yet$/)).toBeInTheDocument(); // the whole study is untouched
expect(screen.getByRole('option', { name: /· 2/ })).toBeInTheDocument(); // the chunk shows its count
});
test('a point can be given a type and minutes, and the total shows', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.type(await screen.findByRole('textbox', { name: 'Point I.' }), 'The story');
await user.click(screen.getByRole('button', { name: /details for i\./i }));
await user.selectOptions(screen.getByLabelText('Type'), 'illustration');
await user.type(screen.getByLabelText('Minutes'), '5');
expect(await screen.findByText('About 5 minutes')).toBeInTheDocument();
await user.type(screen.getByRole('textbox', { name: 'Notes for I.' }), 'Tell it slowly');
await user.click(screen.getByRole('button', { name: /details for i\./i })); // close: the note shows as a line
expect(await screen.findByText('Tell it slowly')).toBeInTheDocument();
expect(screen.getByText('Illustration')).toBeInTheDocument();
});
test('"Add verse text" puts the passage\'s words into the point', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start blank/i }));
await user.click(await screen.findByRole('button', { name: /details for i\./i }));
await user.type(screen.getByLabelText('Passage'), 'Titus 1:1');
await user.click(screen.getByRole('button', { name: /add verse text/i }));
expect(await screen.findByText(/In the beginning God created/)).toBeInTheDocument();
await user.clear(screen.getByLabelText('Passage'));
await user.type(screen.getByLabelText('Passage'), 'nonsense');
await user.click(screen.getByRole('button', { name: /replace verse text/i }));
expect(await screen.findByRole('alert')).toHaveTextContent(/type a passage/i);
});
test('Share copies the outline when there is no share sheet; Print opens a page of it', async () => {
const user = await openOutline();
await user.click(screen.getByRole('button', { name: /start from my chunks/i }));
await screen.findByRole('list', { name: 'Outline' });
await user.click(screen.getByRole('button', { name: /^share$/i }));
expect(await screen.findByText(/outline copied/i)).toBeInTheDocument();
const doc = { write: vi.fn(), close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
await user.click(screen.getByRole('button', { name: /print \/ pdf/i }));
expect(doc.write.mock.calls[0][0]).toContain('class="outline"');
});
});
describe('Study cards on Home', () => {
test('a long run of chapters and many tags stay short', async () => {
const chapters = Array.from({ length: 24 }, (_, i) => `Luke ${i + 1}`).join(', ');
+1
View File
@@ -14,6 +14,7 @@ const BY_PAGE = {
['/', 'Search this chapter or the whole Bible'],
['G', 'Pick a book and chapter'],
['B', 'Bookmark the selected verse'],
['C', 'Compare translations'],
[',', 'Reading settings'],
['F', 'Hide or show the controls'],
],
+35
View File
@@ -0,0 +1,35 @@
// Compare translations: pick a second Bible to read beside the one you're reading, and whether the
// words that differ are highlighted. The chapter shows how different the two are.
import Sheet from './Sheet.jsx';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
export default function CompareSheet({ open, onClose, translation, parallel, onParallel, showDiff, onShowDiff }) {
const chip = (active) => `rounded-full border px-3 py-1.5 text-sm font-medium ${active ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`;
const options = READER_TRANSLATIONS.filter((t) => t.id !== translation);
return (
<Sheet open={open} onClose={onClose} title="Compare translations">
<p className="mb-3 text-sm text-[var(--r-sub)]">
Read another Bible beside the {translation}. You’ll see how different its wording is for the whole chapter and for each verse.
</p>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Compare {translation} with</span>
<div role="group" aria-label="Translation to compare with" className="mb-4 flex flex-wrap gap-1.5">
{options.map((t) => (
<button key={t.id} type="button" title={t.name} aria-pressed={parallel === t.id} onClick={() => { onParallel(parallel === t.id ? '' : t.id); if (parallel !== t.id) onClose(); }} className={chip(parallel === t.id)}>{t.id}</button>
))}
<button type="button" aria-pressed={!parallel} onClick={() => onParallel('')} className={chip(!parallel)}>Off</button>
</div>
{parallel && (
<>
<span className="mb-1.5 block text-xs text-[var(--r-sub)]">Highlight the words that differ</span>
<div role="group" aria-label="Highlight differences" className="flex overflow-hidden rounded-xl border border-[var(--r-line)]">
{[[true, 'Show'], [false, 'Hide']].map(([id, text]) => (
<button key={String(id)} type="button" aria-pressed={showDiff === id} onClick={() => onShowDiff(id)}
className={`flex-1 px-3 py-2 text-sm ${showDiff === id ? 'bg-[var(--r-text)] text-[var(--r-bg)]' : 'text-[var(--r-sub)] hover:bg-black/5'}`}>{text}</button>
))}
</div>
</>
)}
<p className="mt-4 text-[11px] text-[var(--r-sub)]">The wording is compared word by word, ignoring capitals and punctuation. It shows how the wording differs, not which translation is more accurate. Tap any verse and open Compare to see all six translations of it.</p>
</Sheet>
);
}
+13
View File
@@ -0,0 +1,13 @@
// Text with the words that differ from the other translation marked.
export function DiffText({ text, ranges = [] }) {
if (!ranges.length) return text;
const parts = [];
let cursor = 0;
ranges.forEach((r, i) => {
if (r.start > cursor) parts.push(text.slice(cursor, r.start));
parts.push(<span key={i} className="diff-hl">{text.slice(r.start, r.end)}</span>);
cursor = r.end;
});
if (cursor < text.length) parts.push(text.slice(cursor));
return <>{parts}</>;
}
+3 -2
View File
@@ -1,11 +1,11 @@
// Top and bottom chrome for the reader. Everything stays one tap away; focus
// mode hides both.
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon } from './icons.jsx';
import { ChevronLeft, ChevronRight, ChevronDown, SearchIcon, LinkIcon, PenIcon, HeadphonesIcon, FocusIcon, CheckIcon, CompareIcon } from './icons.jsx';
import { BookmarkIcon } from '../../utils/bibleUtils.jsx';
const iconBtn = 'flex h-9 w-9 items-center justify-center rounded-lg text-[var(--r-sub)] transition hover:bg-black/5 disabled:opacity-30 disabled:hover:bg-transparent';
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onSettings, onDoneDrawing }) {
export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks, bookmarkCount, onCompare, compareActive, onSettings, onDoneDrawing }) {
return (
<div className="safe-top sticky top-0 z-30 border-b border-[var(--r-line)] bg-[var(--r-bar)] font-sans">
<div className="mx-auto flex h-12 max-w-4xl items-center gap-1 px-2">
@@ -25,6 +25,7 @@ export function TopBar({ title, onBack, onOpenNavigator, onSearch, onBookmarks,
<button type="button" onClick={onBookmarks} className={`${iconBtn} relative ${bookmarkCount ? 'text-[var(--r-accent)]' : ''}`} aria-label="Bookmarks">
<BookmarkIcon filled={bookmarkCount > 0} />
</button>
<button type="button" onClick={onCompare} className={`${iconBtn} ${compareActive ? 'text-[var(--r-accent)]' : ''}`} aria-label="Compare translations" aria-pressed={!!compareActive} title="Compare translations"><CompareIcon /></button>
<button type="button" onClick={onSettings} className={`${iconBtn} w-10 text-sm font-medium`} aria-label="Reading settings">Aa</button>
</>
)}
+33 -3
View File
@@ -1,6 +1,9 @@
// The page itself: chapter heading, translation notice, and verses set in a
// serif face. Tap a verse to open the verse sheet; select text to highlight.
import { useMemo } from 'react';
import { formatCrossRef } from '../../utils/wordStudy.js';
import { diffChapter, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
// How each typeface choice is set. "Easy to read" is a sans face with extra letter and word spacing.
const FONT_STYLES = {
@@ -13,10 +16,18 @@ const LINE_HEIGHTS = { snug: 1.6, normal: 1.85, airy: 2.15 };
// `parallel` ({ translation, name, notice, verses, loading, error }) shows a second Bible beside the first.
export default function ReaderText({
title, translation, translationId, notice, verses, loading, error, layout, fontSize, wide,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null,
font = 'serif', spacing = 'normal', showVerseNumbers = true, parallel = null, showDiff = true,
bookmarks, bookKey, chapter, selectedVerse, onSelectVerse, renderVerseText, searchLower,
crossRefs, showCrossRefs, onSelectionEnd, footer,
}) {
// How much the second Bible's wording differs from the first, over the chapter and verse by verse
const diff = useMemo(
() => (parallel && showDiff && !parallel.loading && !parallel.error && parallel.verses?.length ? diffChapter(verses, parallel.verses) : null),
[parallel, showDiff, verses],
);
const diffByVerse = useMemo(() => new Map((diff?.verses ?? []).map((d) => [d.number, d])), [diff]);
const jumpToVerse = (n) => document.getElementById(`reader-verse-${n}`)?.scrollIntoView?.({ block: 'center', behavior: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
const handleTap = (n) => {
const sel = window.getSelection();
if (sel && !sel.isCollapsed && sel.toString().trim()) return; // user is selecting text, not tapping
@@ -92,6 +103,23 @@ export default function ReaderText({
{!loading && !error && sideBySide && !parallel.error && (
<div style={bodyStyle} className="font-serif">
{diff && (
<div className="mb-4 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] px-4 py-3 font-sans text-sm" style={{ fontSize: '0.875rem' }}>
<p>
<strong>{parallel.translation}</strong>{diff.similarity === 1 ? ' uses the same words as ' : <> is <strong>{differenceLabel(diff.similarity)}</strong> from </>}{translationId} in this chapter
{diff.total > 0 && <span className="text-[var(--r-sub)]"> · {diff.differing} of {diff.total} verses differ</span>}
</p>
{diff.mostDifferent.length > 0 && (
<p className="mt-1 text-xs text-[var(--r-sub)]">
Most different:{' '}
{diff.mostDifferent.map((v, i) => (
<span key={v.number}>{i > 0 && ', '}<button type="button" onClick={() => jumpToVerse(v.number)} className="font-medium text-[var(--r-accent)] hover:underline">verse {v.number}</button> ({Math.round((1 - v.similarity) * 100)}%)</span>
))}
</p>
)}
<p className="mt-1 text-xs text-[var(--r-sub)]"><span className="diff-hl">Marked words</span> aren’t in the other wording.</p>
</div>
)}
<div className="mb-3 hidden grid-cols-2 gap-x-10 border-b border-[var(--r-line)] pb-1 font-sans text-[11px] uppercase tracking-wide text-[var(--r-sub)] md:grid">
<span>{translationId}</span><span>{parallel.translation}</span>
</div>
@@ -99,12 +127,14 @@ export default function ReaderText({
<div key={row.number} className="mb-4 grid gap-x-10 gap-y-1 md:grid-cols-2">
<p>
{row.first
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower)}{chips(row.first)}</span>
? <span {...verseProps(row.first)}>{num(row.first.number)}{renderVerseText(row.first.number, row.first.text, searchLower, diffByVerse.get(row.number)?.a)}{chips(row.first)}</span>
: <span className="text-[var(--r-sub)]">{num(row.number)}—</span>}
</p>
<p className="border-l-2 border-[var(--r-line)] pl-3 text-[0.95em] text-[var(--r-sub)] md:border-l-0 md:pl-0 md:text-[var(--r-text)]">
<span className="mr-1.5 font-sans text-[10px] uppercase tracking-wide md:hidden">{parallel.translation}</span>
{row.second ? <>{num(row.second.number)}{row.second.text}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
{row.second ? <>{num(row.second.number)}<DiffText text={row.second.text} ranges={diffByVerse.get(row.number)?.b} />{diffByVerse.get(row.number) && !diffByVerse.get(row.number).same && (
<span className="ml-2 whitespace-nowrap font-sans text-[10px] text-[var(--r-sub)]">{differenceLabel(diffByVerse.get(row.number).similarity)}</span>
)}</> : parallel.loading ? <span className="font-sans text-xs" aria-label="Loading">…</span> : '—'}
</p>
</div>
))}
+53
View File
@@ -0,0 +1,53 @@
import { render, screen, fireEvent } from '@testing-library/react';
import { vi, test, expect } from 'vitest';
import ReaderText from './ReaderText.jsx';
const verses = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was formless and void.' },
];
const second = [
{ number: 1, text: 'In the beginning God created the heavens and the earth.' },
{ number: 2, text: 'Now the earth was without shape and empty.' },
];
const base = {
title: 'Genesis 1', translation: 'Berean Standard Bible', translationId: 'BSB', notice: '', verses, loading: false, error: '',
layout: 'flow', fontSize: 1, wide: false, bookmarks: {}, bookKey: 'GEN', chapter: 1, selectedVerse: null,
onSelectVerse: vi.fn(), renderVerseText: (_n, text) => text, searchLower: '', crossRefs: null, showCrossRefs: false, onSelectionEnd: vi.fn(),
};
const parallel = (over = {}) => ({ translation: 'NET', name: 'NET Bible', notice: '', verses: second, loading: false, error: '', ...over });
test('side by side, it says how different the other Bible is and marks the differing words', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff />);
expect(screen.getByText(/is/, { selector: 'p' })).toHaveTextContent(/NET is \d+% different from BSB in this chapter/);
expect(screen.getByText(/1 of 2 verses differ/)).toBeInTheDocument();
const marked = [...container.querySelectorAll('.diff-hl')].map((n) => n.textContent);
expect(marked).toContain('without shape'); // the other Bible's side
expect(screen.getByRole('button', { name: /verse 2/i })).toBeInTheDocument(); // the most different verse
expect(screen.getByText(/% different/, { selector: 'span.ml-2' })).toBeInTheDocument(); // per-verse figure
});
test('identical wording is reported as the same words', () => {
render(<ReaderText {...base} parallel={parallel({ verses })} showDiff />);
expect(screen.getByText(/uses the same words as/)).toBeInTheDocument();
});
test('with highlighting off there is no summary and no marks', () => {
const { container } = render(<ReaderText {...base} parallel={parallel()} showDiff={false} />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(container.querySelectorAll('.diff-hl')).toHaveLength(0);
});
test('while the other Bible loads there is no comparison yet, and the first Bible still shows', () => {
render(<ReaderText {...base} parallel={parallel({ verses: [], loading: true })} showDiff />);
expect(screen.queryByText(/different from BSB/)).not.toBeInTheDocument();
expect(screen.getAllByText(/In the beginning God created/).length).toBeGreaterThan(0);
});
test('the left Bible gets its own differing words passed to the verse renderer', () => {
const renderVerseText = vi.fn((_n, text) => text);
render(<ReaderText {...base} renderVerseText={renderVerseText} parallel={parallel()} showDiff />);
const call = renderVerseText.mock.calls.find(([n]) => n === 2);
expect(call[3].map((r) => verses[1].text.slice(r.start, r.end))).toEqual(['formless', 'void']);
fireEvent.click(screen.getByRole('button', { name: /verse 2/i })); // jumping must not throw even if the element is absent
});
-14
View File
@@ -2,7 +2,6 @@
// to read beside the first.
import Sheet from './Sheet.jsx';
import { READER_THEMES } from './readerThemes.js';
import { READER_TRANSLATIONS } from '../../utils/bibleText.js';
const SIZES = [['S', 0.875], ['M', 1], ['L', 1.125], ['XL', 1.25], ['XXL', 1.5]];
@@ -22,7 +21,6 @@ function Segmented({ options, value, onChange, label }) {
export default function SettingsSheet({
open, onClose, fontSize, onFontSize, theme, onTheme, layout, onLayout, wide, onWide,
font, onFont, spacing, onSpacing, verseNumbers, onVerseNumbers,
translation, parallel, onParallel,
}) {
const row = 'mb-4';
const label = 'mb-1.5 block text-xs text-[var(--r-sub)]';
@@ -56,18 +54,6 @@ export default function SettingsSheet({
<span className={label}>Width</span>
<Segmented label="Reading width" options={[[false, 'Column'], [true, 'Two columns']]} value={wide} onChange={onWide} />
</div>
<div className={row}>
<span className={label}>Read beside another Bible</span>
<div role="group" aria-label="Second translation" className="flex flex-wrap gap-1.5">
{[{ id: '', name: 'Off' }, ...READER_TRANSLATIONS.filter((t) => t.id !== translation)].map((t) => (
<button key={t.id || 'off'} type="button" onClick={() => onParallel(t.id)} aria-pressed={parallel === t.id} title={t.name}
className={`rounded-full border px-3 py-1 text-xs font-medium ${parallel === t.id ? 'border-[var(--r-text)] bg-[var(--r-text)] text-[var(--r-bg)]' : 'border-[var(--r-line)] text-[var(--r-sub)] hover:bg-black/5'}`}>
{t.id || 'Off'}
</button>
))}
</div>
<p className="mt-1.5 text-[11px] text-[var(--r-sub)]">Shows both side by side on wider screens, and stacked on a phone.</p>
</div>
</Sheet>
);
}
+14 -3
View File
@@ -6,6 +6,8 @@ import { CommentaryTab, PeoplePlacesTab } from '../StudyPanel.jsx';
import { BookmarkIcon, CopyIcon } from '../../utils/bibleUtils.jsx';
import { formatCrossRef, speakOriginalWord } from '../../utils/wordStudy.js';
import { READER_TRANSLATIONS, fetchChapter } from '../../utils/bibleText.js';
import { diffVerses, differenceLabel } from '../../utils/textDiff.js';
import { DiffText } from './DiffText.jsx';
const TABS = [['commentary', 'Commentary'], ['compare', 'Compare'], ['original', 'Original'], ['refs', 'Cross-refs'], ['people', 'People & places']];
@@ -68,20 +70,29 @@ function CompareTab({ book, chapter, verse, current }) {
return () => controller.abort();
}, [book, chapter, verse]);
const mine = rows[current]?.text;
return (
<div className="space-y-3">
{READER_TRANSLATIONS.map((t) => {
const row = rows[t.id];
// How this wording differs from the Bible being read, word by word
const d = row?.text && mine && t.id !== current ? diffVerses(mine, row.text) : null;
return (
<div key={t.id}>
<p className="mb-0.5 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">{t.id}{t.id === current ? ' · reading' : ''}</p>
<p className="mb-0.5 flex items-center gap-2 text-[11px] uppercase tracking-wide text-[var(--r-sub)]">
<span>{t.id}{t.id === current ? ' · reading' : ''}</span>
{d && <span className="normal-case tracking-normal">{differenceLabel(d.similarity)}</span>}
</p>
{!row ? <p className="text-sm text-[var(--r-sub)]">Loading…</p>
: row.error ? <p className="text-sm text-[var(--r-sub)]">{row.error}</p>
: <p className="font-serif text-[15px] leading-relaxed">{row.text}</p>}
: <p className="font-serif text-[15px] leading-relaxed"><DiffText text={row.text} ranges={d?.b} /></p>}
</div>
);
})}
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">Copyright notices for each translation appear at the foot of the chapter when you read it.</p>
<p className="border-t border-[var(--r-line)] pt-2 text-[10px] leading-snug text-[var(--r-sub)]">
{mine ? <><span className="diff-hl">Marked words</span> aren’t in the {current} wording. </> : null}
Copyright notices for each translation appear at the foot of the chapter when you read it.
</p>
</div>
);
}
+1
View File
@@ -11,3 +11,4 @@ export const PenIcon = () => <svg {...base}><path d="M13.5 3.5l3 3L7 16l-4 1 1-4
export const HeadphonesIcon = () => <svg {...base}><path d="M3.5 12V10a6.5 6.5 0 0113 0v2" /><rect x="2.5" y="11.5" width="3.5" height="5" rx="1.2" /><rect x="14" y="11.5" width="3.5" height="5" rx="1.2" /></svg>;
export const FocusIcon = () => <svg {...base}><path d="M3 7V4h3M17 7V4h-3M3 13v3h3M17 13v3h-3" /></svg>;
export const CheckIcon = () => <svg {...base}><path d="M4.5 10.5l3.5 3.5 7.5-8" /></svg>;
export const CompareIcon = () => <svg {...base}><rect x="2.5" y="3.5" width="6.5" height="13" rx="1.3" /><rect x="11" y="3.5" width="6.5" height="13" rx="1.3" /><path d="M4.5 7h2.5M4.5 10h2.5M13 7h2.5M13 10h2.5" /></svg>;
+2
View File
@@ -103,6 +103,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
const [readerFont, setReaderFont] = useStoredChoice('reader-font', 'serif', READER_FONTS);
const [readerSpacing, setReaderSpacing] = useStoredChoice('reader-spacing', 'normal', READER_SPACINGS);
const [readerVerseNumbers, setReaderVerseNumbersRaw] = useStoredChoice('reader-verse-numbers', 'on', ['on', 'off']);
const [readerShowDiff, setReaderShowDiffRaw] = useStoredChoice('reader-show-diff', 'on', ['on', 'off']);
const [readerParallel, setReaderParallel] = useStoredChoice('reader-parallel', '', ['', ...READER_TRANSLATIONS.map((t) => t.id)]);
// The page the app opens to, and where every Home button goes: 'studies' | 'reader' | 'plans'
const [startPagePref, setStartPagePrefState] = useState(() => {
@@ -532,6 +533,7 @@ export function useBibleReader(currentPage, translationOverride = null) {
readerSpacing, setReaderSpacing,
readerVerseNumbers: readerVerseNumbers === 'on', setReaderVerseNumbers: (on) => setReaderVerseNumbersRaw(on ? 'on' : 'off'),
readerParallel, setReaderParallel,
readerShowDiff: readerShowDiff === 'on', setReaderShowDiff: (on) => setReaderShowDiffRaw(on ? 'on' : 'off'),
planTranslationPref, setPlanTranslationPref,
startPagePref, setStartPagePref,
readerBookmarks, setReaderBookmarks,
+7
View File
@@ -82,3 +82,10 @@ select {
transition-duration: 0.01ms !important;
}
}
/* Words that differ between two translations (Reader → read beside another Bible) */
.diff-hl {
background: color-mix(in srgb, var(--r-accent, #a84e27) 20%, transparent);
border-bottom: 2px solid color-mix(in srgb, var(--r-accent, #a84e27) 55%, transparent);
border-radius: 2px;
}
+207 -77
View File
@@ -1,21 +1,28 @@
// The Outline stage: the shape of the message as a real outline — main points (I.), sub-points (A.),
// and deeper (1., a.) — written after studying the chunks. Each point can carry a passage and link to
// the chunk it comes from. Everything is kept on the project and goes out with the exports.
// and deeper (1., a.). There is one outline for the whole study and one for each chunk (pick which at
// the top). A point can be an illustration, application, quote…, carry minutes, a longer note, a verse's
// words, and cross-references or word studies from its chunk. Print it, share it, or take it as a file.
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { useToast } from '../components/Toasts.jsx';
import StudioShell from '../components/study/StudioShell.jsx';
import { formatChunkReference } from '../utils/bibleUtils.jsx';
import { buildChapterSummary } from '../utils/projectStorage.js';
import { compactPassages } from '../utils/passageSummary.js';
import { attachableFromChunk, fetchPassageText } from '../utils/passageText.js';
import { downloadOutlineDocx, downloadOutlineMarkdown, printOutline, shareOutline } from '../utils/outlineExport.js';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, insertAt, locate, moveNode, newNode,
outdent, outlineToText, removeNode, seedFromChunks, updateNode,
KINDS, STUDY, addAfter, addChild, addRoot, chunkOf, countNodes, flatten, getNode, indent, insertAt, kindLabel, locate,
moveNode, newNode, outdent, outlineFor, outlineToText, removeNode, seedFromChunks, seedFromVerses, timeSummary, updateNode, withOutline,
} from '../utils/outline.js';
const iconBtn = 'flex h-7 w-7 items-center justify-center rounded-md text-sm text-[var(--r-sub)] hover:bg-black/5 hover:text-[var(--r-text)] disabled:opacity-30 disabled:hover:bg-transparent';
const btn = 'rounded-xl border border-[var(--r-line)] px-3.5 py-2 text-sm hover:bg-black/5 disabled:opacity-50';
const field = 'mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]';
const chip = 'rounded-full border border-[var(--r-line)] px-2 py-0.5 text-[11px] text-[var(--r-sub)]';
// A text box that grows with what is typed, so a long point never hides behind a scroll bar.
function PointText({ value, onChange, ...rest }) {
function AutoText({ value, onChange, className, ...rest }) {
const ref = useRef(null);
useLayoutEffect(() => {
const el = ref.current;
@@ -23,23 +30,25 @@ function PointText({ value, onChange, ...rest }) {
el.style.height = 'auto';
el.style.height = `${el.scrollHeight}px`;
}, [value]);
return (
<textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)}
className="block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none"
{...rest} />
);
return <textarea ref={ref} rows={1} value={value} onChange={(e) => onChange(e.target.value)} className={className} {...rest} />;
}
const pointBox = 'block w-full resize-none overflow-hidden rounded-lg border border-transparent bg-transparent px-2 py-1.5 text-[15px] leading-snug text-[var(--r-text)] placeholder:text-[var(--r-sub)] hover:border-[var(--r-line)] focus:border-[var(--r-line)] focus:bg-[var(--r-bg)] focus:outline-none';
export default function OutlinePage() {
const { project, allChunks, updateProject, setCurrentPage } = useApp();
const { project, updateProject, setCurrentPage } = useApp();
const { toast } = useToast();
const tree = project?.outline ?? [];
const [chosen, setChosen] = useState(STUDY);
const scope = chosen === STUDY || chunkOf(project, chosen) ? chosen : STUDY; // a deleted chunk falls back to the whole study
const scopeChunk = scope === STUDY ? null : chunkOf(project, scope);
const tree = outlineFor(project, scope);
const rows = flatten(tree);
const [pendingFocus, setPendingFocus] = useState(null);
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose passage/chunk fields are showing
const [metaOpen, setMetaOpen] = useState(() => new Set()); // points whose detail fields are showing
const [lookup, setLookup] = useState({ id: null, busy: false, error: '' });
// The one way every change is made, always from the latest outline (typing is fast).
const edit = (fn) => updateProject((current) => ({ ...current, outline: fn(current.outline ?? []) }));
const edit = (fn) => updateProject((current) => withOutline(current, scope, fn(outlineFor(current, scope))));
const patch = (id, change) => edit((t) => updateNode(t, id, change));
useEffect(() => {
if (!pendingFocus) return;
@@ -47,27 +56,27 @@ export default function OutlinePage() {
setPendingFocus(null);
}, [pendingFocus, tree]);
// The chunk choices, named by passage
// Every chunk, named by passage, for the scope picker and the "linked chunk" choice
const chunkChoices = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => chapter.chunks.forEach((chunk) => {
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–') });
chunkChoices.push({ id: chunk.id, label: formatChunkReference(project, chapterIndex, chunk, '–'), count: countNodes(chunk.outline ?? []) });
}));
const unlinked = seedFromChunks(project, tree).length - tree.length;
const scopeLabel = scope === STUDY ? 'Whole study' : chunkChoices.find((c) => c.id === scope)?.label ?? '';
const title = scope === STUDY ? project.title : `${project.title} — ${scopeLabel}`;
const subtitle = scope === STUDY ? compactPassages(buildChapterSummary(project)) : `${scopeLabel} · ${project.translation ?? 'BSB'}`;
const outline = { title, subtitle, tree };
const addMain = () => {
const node = newNode();
edit((t) => addRoot(t, node));
setPendingFocus(node.id);
};
const addSub = (id) => {
const node = newNode();
edit((t) => addChild(t, id, node));
setPendingFocus(node.id);
};
const addSibling = (id) => {
const node = newNode();
edit((t) => addAfter(t, id, node));
setPendingFocus(node.id);
const addable = scope === STUDY
? seedFromChunks(project, tree).length - tree.length
: seedFromVerses(project, scope, tree).length - tree.length;
const seedLabel = scope === STUDY ? 'Add my chunks' : 'Add the verses';
const addMain = () => { const node = newNode(); edit((t) => addRoot(t, node)); setPendingFocus(node.id); };
const addSub = (id) => { const node = newNode(); edit((t) => addChild(t, id, node)); setPendingFocus(node.id); };
const addSibling = (id) => { const node = newNode(); edit((t) => addAfter(t, id, node)); setPendingFocus(node.id); };
const seed = () => {
edit((t) => (scope === STUDY ? seedFromChunks(project, t) : seedFromVerses(project, scope, t)));
toast(`Added ${addable} point${addable === 1 ? '' : 's'}`);
};
const remove = (id) => {
const where = locate(tree, id);
@@ -94,42 +103,87 @@ export default function OutlinePage() {
else if (e.key === 'ArrowDown' && el.selectionStart === text.length && rows[at + 1]) { e.preventDefault(); setPendingFocus(rows[at + 1].node.id); }
};
// ---- taking it out: copy, share, print, files
const copy = async () => {
try { await navigator.clipboard.writeText(outlineToText(tree)); toast('Outline copied'); } catch { toast("Couldn't copy. Select the text and copy it instead.", { tone: 'error' }); }
};
const addChunks = () => {
edit((t) => seedFromChunks(project, t));
toast(`Added ${unlinked} point${unlinked === 1 ? '' : 's'} from your chunks`);
const share = async () => {
const result = await shareOutline(outline);
if (result === 'copied') toast('Outline copied. Paste it into a message or email.');
else if (result === 'failed') toast("Couldn't share or copy the outline.", { tone: 'error' });
};
const print = () => { if (!printOutline(outline)) toast('Allow pop-ups for this site to print or save as PDF.', { tone: 'error' }); };
const word = async () => { try { await downloadOutlineDocx(outline); } catch { toast("Couldn't make the Word file.", { tone: 'error' }); } };
const openChunk = (chunkId) => {
updateProject((current) => ({ ...current, selectedChunkId: chunkId }));
setCurrentPage('study');
};
const toggleMeta = (id) => setMetaOpen((s) => { const next = new Set(s); if (next.has(id)) next.delete(id); else next.add(id); return next; });
// Puts a passage's words into a point (the point's passage field says which).
const addVerseText = async (node) => {
setLookup({ id: node.id, busy: true, error: '' });
try {
const got = await fetchPassageText(node.ref, project.translation ?? 'BSB');
patch(node.id, { scripture: got });
setLookup({ id: null, busy: false, error: '' });
} catch (err) {
setLookup({ id: node.id, busy: false, error: err.message || 'Could not load those verses.' });
}
};
return (
<StudioShell stage="outline">
<main className="mx-auto max-w-3xl px-4 py-8">
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
<div>
<h1 className="text-xl font-medium">Outline</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message: main points, sub-points under them, and deeper if you need it.</p>
</div>
<div className="flex flex-wrap gap-2">
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
<button type="button" onClick={addChunks} disabled={unlinked === 0} className={btn} title="Adds a main point for each chunk that isn't in the outline yet">
Add my chunks{unlinked > 0 ? ` (${unlinked})` : ''}
</button>
<button type="button" onClick={copy} disabled={tree.length === 0} className={btn}>Copy as text</button>
</div>
<div className="mb-4">
<h1 className="text-xl font-medium">Outline</h1>
<p className="mt-1 text-sm text-[var(--r-sub)]">The shape of the message. Keep one for the whole study, and one for each chunk if you like.</p>
</div>
<div className="mb-4 flex flex-wrap items-center gap-x-4 gap-y-2">
<label className="flex items-center gap-2 text-sm text-[var(--r-sub)]">Outline for
<select value={scope} onChange={(e) => setChosen(e.target.value)} aria-label="Outline for"
className="rounded-lg border border-[var(--r-line)] bg-[var(--r-bar)] px-2.5 py-1.5 text-sm text-[var(--r-text)]">
<option value={STUDY}>Whole study{countNodes(project.outline ?? []) ? ` · ${countNodes(project.outline)}` : ''}</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}{c.count ? ` · ${c.count}` : ''}</option>)}
</select>
</label>
{timeSummary(tree) && <span className="text-sm text-[var(--r-sub)]">{timeSummary(tree)}</span>}
{scopeChunk && <button type="button" onClick={() => openChunk(scope)} className="text-sm text-[var(--r-accent)] hover:underline">Open this chunk in Study</button>}
</div>
<div className="mb-5 flex flex-wrap items-center gap-2">
<button type="button" onClick={addMain} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90">+ Main point</button>
<button type="button" onClick={seed} disabled={addable <= 0} className={btn}
title={scope === STUDY ? "Adds a main point for each chunk that isn't in the outline yet" : "Adds a point for each verse of this chunk, with its words"}>
{seedLabel}{addable > 0 ? ` (${addable})` : ''}
</button>
<span className="mx-1 hidden h-6 w-px bg-[var(--r-line)] sm:block" aria-hidden="true" />
<button type="button" onClick={share} disabled={tree.length === 0} className={btn}>Share</button>
<button type="button" onClick={print} disabled={tree.length === 0} className={btn}>Print / PDF</button>
<details className="relative">
<summary className={`${btn} cursor-pointer list-none ${tree.length === 0 ? 'pointer-events-none opacity-50' : ''}`}>More ▾</summary>
<div className="absolute left-0 z-20 mt-1 w-48 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-1 shadow-lg">
<button type="button" onClick={copy} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Copy as text</button>
<button type="button" onClick={word} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Word (.docx)</button>
<button type="button" onClick={() => downloadOutlineMarkdown(outline)} className="block w-full rounded-lg px-3 py-2 text-left text-sm hover:bg-black/5">Download Markdown</button>
</div>
</details>
</div>
{tree.length === 0 ? (
<div className="rounded-2xl border border-dashed border-[var(--r-line)] p-10 text-center">
<p className="text-base font-medium">No outline yet</p>
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">Start from your chunks to get a main point for each passage you studied, or start blank and write your own.</p>
<p className="text-base font-medium">No outline yet{scope === STUDY ? '' : ` for ${scopeLabel}`}</p>
<p className="mx-auto mt-2 max-w-md text-sm text-[var(--r-sub)]">
{scope === STUDY
? 'Start from your chunks to get a main point for each passage you studied, or start blank and write your own.'
: 'Start from the verses to get a point for each one, with its words, or start blank and write your own.'}
</p>
<div className="mt-5 flex flex-wrap justify-center gap-2">
<button type="button" onClick={addChunks} disabled={unlinked === 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Start from my chunks</button>
<button type="button" onClick={seed} disabled={addable <= 0} className="rounded-xl bg-[var(--r-accent)] px-4 py-2 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">
{scope === STUDY ? 'Start from my chunks' : 'Start from the verses'}
</button>
<button type="button" onClick={addMain} className={btn}>Start blank</button>
</div>
</div>
@@ -137,54 +191,130 @@ export default function OutlinePage() {
<ul aria-label="Outline" className="space-y-0.5">
{rows.map(({ node, depth, label }, at) => {
const hasChildren = node.children.length > 0;
const showMeta = metaOpen.has(node.id) || (!!node.ref && false);
const showMeta = metaOpen.has(node.id);
const linked = chunkChoices.find((c) => c.id === node.chunkId);
const siblings = rows.filter((r) => r.parentId === rows[at].parentId);
const place = siblings.findIndex((r) => r.node.id === node.id);
const attachSource = chunkOf(project, node.chunkId ?? scopeChunk?.id);
const attached = new Set((node.attachments ?? []).map((a) => a.id));
const available = attachableFromChunk(attachSource).filter((a) => !attached.has(a.key));
const mins = Number.isFinite(node.minutes) && node.minutes > 0;
const noteLine = (node.note ?? '').trim().split('\n')[0];
return (
<li key={node.id} className="group" style={{ paddingLeft: `${depth * 1.75}rem` }}>
<div className="flex items-start gap-1.5">
<span className="w-9 shrink-0 pt-1.5 text-right font-serif text-sm text-[var(--r-sub)]" aria-hidden="true">{label}</span>
<div className="min-w-0 flex-1">
<PointText
value={node.text} data-outline-id={node.id}
aria-label={`Point ${label}`}
placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
onChange={(value) => edit((t) => updateNode(t, node.id, { text: value }))}
<AutoText
value={node.text} data-outline-id={node.id} className={pointBox}
aria-label={`Point ${label}`} placeholder={depth === 0 ? 'Main point' : 'Sub-point'}
onChange={(value) => patch(node.id, { text: value })}
onKeyDown={(e) => onKeyDown(e, node.id, node.text, hasChildren)}
/>
{(node.ref || linked) && !showMeta && (
<p className="px-2 text-xs text-[var(--r-sub)]">
{node.ref}
{linked && <>{node.ref ? ' · ' : ''}<button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button></>}
{/* What the point carries, at a glance */}
{(node.kind || mins || node.ref || linked) && (
<p className="flex flex-wrap items-center gap-x-2 gap-y-1 px-2 text-xs text-[var(--r-sub)]">
{node.kind && <span className={`${chip} border-[var(--r-accent)] text-[var(--r-accent)]`}>{kindLabel(node.kind)}</span>}
{mins && <span>{node.minutes} min</span>}
{node.ref && <span>{node.ref}</span>}
{linked && node.chunkId !== scope && <button type="button" onClick={() => openChunk(node.chunkId)} className="text-[var(--r-accent)] hover:underline">Open chunk in Study</button>}
</p>
)}
{noteLine && !showMeta && <p className="truncate px-2 text-xs italic text-[var(--r-sub)]">{noteLine}</p>}
{node.scripture?.text && !showMeta && (
<blockquote className="mx-2 mt-1 border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text.length > 220 ? `${node.scripture.text.slice(0, 220)}…` : node.scripture.text}” <span className="not-italic">— {node.scripture.ref}</span></blockquote>
)}
{(node.attachments ?? []).length > 0 && !showMeta && (
<div className="mt-1 flex flex-wrap gap-1 px-2">
{node.attachments.map((a) => <span key={a.id} className={chip} title={a.detail}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</span>)}
</div>
)}
{showMeta && (
<div className="mb-1 grid gap-2 px-2 pt-1 sm:grid-cols-2">
<label className="text-xs text-[var(--r-sub)]">Passage
<input value={node.ref} onChange={(e) => edit((t) => updateNode(t, node.id, { ref: e.target.value }))} placeholder="e.g. Titus 1:5–9"
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]" />
</label>
<label className="text-xs text-[var(--r-sub)]">Linked chunk
<select value={node.chunkId ?? ''} onChange={(e) => edit((t) => updateNode(t, node.id, {
chunkId: e.target.value || null,
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
}))}
className="mt-0.5 block w-full rounded-lg border border-[var(--r-line)] bg-[var(--r-bg)] px-2 py-1.5 text-sm text-[var(--r-text)]">
<option value="">None</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
<div className="mb-2 mt-1 space-y-3 rounded-xl border border-[var(--r-line)] bg-[var(--r-bar)] p-3">
<div className="grid gap-3 sm:grid-cols-[1fr_6rem]">
<label className="text-xs text-[var(--r-sub)]">Type
<select value={node.kind ?? ''} onChange={(e) => patch(node.id, { kind: e.target.value })} className={field}>
{KINDS.map(([id, text]) => <option key={id} value={id}>{text}</option>)}
</select>
</label>
<label className="text-xs text-[var(--r-sub)]">Minutes
<input type="number" min="0" max="240" inputMode="numeric" value={node.minutes ?? ''}
onChange={(e) => patch(node.id, { minutes: e.target.value === '' ? null : Math.max(0, Math.min(240, Math.round(Number(e.target.value)))) })} className={field} />
</label>
</div>
<label className="block text-xs text-[var(--r-sub)]">Notes
<AutoText value={node.note ?? ''} onChange={(value) => patch(node.id, { note: value })} placeholder="Anything to remember when you speak or write this point"
className={`${field} min-h-[4.5rem] resize-none overflow-hidden`} aria-label={`Notes for ${label}`} />
</label>
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="text-xs text-[var(--r-sub)]">Passage
<input value={node.ref ?? ''} onChange={(e) => patch(node.id, { ref: e.target.value })} placeholder="e.g. Titus 1:5–9" className={field} />
</label>
<div className="mt-1.5 flex items-center gap-2">
<button type="button" disabled={!node.ref?.trim() || (lookup.busy && lookup.id === node.id)} onClick={() => addVerseText(node)} className={`${btn} !px-3 !py-1 text-xs`}>
{lookup.busy && lookup.id === node.id ? 'Loading…' : node.scripture ? 'Replace verse text' : 'Add verse text'}
</button>
{node.scripture && <button type="button" onClick={() => patch(node.id, { scripture: null })} className="text-xs text-[var(--r-sub)] hover:text-[var(--r-danger,#b42318)]">Remove</button>}
</div>
{lookup.id === node.id && lookup.error && <p role="alert" className="mt-1 text-xs text-[var(--r-danger,#b42318)]">{lookup.error}</p>}
</div>
<label className="text-xs text-[var(--r-sub)]">Linked chunk
<select value={node.chunkId ?? ''} onChange={(e) => patch(node.id, {
chunkId: e.target.value || null,
...(e.target.value && !node.ref ? { ref: chunkChoices.find((c) => c.id === e.target.value)?.label ?? '' } : {}),
})} className={field}>
<option value="">None</option>
{chunkChoices.map((c) => <option key={c.id} value={c.id}>{c.label}</option>)}
</select>
</label>
</div>
{node.scripture?.text && (
<blockquote className="border-l-2 border-[var(--r-line)] pl-2.5 font-serif text-sm italic text-[var(--r-sub)]">“{node.scripture.text}” <span className="not-italic">— {node.scripture.ref} ({node.scripture.translation})</span></blockquote>
)}
<div>
<p className="text-xs text-[var(--r-sub)]">Attach from {attachSource ? 'its chunk' : 'a chunk'}</p>
{(node.attachments ?? []).length > 0 && (
<div className="mt-1 flex flex-wrap gap-1">
{node.attachments.map((a) => (
<span key={a.id} className={`${chip} inline-flex items-center gap-1`} title={a.detail}>
{a.type === 'word' ? 'Word' : 'See'}: {a.label}
<button type="button" aria-label={`Remove ${a.label}`} onClick={() => patch(node.id, { attachments: node.attachments.filter((x) => x.id !== a.id) })} className="hover:text-[var(--r-danger,#b42318)]">×</button>
</span>
))}
</div>
)}
{!attachSource ? (
<p className="mt-1 text-xs text-[var(--r-sub)]">Link a chunk above to attach its cross-references and word studies.</p>
) : available.length === 0 ? (
<p className="mt-1 text-xs text-[var(--r-sub)]">{(node.attachments ?? []).length ? 'Everything from this chunk is attached.' : 'This chunk has no cross-references or word studies yet. Add some in the Study stage.'}</p>
) : (
<select value="" aria-label={`Attach to ${label}`} className={field}
onChange={(e) => {
const pick = available.find((a) => a.key === e.target.value);
if (pick) patch(node.id, { attachments: [...(node.attachments ?? []), { id: pick.key, type: pick.type, label: pick.label, detail: pick.detail }] });
}}>
<option value="">Choose a cross-reference or word…</option>
{available.map((a) => <option key={a.key} value={a.key}>{a.type === 'word' ? 'Word' : 'See'}: {a.label}</option>)}
</select>
)}
</div>
</div>
)}
</div>
<div className="flex shrink-0 items-center opacity-0 transition focus-within:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100">
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => { edit((t) => outdent(t, node.id)); }}>←</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => { edit((t) => indent(t, node.id)); }}>→</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} out a level`} title="Out a level (Shift+Tab)" disabled={depth === 0} onClick={() => edit((t) => outdent(t, node.id))}>←</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} in a level`} title="In a level (Tab)" disabled={place === 0} onClick={() => edit((t) => indent(t, node.id))}>→</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} up`} title="Up (Alt+↑)" disabled={place === 0} onClick={() => edit((t) => moveNode(t, node.id, -1))}>↑</button>
<button type="button" className={iconBtn} aria-label={`Move ${label} down`} title="Down (Alt+↓)" disabled={place === siblings.length - 1} onClick={() => edit((t) => moveNode(t, node.id, 1))}>↓</button>
<button type="button" className={iconBtn} aria-label={`Add a sub-point under ${label}`} title="Add a sub-point" onClick={() => addSub(node.id)}>+</button>
<button type="button" className={iconBtn} aria-label={`Passage and chunk for ${label}`} aria-expanded={showMeta} title="Passage and chunk" onClick={() => toggleMeta(node.id)}>⋯</button>
<button type="button" className={iconBtn} aria-label={`Details for ${label}`} aria-expanded={showMeta} title="Type, minutes, notes, verse text, attachments" onClick={() => toggleMeta(node.id)}>⋯</button>
<button type="button" className={`${iconBtn} hover:!text-[var(--r-danger,#b42318)]`} aria-label={`Delete ${label}`} title="Delete" onClick={() => remove(node.id)}>×</button>
</div>
</div>
@@ -196,7 +326,7 @@ export default function OutlinePage() {
{tree.length > 0 && (
<p className="mt-6 text-xs text-[var(--r-sub)]">
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it.
Enter adds a point · Tab and Shift+Tab change its level · Alt+↑ / ↓ moves it · Backspace on an empty point deletes it · ⋯ opens its details.
</p>
)}
</main>
+18 -2
View File
@@ -15,6 +15,7 @@ import ReaderText from '../components/reader/ReaderText.jsx';
import PlanStrip from '../components/reader/PlanStrip.jsx';
import NavigatorSheet from '../components/reader/NavigatorSheet.jsx';
import SettingsSheet from '../components/reader/SettingsSheet.jsx';
import CompareSheet from '../components/reader/CompareSheet.jsx';
import SearchSheet from '../components/reader/SearchSheet.jsx';
import MarksSheet from '../components/reader/MarksSheet.jsx';
import { useToast } from '../components/Toasts.jsx';
@@ -47,6 +48,7 @@ export default function ReaderPage() {
readerSpacing, setReaderSpacing,
readerVerseNumbers, setReaderVerseNumbers,
readerParallel, setReaderParallel,
readerShowDiff, setReaderShowDiff,
readerBookmarks,
readerCrossRefs, setReaderCrossRefs,
readerCrossRefsLoading,
@@ -271,6 +273,7 @@ export default function ReaderPage() {
if (e.key === '/') { e.preventDefault(); openSheet('search'); }
else if (e.key === 'g' || e.key === 'G') { e.preventDefault(); openSheet('nav'); }
else if (e.key === ',') { e.preventDefault(); openSheet('settings'); }
else if (e.key === 'c' || e.key === 'C') { e.preventDefault(); openSheet('compare'); }
else if (e.key === 'f' || e.key === 'F') setChromeVisible((v) => !v);
else if ((e.key === 'b' || e.key === 'B') && selectedKey) {
readerBookmarks[selectedKey] ? removeBookmark(selectedKey, readerBookmarks[selectedKey]) : toggleReaderBookmark(selectedKey);
@@ -302,8 +305,9 @@ export default function ReaderPage() {
// Render verse text with colored highlights and optional search match highlight
const chapterSearch = sheet === 'search' && readerSearchScope === 'chapter' ? readerSearch.trim().toLowerCase() : '';
function renderVerseText(verseNum, text, searchLow) {
function renderVerseText(verseNum, text, searchLow, diffRanges) {
const spans = [];
for (const r of diffRanges ?? []) spans.push({ start: r.start, end: r.end, type: 'diff' });
for (const h of (readerTextHighlights || [])) {
if (h.book === readerBookAbbrev && h.chapter === readerChapter && h.verse === verseNum) {
spans.push({ start: h.startOffset, end: h.endOffset, type: 'hl', id: h.id, color: h.color });
@@ -318,6 +322,11 @@ export default function ReaderPage() {
}
}
if (!spans.length) return text;
// Your highlights and search matches always show; a difference mark that would overlap one is dropped
const firm = spans.filter((x) => x.type !== 'diff');
for (let i = spans.length - 1; i >= 0; i -= 1) {
if (spans[i].type === 'diff' && firm.some((f) => spans[i].start < f.end && f.start < spans[i].end)) spans.splice(i, 1);
}
spans.sort((a, b) => a.start - b.start);
const parts = [];
let cursor = 0;
@@ -332,6 +341,8 @@ export default function ReaderPage() {
{text.slice(s.start, s.end)}
</mark>,
);
} else if (s.type === 'diff') {
parts.push(<span key={`diff${s.start}`} className="diff-hl">{text.slice(s.start, s.end)}</span>);
} else {
parts.push(<mark key={`srch${s.start}`} className="rounded bg-[var(--r-hl)] px-0.5 text-[var(--r-text)]">{text.slice(s.start, s.end)}</mark>);
}
@@ -396,6 +407,7 @@ export default function ReaderPage() {
onSearch={() => openSheet('search')}
onBookmarks={() => openSheet('bookmarks')}
bookmarkCount={bookmarkEntries.length + highlightEntries.length}
onCompare={() => openSheet('compare')} compareActive={!!parallelId}
onSettings={() => openSheet('settings')}
onDoneDrawing={readerDrawMode ? () => setReaderDrawMode(false) : undefined}
/>
@@ -429,7 +441,7 @@ export default function ReaderPage() {
title={`${readerBook?.name ?? ''} ${readerChapter}`}
translation={READER_TRANSLATIONS.find((t) => t.id === readerTranslation)?.name ?? readerTranslation}
translationId={readerTranslation}
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers}
font={readerFont} spacing={readerSpacing} showVerseNumbers={readerVerseNumbers} showDiff={readerShowDiff}
parallel={parallelId ? { translation: parallelId, name: parallelSource?.name ?? parallelId, notice: parallelSource?.notice ?? '', ...parallelChapter } : null}
notice={readerNotice}
verses={readerVerses}
@@ -549,7 +561,11 @@ export default function ReaderPage() {
font={readerFont} onFont={setReaderFont}
spacing={readerSpacing} onSpacing={setReaderSpacing}
verseNumbers={readerVerseNumbers} onVerseNumbers={setReaderVerseNumbers}
/>
<CompareSheet
open={sheet === 'compare'} onClose={closeSheet}
translation={readerTranslation} parallel={parallelId} onParallel={setReaderParallel}
showDiff={readerShowDiff} onShowDiff={setReaderShowDiff}
/>
<SearchSheet
open={sheet === 'search'} onClose={closeSheet}
+11
View File
@@ -47,6 +47,17 @@ test('renders the chapter, chrome and opens the tool sheets', () => {
expect(screen.getByPlaceholderText(/Search John 3/)).toBeTruthy();
});
test('the compare button next to Aa opens the comparison sheet, and choosing a Bible turns it on', () => {
ctx.setReaderParallel = vi.fn();
render(<ReaderPage />);
const compare = screen.getByLabelText('Compare translations');
expect(compare.nextElementSibling).toBe(screen.getByLabelText('Reading settings')); // sits right beside Aa
fireEvent.click(compare);
expect(screen.getByText(/how different its wording is/)).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'NET' }));
expect(ctx.setReaderParallel).toHaveBeenCalledWith('NET');
});
test('tapping a verse selects it', () => {
render(<ReaderPage />);
fireEvent.click(screen.getByText(/This man came to Jesus/));
+65 -3
View File
@@ -1,4 +1,4 @@
import { labelFor as outlineLabel } from './outlineLabels.js';
import { labelFor as outlineLabel, kindLabel as outlineKindLabel } from './outlineLabels.js';
// ---------------------------------------------------------------------------
// Study constants
@@ -193,6 +193,10 @@ export function buildExportHtml(project) {
.outline > ul { margin-left: 0; }
.outline li { margin: 0.3rem 0; line-height: 1.5; }
.outline .lbl { display: inline-block; min-width: 2rem; font-weight: 700; }
.outline .kind { font-size: 0.75em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; }
.outline .o-note { margin: 0.2rem 0 0 2rem; color: #334155; font-size: 0.95em; }
.outline .o-quote { margin: 0.3rem 0 0.3rem 2rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; }
.outline .o-attach { margin-left: 2rem; font-size: 0.85em; color: #64748b; }
.outline .ref { color: #64748b; font-size: 0.9em; }
.definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; }
@media print {
@@ -206,7 +210,7 @@ export function buildExportHtml(project) {
// The outline, if one was written: nested lists, labelled I. / A. / 1. like on the page
const outlineHtml = (project.outline ?? []).length > 0
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineListHtml(project.outline, 0)}</section>`
? `<section class="outline"><h2 class="chapter-heading">Outline</h2>${outlineTimeHtml(project.outline)}${outlineListHtml(project.outline, 0)}</section>`
: '';
const chunksHtml = chapters.map((ch, chapterIndex) => {
@@ -268,6 +272,7 @@ export function buildExportHtml(project) {
<section class="chunk">
<div class="scripture-ref">${escHtml(scripture)}</div>
${versesText}
${(chunk.outline ?? []).length > 0 ? `<div class="outline"><strong>OUTLINE:</strong>${outlineListHtml(chunk.outline, 0)}</div>` : ''}
<div class="oia">
<div class="oia-section"><strong>OBSERVATION:</strong><p>${observation}</p></div>
<div class="oia-section"><strong>INTERPRETATION:</strong><p>${interpretation}</p></div>
@@ -304,8 +309,65 @@ export function buildExportHtml(project) {
}
// <ul> of outline points, each with its label (I., A., 1. …) and passage
function outlineTimeHtml(tree) {
const total = flattenMinutes(tree);
return total > 0 ? `<p class="ref">About ${total} minute${total === 1 ? '' : 's'}</p>` : '';
}
function flattenMinutes(nodes) {
return (nodes ?? []).reduce((sum, n) => sum + (Number.isFinite(n.minutes) ? n.minutes : 0) + flattenMinutes(n.children), 0);
}
function outlineListHtml(nodes, depth) {
return `<ul>${nodes.map((n, i) => `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`).join('')}</ul>`;
const one = (n, i) => {
const kind = n.kind ? `<span class="kind">${escHtml(outlineKindLabel(n.kind))}</span> ` : '';
const mins = Number.isFinite(n.minutes) && n.minutes > 0 ? ` <span class="ref">· ${n.minutes} min</span>` : '';
const note = (n.note ?? '').trim() ? `<div class="o-note">${escHtml(n.note.trim()).replace(/\n/g, '<br />')}</div>` : '';
const quote = n.scripture?.text ? `<blockquote class="o-quote">“${escHtml(n.scripture.text)}” — ${escHtml(n.scripture.ref)}</blockquote>` : '';
const attached = (n.attachments ?? []).length > 0
? `<div class="o-attach">${n.attachments.map((a) => `${a.type === 'word' ? 'Word' : 'See'}: ${escHtml(a.label)}${a.detail ? ` — ${escHtml(a.detail)}` : ''}`).join(' · ')}</div>` : '';
return `<li><span class="lbl">${escHtml(outlineLabel(depth, i))}</span>${kind}${escHtml((n.text ?? '').trim() || '(untitled)')}${n.ref ? ` <span class="ref">(${escHtml(n.ref)})</span>` : ''}${mins}${note}${quote}${attached}${n.children?.length ? outlineListHtml(n.children, depth + 1) : ''}</li>`;
};
return `<ul>${nodes.map(one).join('')}</ul>`;
}
// A page of just one outline (for printing or saving as PDF): the title, the passage, the points.
export function buildOutlineHtml({ title, subtitle = '', tree }) {
const total = flattenMinutes(tree);
const style = `
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 40px; }
.page { max-width: 760px; margin: auto; }
h1 { margin: 0 0 0.25rem; font-size: 1.7rem; }
.sub { color: #64748b; margin: 0 0 1.5rem; }
.outline ul { list-style: none; margin: 0.25rem 0 0.25rem 1.6rem; padding: 0; }
.outline > ul { margin-left: 0; }
.outline li { margin: 0.45rem 0; line-height: 1.5; }
.outline > ul > li { margin-top: 0.9rem; font-weight: 600; }
.outline > ul > li li { font-weight: 400; }
.lbl { display: inline-block; min-width: 2.1rem; font-weight: 700; }
.ref { color: #64748b; font-size: 0.9em; font-weight: 400; }
.kind { font-size: 0.72em; text-transform: uppercase; letter-spacing: 0.05em; color: #92400e; border: 1px solid #fcd34d; border-radius: 9999px; padding: 0 0.4em; margin-right: 0.3em; font-weight: 400; }
.o-note { margin: 0.2rem 0 0 2.1rem; color: #334155; font-size: 0.95em; font-weight: 400; }
.o-quote { margin: 0.3rem 0 0.3rem 2.1rem; padding-left: 0.75rem; border-left: 3px solid #cbd5e1; font-style: italic; font-weight: 400; }
.o-attach { margin-left: 2.1rem; font-size: 0.85em; color: #64748b; font-weight: 400; }
@media print { body { padding: 0; } li { break-inside: avoid; } }
`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escHtml(title)} — Outline</title>
<style>${style}</style>
</head>
<body>
<div class="page">
<h1>${escHtml(title)}</h1>
<p class="sub">${escHtml([subtitle, total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '))}</p>
<div class="outline">${outlineListHtml(tree, 0)}</div>
</div>
</body>
</html>`;
}
export function wordTableHtml(rows) {
+40 -12
View File
@@ -14,7 +14,8 @@ import {
WidthType,
} from 'docx';
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
import { flatten } from './outline.js';
import { detailLines, flatten, timeSummary } from './outline.js';
import { kindLabel } from './outlineLabels.js';
export function createParagraphsFromText(text) {
return text
@@ -73,6 +74,37 @@ export function sectionLabel(text) {
});
}
// The paragraphs of an outline: each point indented by level, with its note, verse and attachments under it.
export const outlineParagraphs = (tree) => flatten(tree).flatMap(({ node, depth, label }) => [
new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 60 },
children: [
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
...(node.kind ? [new TextRun({ text: `${kindLabel(node.kind)}: `, italics: true, color: '92400E' })] : []),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
...(Number.isFinite(node.minutes) && node.minutes > 0 ? [new TextRun({ text: ` · ${node.minutes} min`, color: '7A7060' })] : []),
],
}),
...detailLines(node).map((line) => new Paragraph({
indent: { left: 420 * depth + 420 },
spacing: { after: 40 },
children: [new TextRun({ text: line, italics: true, color: '4B5563', size: 20 })],
})),
]);
// Just one outline, as its own Word document.
export async function buildOutlineDocxBlob({ title, subtitle = '', tree }) {
const children = [
new Paragraph({ text: title, heading: HeadingLevel.TITLE }),
new Paragraph({ children: [new TextRun({ text: [subtitle, timeSummary(tree)].filter(Boolean).join(' · '), italics: true, color: '7A7060' })], spacing: { after: 300 } }),
...outlineParagraphs(tree),
];
const doc = new Document({ styles: DOCX_STYLES, sections: [{ children }] });
return Packer.toBlob(doc);
}
export async function buildStudyDocxBlob(project, chapterSummary) {
const children = [
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
@@ -93,17 +125,8 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
// The outline comes first, indented by level like the page
if ((project.outline ?? []).length > 0) {
children.push(new Paragraph({ text: 'Outline', heading: HeadingLevel.HEADING_1, spacing: { before: 240, after: 160 } }));
flatten(project.outline).forEach(({ node, depth, label }) => {
children.push(new Paragraph({
indent: { left: 420 * depth, hanging: 420 },
spacing: { after: 80 },
children: [
new TextRun({ text: `${label}\t`, bold: true, color: DOCX_ACCENT }),
new TextRun({ text: (node.text ?? '').trim() || '(untitled)' }),
...(node.ref ? [new TextRun({ text: ` (${node.ref})`, italics: true, color: '7A7060' })] : []),
],
}));
});
if (timeSummary(project.outline)) children.push(new Paragraph({ children: [new TextRun({ text: timeSummary(project.outline), italics: true, color: '7A7060' })], spacing: { after: 120 } }));
children.push(...outlineParagraphs(project.outline));
}
project.chapters.forEach((ch, chapterIndex) => {
@@ -154,6 +177,11 @@ export async function buildStudyDocxBlob(project, chapterSummary) {
}));
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
if ((chunk.outline ?? []).length > 0) {
children.push(sectionLabel('Outline'));
children.push(...outlineParagraphs(chunk.outline));
}
if (chunk.generalNotes?.trim()) {
children.push(sectionLabel('Background / General Notes'));
children.push(...createParagraphsFromText(chunk.generalNotes));
+5 -3
View File
@@ -4,7 +4,7 @@ import {
getChunkVerseEntries,
htmlToPlainText,
} from './bibleUtils.jsx';
import { outlineToMarkdown } from './outline.js';
import { outlineToMarkdown, timeSummary } from './outline.js';
// ---------------------------------------------------------------------------
// Export / prompt builders (exported for testing)
@@ -43,13 +43,15 @@ export function buildMarkdownExport(project) {
return `- ${summary}${definition}`;
}).join('\n')}\n\n`;
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
const chunkOutline = (chunk.outline ?? []).length > 0 ? `**Outline**\n\n${outlineToMarkdown(chunk.outline)}\n\n` : '';
return `### ${scripture}\n\n${versesText}\n\n${chunkOutline}${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
}).join('---\n\n');
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
}).join('\n');
const outline = (project.outline ?? []).length > 0 ? `## Outline\n\n${outlineToMarkdown(project.outline)}\n\n` : '';
const outline = (project.outline ?? []).length > 0 ? `## Outline\n\n${timeSummary(project.outline) ? `*${timeSummary(project.outline)}*\n\n` : ''}${outlineToMarkdown(project.outline)}\n\n` : '';
return `# ${project.title}\n\n*${project.translation}*\n\n${outline}${chapterSections}`;
}
+79 -7
View File
@@ -1,14 +1,18 @@
// A study's true outline: main points, sub-points under them, and so on (I. / A. / 1. / a.). It is a tree
// of nodes { id, text, ref, chunkId, children } kept on the project as `project.outline`. Every function
// returns a new tree and leaves the one it was given alone.
import { formatChunkReference, makeId } from './bibleUtils.jsx';
import { labelFor, toRoman } from './outlineLabels.js';
import { formatChunkReference, getChunkVerseEntries, makeId } from './bibleUtils.jsx';
import { KINDS, kindLabel, labelFor, toRoman } from './outlineLabels.js';
export { labelFor, toRoman };
export { KINDS, kindLabel, labelFor, toRoman };
export const MAX_DEPTH = 5; // levels below the main points
export const newNode = (text = '', extra = {}) => ({ id: makeId(), text, ref: '', chunkId: null, children: [], ...extra });
// A point: its words, an optional passage and chunk link, plus a longer note, a kind, minutes,
// the text of a verse, and things attached from the chunks (cross-references, word studies).
export const newNode = (text = '', extra = {}) => ({
id: makeId(), text, ref: '', chunkId: null, note: '', kind: '', minutes: null, scripture: null, attachments: [], children: [], ...extra,
});
// ---- finding ---------------------------------------------------------------
@@ -136,14 +140,44 @@ export function flatten(tree, depth = 0, parentId = null) {
export const countNodes = (tree) => flatten(tree).length;
const pointText = (n) => `${n.text.trim() || '(untitled)'}${n.ref.trim() ? ` (${n.ref.trim()})` : ''}`;
const minutesText = (m) => (Number.isFinite(m) && m > 0 ? `${m} min` : '');
// Total minutes written on the points (blank ones count as nothing)
export const totalMinutes = (tree) => flatten(tree).reduce((sum, { node }) => sum + (Number.isFinite(node.minutes) ? node.minutes : 0), 0);
// The headline of a point: "[Illustration] The words (passage) · 5 min"
const pointText = (n) => {
const kind = n.kind ? `[${kindLabel(n.kind)}] ` : '';
const mins = minutesText(n.minutes);
return `${kind}${(n.text ?? '').trim() || '(untitled)'}${(n.ref ?? '').trim() ? ` (${n.ref.trim()})` : ''}${mins ? ` · ${mins}` : ''}`;
};
// The lines under a point's headline: its note, verse text and attachments.
export function detailLines(node) {
const lines = [];
for (const l of (node.note ?? '').split('\n')) if (l.trim()) lines.push(l.trim());
if (node.scripture?.text) lines.push(`“${node.scripture.text}” — ${node.scripture.ref}`);
for (const a of node.attachments ?? []) lines.push(`${a.type === 'word' ? 'Word' : 'See'}: ${a.label}${a.detail ? ` — ${a.detail}` : ''}`);
return lines;
}
export const timeSummary = (tree) => {
const total = totalMinutes(tree);
return total > 0 ? `About ${total} minute${total === 1 ? '' : 's'}` : '';
};
export function outlineToText(tree) {
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}${label} ${pointText(node)}`).join('\n');
return flatten(tree).flatMap(({ node, depth, label }) => [
`${' '.repeat(depth)}${label} ${pointText(node)}`,
...detailLines(node).map((l) => `${' '.repeat(depth)} ${l}`),
]).join('\n');
}
export function outlineToMarkdown(tree) {
return flatten(tree).map(({ node, depth, label }) => `${' '.repeat(depth)}- **${label}** ${pointText(node)}`).join('\n');
return flatten(tree).flatMap(({ node, depth, label }) => [
`${' '.repeat(depth)}- **${label}** ${pointText(node)}`,
...detailLines(node).map((l) => `${' '.repeat(depth)} *${l}*`),
]).join('\n');
}
// ---- starting one ------------------------------------------------------------
@@ -162,3 +196,41 @@ export function seedFromChunks(project, tree = []) {
});
return [...tree, ...added];
}
// ---- one outline per chunk ---------------------------------------------------
// A study has one outline for the whole message (project.outline) and each chunk can have its own
// (chunk.outline). `scope` is STUDY or a chunk's id.
export const STUDY = 'study';
export function chunkOf(project, chunkId) {
for (const chapter of project?.chapters ?? []) for (const chunk of chapter.chunks ?? []) if (chunk.id === chunkId) return chunk;
return null;
}
export const outlineFor = (project, scope) => (scope === STUDY ? project?.outline ?? [] : chunkOf(project, scope)?.outline ?? []);
// A copy of the project with that scope's outline replaced.
export function withOutline(project, scope, tree) {
if (scope === STUDY) return { ...project, outline: tree };
return {
...project,
chapters: project.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === scope ? { ...c, outline: tree } : c)) })),
};
}
// One point per verse of the chunk, each carrying that verse's words, for verses not already in `tree`.
export function seedFromVerses(project, chunkId, tree = []) {
const have = new Set(flatten(tree).map((f) => f.node.scripture?.ref).filter(Boolean));
const added = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) {
if (chunk.id !== chunkId) continue;
for (const v of getChunkVerseEntries(project, chapterIndex, chunk)) {
const ref = `${chapter.book} ${v.chapter}:${v.number}`;
if (have.has(ref)) continue;
added.push(newNode('', { ref, scripture: { ref, text: v.text, translation: project.translation ?? 'BSB' } }));
}
}
});
return [...tree, ...added];
}
+58 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, test } from 'vitest';
import {
addAfter, addChild, addRoot, countNodes, flatten, getNode, indent, labelFor, moveNode, newNode, outdent,
insertAt, locate, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
STUDY, chunkOf, detailLines, insertAt, outlineFor, seedFromVerses, withOutline, locate, timeSummary, totalMinutes, outlineToMarkdown, outlineToText, removeNode, seedFromChunks, toRoman, updateNode, MAX_DEPTH,
} from './outline.js';
const n = (id, text = id, children = []) => ({ id, text, ref: '', chunkId: null, children });
@@ -120,3 +120,60 @@ describe('putting a deleted point back', () => {
expect(ids(insertAt([n('b')], 'missing', 0, n('x')))).toEqual(['x', 'b']);
});
});
describe('richer points', () => {
const rich = [{
...n('a', 'Creation'), ref: 'Gen 1', kind: 'illustration', minutes: 5, note: 'Tell the story.\n\nKeep it short.',
scripture: { ref: 'Genesis 1:1', text: 'In the beginning', translation: 'BSB' },
attachments: [{ id: 'x', type: 'xref', label: 'John 1:1' }, { id: 'w', type: 'word', label: 'bara (H1254)', detail: 'to create' }],
children: [{ ...n('b', 'Light'), minutes: 3 }],
}];
test('text and markdown show the kind, minutes, note, verse and attachments', () => {
expect(outlineToText(rich)).toBe([
'I. [Illustration] Creation (Gen 1) · 5 min',
' Tell the story.',
' Keep it short.',
' “In the beginning” — Genesis 1:1',
' See: John 1:1',
' Word: bara (H1254) — to create',
' A. Light · 3 min',
].join('\n'));
expect(outlineToMarkdown(rich)).toContain('- **I.** [Illustration] Creation (Gen 1) · 5 min\n *Tell the story.*');
});
test('totals the minutes, and says nothing when there are none', () => {
expect(totalMinutes(rich)).toBe(8);
expect(timeSummary(rich)).toBe('About 8 minutes');
expect(timeSummary([n('z')])).toBe('');
});
test('new points start plain, and older ones without the new fields still work', () => {
expect(newNode('x')).toMatchObject({ note: '', kind: '', minutes: null, scripture: null, attachments: [] });
expect(detailLines(n('old'))).toEqual([]);
});
});
describe('an outline for each chunk', () => {
const project = {
translation: 'BSB', outline: [n('whole')],
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses: [{ number: 1, text: 'One.' }, { number: 2, text: 'Two.' }, { number: 3, text: 'Three.' }], chunks: [
{ id: 'c1', startVerse: 1, endVerse: 2, observation: '', outline: [n('mine')] },
{ id: 'c2', startVerse: 3, endVerse: 3, observation: '' },
] }],
};
test('reads and replaces the outline of the whole study or of one chunk', () => {
expect(ids(outlineFor(project, STUDY))).toEqual(['whole']);
expect(ids(outlineFor(project, 'c1'))).toEqual(['mine']);
expect(outlineFor(project, 'c2')).toEqual([]);
const next = withOutline(project, 'c2', [n('new')]);
expect(ids(outlineFor(next, 'c2'))).toEqual(['new']);
expect(ids(outlineFor(next, 'c1'))).toEqual(['mine']);
expect(ids(outlineFor(next, STUDY))).toEqual(['whole']);
expect(outlineFor(project, 'c2')).toEqual([]); // the original is untouched
expect(ids(outlineFor(withOutline(project, STUDY, []), STUDY))).toEqual([]);
});
test('seeding from verses adds a point per verse with its words, and never twice', () => {
const t = seedFromVerses(project, 'c1', []);
expect(t.map((x) => [x.ref, x.scripture.text])).toEqual([['Titus 1:1', 'One.'], ['Titus 1:2', 'Two.']]);
expect(seedFromVerses(project, 'c1', t)).toHaveLength(2);
expect(seedFromVerses(project, 'c2', [])).toHaveLength(1);
});
});
+60
View File
@@ -0,0 +1,60 @@
// Taking one outline out of the app: print or save as PDF, share it, or download it.
import { buildOutlineHtml } from './bibleUtils.jsx';
import { outlineToText } from './outline.js';
const slug = (s) => String(s).replace(/[^\w]+/g, '-').replace(/^-|-$/g, '') || 'outline';
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
}
// What gets shared as text: a heading, then the outline.
export const outlineShareText = ({ title, subtitle = '', tree }) => `${title}${subtitle ? `\n${subtitle}` : ''}\n\n${outlineToText(tree)}`;
// Opens a print-ready page of just this outline. Returns false when the browser blocked the window.
export function printOutline({ title, subtitle, tree }) {
const win = window.open('', '_blank');
if (!win) return false;
win.document.write(buildOutlineHtml({ title, subtitle, tree }));
win.document.close();
win.onload = () => { win.focus(); win.print(); };
return true;
}
// Uses the phone/tablet/computer share sheet when there is one (Messages, Mail, AirDrop…), otherwise
// copies the text. -> 'shared' | 'copied' | 'cancelled' | 'failed'
export async function shareOutline(outline) {
const text = outlineShareText(outline);
if (navigator.share) {
try {
await navigator.share({ title: `${outline.title} — Outline`, text });
return 'shared';
} catch (err) {
if (err?.name === 'AbortError') return 'cancelled';
// fall through to copying
}
}
try {
await navigator.clipboard.writeText(text);
return 'copied';
} catch {
return 'failed';
}
}
export function downloadOutlineMarkdown({ title, subtitle, tree }) {
const text = `# ${title}\n\n${subtitle ? `*${subtitle}*\n\n` : ''}${tree.length ? outlineToText(tree) : ''}\n`;
downloadBlob(new Blob([text], { type: 'text/markdown;charset=utf-8' }), `${slug(title)}-outline.md`);
}
export async function downloadOutlineDocx(outline) {
const { buildOutlineDocxBlob } = await import('./docxExport.js'); // the Word library only loads when used
downloadBlob(await buildOutlineDocxBlob(outline), `${slug(outline.title)}-outline.docx`);
}
+56
View File
@@ -0,0 +1,56 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { outlineShareText, printOutline, shareOutline } from './outlineExport.js';
import { buildOutlineHtml } from './bibleUtils.jsx';
const outline = {
title: 'Titus 1 — Elders', subtitle: 'Titus 1:5–9',
tree: [{ id: 'a', text: 'Appoint elders', ref: 'Titus 1:5', kind: 'application', minutes: 4, note: 'Why it matters', children: [{ id: 'b', text: 'Blameless', ref: '', children: [] }] }],
};
beforeEach(() => { vi.stubGlobal('navigator', { ...navigator, share: undefined, clipboard: { writeText: vi.fn(() => Promise.resolve()) } }); });
afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); });
test('the share text has the title, passage and the outline', () => {
const text = outlineShareText(outline);
expect(text.startsWith('Titus 1 — Elders\nTitus 1:5–9\n\nI. [Application] Appoint elders (Titus 1:5) · 4 min')).toBe(true);
expect(text).toContain('A. Blameless');
});
test('uses the system share sheet when there is one', async () => {
const share = vi.fn(() => Promise.resolve());
vi.stubGlobal('navigator', { share, clipboard: { writeText: vi.fn() } });
expect(await shareOutline(outline)).toBe('shared');
expect(share).toHaveBeenCalledWith(expect.objectContaining({ title: 'Titus 1 — Elders — Outline' }));
});
test('closing the share sheet is not an error, and a broken one falls back to copying', async () => {
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(Object.assign(new Error('x'), { name: 'AbortError' }))), clipboard: { writeText: vi.fn() } });
expect(await shareOutline(outline)).toBe('cancelled');
const writeText = vi.fn(() => Promise.resolve());
vi.stubGlobal('navigator', { share: vi.fn(() => Promise.reject(new Error('nope'))), clipboard: { writeText } });
expect(await shareOutline(outline)).toBe('copied');
expect(writeText).toHaveBeenCalled();
});
test('without a share sheet it copies, and says so when even that fails', async () => {
expect(await shareOutline(outline)).toBe('copied');
vi.stubGlobal('navigator', { clipboard: { writeText: vi.fn(() => Promise.reject(new Error('denied'))) } });
expect(await shareOutline(outline)).toBe('failed');
});
test('print writes a page of just the outline, and reports a blocked window', () => {
const doc = { write: vi.fn(), close: vi.fn() };
vi.spyOn(window, 'open').mockReturnValue({ document: doc, focus: vi.fn(), print: vi.fn() });
expect(printOutline(outline)).toBe(true);
const html = doc.write.mock.calls[0][0];
expect(html).toContain('Appoint elders');
expect(html).toContain('About 4 minutes');
window.open.mockReturnValue(null);
expect(printOutline(outline)).toBe(false);
});
test('the printable page escapes text and shows notes', () => {
const html = buildOutlineHtml({ title: 'A <b>', tree: [{ id: 'x', text: '<i>hi</i>', ref: '', note: 'line', children: [] }] });
expect(html).toContain('A &lt;b&gt;');
expect(html).toContain('&lt;i&gt;hi&lt;/i&gt;');
expect(html).toContain('class="o-note"');
});
+6
View File
@@ -26,3 +26,9 @@ export function labelFor(depth, index) {
default: return `(${letter(n, false)})`;
}
}
export const KINDS = [
['', 'Point'], ['illustration', 'Illustration'], ['application', 'Application'],
['quote', 'Quote'], ['question', 'Question'], ['transition', 'Transition'],
];
export const kindLabel = (kind) => KINDS.find(([id]) => id === kind)?.[1] ?? '';
+49
View File
@@ -0,0 +1,49 @@
// "Titus 1:5–9" -> the words of those verses, for putting scripture inside an outline point.
import { matchBooks } from './passageRef.js';
import { CHAPTER_COUNTS, bookOptions } from './bibleUtils.jsx';
import { fetchChapter } from './bibleText.js';
export const MAX_VERSES = 40; // a whole chapter's worth is plenty to paste into a point
// -> { book, bookName, chapter, start, end } (start/end null = the whole chapter) or null. Accepts
// "Titus 1:5-9", "titus 1:5", "John 3" and a dash or en dash.
export function parsePassageRange(text) {
const m = String(text ?? '').trim().replace(/\./g, ' ').match(/^((?:[1-3]\s*)?[a-z][a-z ]*?)\s*(\d{1,3})(?:\s*[:v ]\s*(\d{1,3})(?:\s*[-–—]\s*(\d{1,3}))?)?$/i);
if (!m) return null;
const [, name, chapterText, startText, endText] = m;
const book = matchBooks(name)[0];
const chapter = Number(chapterText);
if (!book || chapter < 1 || chapter > (CHAPTER_COUNTS[book.abbrev] ?? 1)) return null;
const start = startText ? Number(startText) : null;
const end = endText ? Number(endText) : start;
if (start != null && end < start) return null;
return { book: book.abbrev, bookName: bookOptions.find((b) => b.abbrev === book.abbrev)?.name ?? book.name, chapter, start, end };
}
export const formatRange = (r) => `${r.bookName} ${r.chapter}${r.start == null ? '' : r.end === r.start ? `:${r.start}` : `:${r.start}–${r.end}`}`;
// -> { ref, text, translation }. Throws Error(message) when the passage can't be read or found.
export async function fetchPassageText(text, translation = 'BSB') {
const range = parsePassageRange(text);
if (!range) throw new Error('Type a passage like “Titus 1:5–9”.');
const { verses } = await fetchChapter(translation, range.book, range.chapter);
const picked = range.start == null ? verses : verses.filter((v) => v.number >= range.start && v.number <= range.end);
if (picked.length === 0) throw new Error('Those verses weren’t found in that chapter.');
if (picked.length > MAX_VERSES) throw new Error(`That's ${picked.length} verses — pick ${MAX_VERSES} or fewer.`);
return { ref: formatRange(range), text: picked.map((v) => v.text).join(' '), translation };
}
// What a chunk offers to attach to a point: its cross-references and the words studied in it.
// -> [{ key, type: 'xref' | 'word', label, detail? }]
export function attachableFromChunk(chunk) {
if (!chunk) return [];
const refs = (chunk.crossReferences ?? []).map((r) => ({ key: `x:${r}`, type: 'xref', label: r }));
const words = (chunk.greekWords ?? [])
.filter((w) => w.lexeme || w.strongNumber || w.query)
.map((w) => ({
key: `w:${w.strongNumber || w.lexeme || w.query}`, type: 'word',
label: [w.lexeme, w.strongNumber && `(${w.strongNumber})`].filter(Boolean).join(' ') || w.query,
detail: w.shortDefinition && !/^(No definition|Lookup failed)/.test(w.shortDefinition) ? w.shortDefinition : undefined,
}));
return [...refs, ...words];
}
+45
View File
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, expect, test, vi } from 'vitest';
import { attachableFromChunk, fetchPassageText, parsePassageRange } from './passageText.js';
const chapter = { chapter: { content: [1, 2, 3, 4, 5].map((n) => ({ type: 'verse', number: n, content: [`Verse ${n} text.`] })) } };
beforeEach(() => vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve(chapter) }))));
afterEach(() => vi.unstubAllGlobals());
test('reads a verse, a range, or a whole chapter', () => {
expect(parsePassageRange('Titus 1:5')).toMatchObject({ book: 'TIT', chapter: 1, start: 5, end: 5 });
expect(parsePassageRange('titus 1:5-9')).toMatchObject({ start: 5, end: 9 });
expect(parsePassageRange('1 Cor 13:4–7')).toMatchObject({ book: '1CO', chapter: 13, start: 4, end: 7 });
expect(parsePassageRange('John 3')).toMatchObject({ chapter: 3, start: null });
});
test('rejects things that are not passages, or backwards ranges', () => {
expect(parsePassageRange('hello')).toBeNull();
expect(parsePassageRange('Titus 9:1')).toBeNull();
expect(parsePassageRange('Titus 1:9-5')).toBeNull();
});
test('fetches just the verses asked for, and names the passage', async () => {
const got = await fetchPassageText('Titus 1:2-3');
expect(got).toEqual({ ref: 'Titus 1:2–3', text: 'Verse 2 text. Verse 3 text.', translation: 'BSB' });
});
test('says so when the verses are not there, or the passage is not understood', async () => {
await expect(fetchPassageText('Titus 1:50')).rejects.toThrow(/weren’t found/);
await expect(fetchPassageText('nonsense')).rejects.toThrow(/Type a passage/);
});
test('offers a chunk\'s cross-references and studied words to attach', () => {
const chunk = {
crossReferences: ['John 1:1'],
greekWords: [
{ strongNumber: 'G1401', lexeme: 'δοῦλος', shortDefinition: 'a slave' },
{ query: 'xyz', shortDefinition: 'No definition found.' },
],
};
expect(attachableFromChunk(chunk)).toEqual([
{ key: 'x:John 1:1', type: 'xref', label: 'John 1:1' },
{ key: 'w:G1401', type: 'word', label: 'δοῦλος (G1401)', detail: 'a slave' },
{ key: 'w:xyz', type: 'word', label: 'xyz', detail: undefined },
]);
expect(attachableFromChunk(null)).toEqual([]);
});
+90
View File
@@ -0,0 +1,90 @@
// How two wordings of the same verse differ: which words are not shared, and how alike they are overall.
// Comparison ignores case and punctuation ("light,”" matches "Light"), and works on whole words.
// Words with their position in the original text: [{ word: 'light', start, end }]
export function tokenize(text) {
const out = [];
const re = /[\p{L}\p{N}][\p{L}\p{N}’'-]*/gu;
let m;
while ((m = re.exec(text ?? '')) !== null) {
out.push({ word: m[0].toLowerCase().replace(/[’']s?$/u, '').replace(/[’'-]/gu, ''), start: m.index, end: m.index + m[0].length });
}
return out;
}
// Longest common subsequence of two word lists -> the indexes kept in each (classic DP; verses are short).
function commonWords(a, b) {
const n = a.length;
const m = b.length;
const dp = Array.from({ length: n + 1 }, () => new Uint16Array(m + 1));
for (let i = n - 1; i >= 0; i -= 1) {
for (let j = m - 1; j >= 0; j -= 1) {
dp[i][j] = a[i].word === b[j].word ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
}
}
const inA = new Set();
const inB = new Set();
let i = 0;
let j = 0;
while (i < n && j < m) {
if (a[i].word === b[j].word) { inA.add(i); inB.add(j); i += 1; j += 1; }
else if (dp[i + 1][j] >= dp[i][j + 1]) i += 1;
else j += 1;
}
return { inA, inB };
}
// Joins neighbouring changed words into one range, so a changed phrase is one highlight, not many.
function ranges(tokens, kept, text) {
const out = [];
tokens.forEach((t, i) => {
if (kept.has(i)) return;
const last = out[out.length - 1];
if (last && /^\s*$/.test(text.slice(last.end, t.start))) last.end = t.end;
else out.push({ start: t.start, end: t.end });
});
return out;
}
// -> { a: [{ start, end }], b: [{ start, end }], similarity: 0..1, same: bool }
// `a` and `b` are the character ranges (in each text) of words the other does not have.
export function diffVerses(textA, textB) {
const a = tokenize(textA);
const b = tokenize(textB);
if (a.length === 0 && b.length === 0) return { a: [], b: [], similarity: 1, same: true };
const { inA, inB } = commonWords(a, b);
const similarity = (2 * inA.size) / (a.length + b.length);
return { a: ranges(a, inA, textA), b: ranges(b, inB, textB), similarity, same: inA.size === a.length && inB.size === b.length };
}
// A whole chapter: pairs of [mainVerse, otherVerse] by verse number ->
// { similarity, differing, total, verses: [{ number, similarity, ...ranges }] sorted most different first }
export function diffChapter(mainVerses, otherVerses) {
const other = new Map(otherVerses.map((v) => [v.number, v]));
let shared = 0;
let words = 0;
const verses = [];
for (const v of mainVerses) {
const o = other.get(v.number);
if (!o) continue;
const d = diffVerses(v.text, o.text);
const na = tokenize(v.text).length;
const nb = tokenize(o.text).length;
shared += (d.similarity * (na + nb)) / 2;
words += (na + nb) / 2;
verses.push({ number: v.number, ...d });
}
return {
similarity: words === 0 ? 1 : shared / words,
total: verses.length,
differing: verses.filter((v) => !v.same).length,
verses,
mostDifferent: [...verses].filter((v) => !v.same).sort((x, y) => x.similarity - y.similarity).slice(0, 3),
};
}
// "12% different" for a similarity of 0.88; "identical words" at 1.
export const differenceLabel = (similarity) => {
const pct = Math.round((1 - similarity) * 100);
return pct === 0 && similarity < 1 ? '<1% different' : pct === 0 ? 'Same words' : `${pct}% different`;
};
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, test } from 'vitest';
import { diffChapter, diffVerses, differenceLabel, tokenize } from './textDiff.js';
const slice = (text, ranges) => ranges.map((r) => text.slice(r.start, r.end));
describe('tokenize', () => {
test('words only, lowercased, with their place in the text', () => {
const t = tokenize('God said, “Let there be light,”');
expect(t.map((x) => x.word)).toEqual(['god', 'said', 'let', 'there', 'be', 'light']);
expect(t[0]).toMatchObject({ start: 0, end: 3 });
});
test('possessives and apostrophes do not split or change words', () => {
expect(tokenize('God’s law').map((x) => x.word)).toEqual(['god', 'law']);
expect(tokenize("God's law").map((x) => x.word)).toEqual(['god', 'law']);
});
});
describe('diffVerses', () => {
test('identical wording, whatever the punctuation or case, is the same', () => {
const d = diffVerses('In the beginning God created the heavens and the earth.', 'in the beginning, God created the heavens and the earth');
expect(d).toMatchObject({ same: true, similarity: 1, a: [], b: [] });
});
test('marks the words each side has that the other does not', () => {
const a = 'The earth was formless and void';
const b = 'The earth was without shape and empty';
const d = diffVerses(a, b);
expect(slice(a, d.a)).toEqual(['formless', 'void']);
expect(slice(b, d.b)).toEqual(['without shape', 'empty']); // neighbouring words become one range
expect(d.same).toBe(false);
expect(d.similarity).toBeCloseTo((2 * 4) / 13, 5);
});
test('a pure addition shows only on the side that has it', () => {
const d = diffVerses('God saw the light', 'God saw that the light was good');
expect(d.a).toEqual([]);
expect(d.b.length).toBeGreaterThan(0);
});
test('totally different text is not alike at all; two empty ones are the same', () => {
expect(diffVerses('alpha beta', 'gamma delta').similarity).toBe(0);
expect(diffVerses('', '').same).toBe(true);
});
});
describe('diffChapter', () => {
const main = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was formless.' }, { number: 3, text: 'Let there be light.' }];
const other = [{ number: 1, text: 'In the beginning God created.' }, { number: 2, text: 'The earth was shapeless and void.' }, { number: 4, text: 'Extra verse only here.' }];
test('scores the chapter over verses both have, and lists the most different first', () => {
const c = diffChapter(main, other);
expect(c.total).toBe(2); // verse 3 and 4 are not in both
expect(c.differing).toBe(1);
expect(c.mostDifferent.map((v) => v.number)).toEqual([2]);
expect(c.similarity).toBeGreaterThan(0.7);
expect(c.similarity).toBeLessThan(1);
});
test('an identical chapter is fully alike', () => {
expect(diffChapter(main, main)).toMatchObject({ similarity: 1, differing: 0, mostDifferent: [] });
});
});
describe('differenceLabel', () => {
test('says it in percent, and never "0% different" for something that does differ', () => {
expect(differenceLabel(0.88)).toBe('12% different');
expect(differenceLabel(1)).toBe('Same words');
expect(differenceLabel(0.9999)).toBe('<1% different');
});
});