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>
This commit is contained in:
1 parent
d273c8cb3e
commit
820886e3ab
12 files changed
+741
-103
No files matched your search
@@ -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(', ');
|
||||
|
||||
+207
-77
@@ -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>
|
||||
|
||||
@@ -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
@@ -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));
|
||||
|
||||
@@ -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
@@ -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];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
}
|
||||
@@ -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 <b>');
|
||||
expect(html).toContain('<i>hi</i>');
|
||||
expect(html).toContain('class="o-note"');
|
||||
});
|
||||
@@ -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] ?? '';
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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([]);
|
||||
});
|
||||
Reference in new issue
Block a user