Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s

Studies now move through a stage bar in the reader's themes. Outline splits
chunks by clicking between verses (join merges notes) and suggests chunks from
section headings. Study is three panes: outline, passage with an Observe /
Interpret / Apply accordion, and commentary / word study / cross-refs / people
& places for the chunk; original words are added by clicking them in the
interlinear. Script drafts an outline from notes; Export holds files and the
share link. Home, Import and the Pronunciation guide are restyled. Study
projects are BSB only.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-09-29 10:13:46 -04:00
1 parent b1bc1c8ea4
commit 31abee0695
31 files changed
+1911 -2460

No files matched your search

+154 -12
View File
@@ -4,6 +4,77 @@
import { useEffect, useState } from 'react';
import { bookOptions, CHAPTER_COUNTS, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
// Every chunk starts blank; all note fields exist from the start.
export function blankChunk(start, end, spilloverEndVerse = null) {
return {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
generalNotes: '',
episodeNumber: '',
episodeTitle: '',
finalScript: '',
tags: [],
};
}
const joinText = (a, b) => [a, b].filter((s) => (s ?? '').trim()).join('\n\n');
// Merges chunk `b` (the one after `a`) into `a`, keeping a's id and combining notes,
// words, cross-references and tags so joining never loses anything.
export function mergeChunks(a, b) {
const uniq = (list) => [...new Set(list)];
return {
...a,
endVerse: b.endVerse,
spilloverEndVerse: b.spilloverEndVerse ?? null,
observation: joinText(a.observation, b.observation),
interpretation: joinText(a.interpretation, b.interpretation),
application: joinText(a.application, b.application),
generalNotes: joinText(a.generalNotes, b.generalNotes),
finalScript: joinText(a.finalScript, b.finalScript),
crossReferences: uniq([...(a.crossReferences ?? []), ...(b.crossReferences ?? [])]),
greekWords: [...(a.greekWords ?? []), ...(b.greekWords ?? [])],
tags: uniq([...(a.tags ?? []), ...(b.tags ?? [])]),
episodeNumber: a.episodeNumber || b.episodeNumber || '',
episodeTitle: a.episodeTitle || b.episodeTitle || '',
};
}
// Verse numbers where a section heading starts, from a HelloAO chapter payload.
export function headingStarts(chapterData) {
const starts = [];
let pending = false;
for (const item of chapterData?.chapter?.content ?? []) {
if (item.type === 'heading') pending = true;
else if (item.type === 'verse') {
if (pending) starts.push(item.number);
pending = false;
}
}
return starts;
}
// Uncovered runs of verses inside [from, to], given the chunks already there.
export function uncoveredRuns(chunks, from, to) {
const covered = new Set();
chunks.forEach((c) => { for (let v = c.startVerse; v <= c.endVerse; v += 1) covered.add(v); });
const runs = [];
let start = null;
for (let v = from; v <= to + 1; v += 1) {
const free = v <= to && !covered.has(v);
if (free && start === null) start = v;
if (!free && start !== null) { runs.push([start, v - 1]); start = null; }
}
return runs;
}
// A new project starts on Titus 1. When adding to an open project, start on
// the chapter after its last one (same book and translation).
function initialSetupForm(project) {
@@ -228,18 +299,7 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
skippedCount += 1;
return;
}
const newChunk = {
id: makeId(),
startVerse: start,
endVerse: end,
spilloverEndVerse,
observation: '',
interpretation: '',
application: '',
crossReferences: [],
greekWords: [],
tags: [],
};
const newChunk = blankChunk(start, end, spilloverEndVerse);
existing.add(key);
newChunks.push(newChunk);
addedCount += 1;
@@ -491,6 +551,84 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
});
};
// Splits the chunk containing verse `n` into [start..n] and [n+1..end] (the first keeps
// its notes). If `n` isn't in any chunk yet, makes one from the last covered verse to `n`.
const splitAfterVerse = (n) => {
if (!activeChapter) return;
updateProject((current) => {
let selected = current.selectedChunkId;
const chapters = current.chapters.map((ch, idx) => {
if (idx !== activeChapterIndex) return ch;
const chunks = [...ch.chunks];
const i = chunks.findIndex((c) => c.startVerse <= n && n < c.endVerse && !c.spilloverEndVerse);
if (i >= 0) {
const c = chunks[i];
chunks[i] = { ...c, endVerse: n, spilloverEndVerse: null };
chunks.splice(i + 1, 0, blankChunk(n + 1, c.endVerse, c.spilloverEndVerse ?? null));
return { ...ch, chunks };
}
if (chunks.some((c) => c.startVerse <= n && n <= c.endVerse)) return ch; // already a boundary
const start = Math.max(0, ...chunks.filter((c) => c.endVerse < n).map((c) => c.endVerse)) + 1;
const created = blankChunk(start, n);
selected = created.id;
const at = chunks.findIndex((c) => c.startVerse > n);
chunks.splice(at < 0 ? chunks.length : at, 0, created);
return { ...ch, chunks };
});
return { ...current, chapters, selectedChunkId: selected };
});
};
// Joins a chunk with the one right after it (adjacent verses), combining their notes.
const joinWithNext = (chunkId) => {
updateProject((current) => {
let selected = current.selectedChunkId;
const chapters = current.chapters.map((ch) => {
const i = ch.chunks.findIndex((c) => c.id === chunkId);
if (i < 0 || i >= ch.chunks.length - 1) return ch;
const a = ch.chunks[i];
const b = ch.chunks[i + 1];
if (a.endVerse + 1 !== b.startVerse) return ch;
if (selected === b.id) selected = a.id;
const chunks = [...ch.chunks];
chunks.splice(i, 2, mergeChunks(a, b));
return { ...ch, chunks };
});
return { ...current, chapters, selectedChunkId: selected };
});
};
// Fills verses that aren't in a chunk yet: one chunk per section heading (or every 8
// verses when the text has no headings). Chunks you already made are left alone.
const [suggesting, setSuggesting] = useState(false);
const suggestChunks = async () => {
if (!activeChapter) return;
const notify = (message, ms = 2400) => { setStatusMessage(message); window.setTimeout(() => setStatusMessage(''), ms); };
const last = activeChapter.verses.at(-1)?.number ?? 0;
setSuggesting(true);
try {
let starts = [];
try {
const res = await fetch(`https://bible.helloao.org/api/${project?.translation ?? 'BSB'}/${activeChapter.bookAbbrev}/${activeChapter.chapter}.json`);
if (res.ok) starts = headingStarts(await res.json());
} catch { /* headings are a nicety — fall back to even chunks */ }
const usedHeadings = starts.length > 0;
const first = activeChapter.verses[0]?.number ?? 1;
if (!usedHeadings) for (let v = first; v <= last; v += 8) starts.push(v);
const bounds = [...new Set([first, ...starts])].sort((a, b) => a - b);
const ranges = [];
bounds.forEach((from, k) => {
const to = (bounds[k + 1] ?? last + 1) - 1;
uncoveredRuns(activeChapter.chunks, from, to).forEach(([s, e]) => ranges.push({ start: s, end: e }));
});
if (ranges.length === 0) { notify('Every verse is already in a chunk.'); return; }
const { addedCount } = addChunkRanges(ranges);
notify(`Added ${addedCount} chunk${addedCount === 1 ? '' : 's'} ${usedHeadings ? 'from section headings' : 'of about 8 verses'}.`);
} finally {
setSuggesting(false);
}
};
return {
setup,
titleEdited, setTitleEdited,
@@ -517,5 +655,9 @@ export function useChunkBuilder({ project, setProject, updateProject, allChunks,
addClickSpanChunk,
moveChunk,
deleteChunk,
splitAfterVerse,
joinWithNext,
suggestChunks,
suggesting,
};
}