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

-25
View File
@@ -1,25 +0,0 @@
// The list of Bible translations offered by the HelloAO API, fetched once per
// visit and shared by every page that needs it (Setup, Import).
import { useEffect, useState } from 'react';
let cached = null;
let pending = null;
export function useAvailableTranslations() {
const [translations, setTranslations] = useState(cached ?? ['BSB']);
useEffect(() => {
if (cached) return;
pending ??= fetch('https://bible.helloao.org/api/available_translations.json')
.then((res) => res.json())
.then((data) => {
if (Array.isArray(data) && data.length > 0) cached = data;
return cached;
})
.catch(() => null)
.finally(() => { pending = null; });
let active = true;
pending.then((list) => { if (active && list) setTranslations(list); });
return () => { active = false; };
}, []);
return translations;
}
+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,
};
}
+109
View File
@@ -0,0 +1,109 @@
import { renderHook, act } from '@testing-library/react';
import { useState } from 'react';
import { vi, test, expect, beforeEach } from 'vitest';
import { blankChunk, headingStarts, mergeChunks, uncoveredRuns, useChunkBuilder } from './useChunkBuilder.js';
const c = (s, e) => ({ startVerse: s, endVerse: e });
test('uncoveredRuns lists the verse runs not yet in any chunk', () => {
expect(uncoveredRuns([c(1, 3), c(6, 7)], 1, 10)).toEqual([[4, 5], [8, 10]]);
expect(uncoveredRuns([], 5, 8)).toEqual([[5, 8]]);
expect(uncoveredRuns([c(1, 10)], 1, 10)).toEqual([]);
});
test('headingStarts finds the verse each section heading precedes', () => {
const data = { chapter: { content: [
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'verse', number: 2 },
{ type: 'heading' }, { type: 'line_break' }, { type: 'verse', number: 22 },
] } };
expect(headingStarts(data)).toEqual([1, 22]);
});
test('mergeChunks keeps the first id and combines notes, words, refs and tags', () => {
const a = { ...blankChunk(1, 3), observation: 'A', tags: ['x'], crossReferences: ['John 1:1'], greekWords: [{ id: 'w1' }] };
const b = { ...blankChunk(4, 6), observation: 'B', tags: ['x', 'y'], crossReferences: ['John 1:1', 'Rom 3:23'], greekWords: [{ id: 'w2' }], episodeTitle: 'Ep' };
const m = mergeChunks(a, b);
expect([m.id, m.startVerse, m.endVerse]).toEqual([a.id, 1, 6]);
expect(m.observation).toBe('A\n\nB');
expect(m.tags).toEqual(['x', 'y']);
expect(m.crossReferences).toEqual(['John 1:1', 'Rom 3:23']);
expect(m.greekWords.map((w) => w.id)).toEqual(['w1', 'w2']);
expect(m.episodeTitle).toBe('Ep');
});
// --- hook operations against a small in-memory project -----------------------
const verses = Array.from({ length: 12 }, (_, i) => ({ number: i + 1, text: `verse ${i + 1}` }));
const makeProject = (chunks = []) => ({
id: 'p', title: 'T', translation: 'BSB', selectedChunkId: null,
chapters: [{ book: 'Titus', bookAbbrev: 'TIT', chapter: '1', verses, chunks }],
});
function setup(initialChunks) {
return renderHook(() => {
const [project, setProject] = useState(makeProject(initialChunks));
const updateProject = (fn) => setProject((cur) => fn(cur));
const allChunks = project.chapters.flatMap((ch) => ch.chunks);
const builder = useChunkBuilder({ project, setProject, updateProject, allChunks, setCurrentPage: () => {} });
return { project, builder };
});
}
const ranges = (h) => h.result.current.project.chapters[0].chunks.map((k) => [k.startVerse, k.endVerse]);
beforeEach(() => { vi.restoreAllMocks(); });
test('splitting between uncovered verses builds chunks one after another', () => {
const h = setup();
act(() => h.result.current.builder.splitAfterVerse(4));
act(() => h.result.current.builder.splitAfterVerse(9));
expect(ranges(h)).toEqual([[1, 4], [5, 9]]);
});
test('splitting inside a chunk keeps the notes on the first part', () => {
const original = { ...blankChunk(1, 8), observation: 'keep me' };
const h = setup([original]);
act(() => h.result.current.builder.splitAfterVerse(3));
const [first, second] = h.result.current.project.chapters[0].chunks;
expect([first.id, first.startVerse, first.endVerse, first.observation]).toEqual([original.id, 1, 3, 'keep me']);
expect([second.startVerse, second.endVerse, second.observation]).toEqual([4, 8, '']);
});
test('splitting where a boundary already exists changes nothing', () => {
const h = setup([blankChunk(1, 4), blankChunk(5, 8)]);
act(() => h.result.current.builder.splitAfterVerse(4));
expect(ranges(h)).toEqual([[1, 4], [5, 8]]);
});
test('joining merges a chunk with the next one and re-selects the survivor', () => {
const a = { ...blankChunk(1, 4), observation: 'one' };
const b = { ...blankChunk(5, 8), observation: 'two' };
const h = setup([a, b]);
act(() => h.result.current.builder.joinWithNext(a.id));
const [only] = h.result.current.project.chapters[0].chunks;
expect([only.id, only.startVerse, only.endVerse, only.observation]).toEqual([a.id, 1, 8, 'one\n\ntwo']);
});
test('joining is refused when the chunks are not adjacent', () => {
const a = blankChunk(1, 3);
const h = setup([a, blankChunk(6, 8)]);
act(() => h.result.current.builder.joinWithNext(a.id));
expect(ranges(h)).toEqual([[1, 3], [6, 8]]);
});
test('suggest from headings fills only the uncovered verses, one chunk per section', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({
ok: true,
json: () => Promise.resolve({ chapter: { content: [
{ type: 'heading' }, { type: 'verse', number: 1 }, { type: 'heading' }, { type: 'verse', number: 5 }, { type: 'heading' }, { type: 'verse', number: 9 },
] } }),
})));
const h = setup([blankChunk(5, 6)]); // verses 5-6 already done
await act(async () => { await h.result.current.builder.suggestChunks(); });
expect(ranges(h).sort((x, y) => x[0] - y[0])).toEqual([[1, 4], [5, 6], [7, 8], [9, 12]]);
});
test('suggest falls back to even chunks when the chapter has no headings', async () => {
vi.stubGlobal('fetch', vi.fn(() => Promise.resolve({ ok: true, json: () => Promise.resolve({ chapter: { content: [{ type: 'verse', number: 1 }] } }) })));
const h = setup();
await act(async () => { await h.result.current.builder.suggestChunks(); });
expect(ranges(h)).toEqual([[1, 8], [9, 12]]);
});
+38
View File
@@ -46,6 +46,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
}));
};
// Adds a blank word entry and returns its id (so the UI can open it for editing).
const addGreekWord = (chunkId) => {
const newWord = {
id: makeId(),
@@ -70,6 +71,42 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
),
})),
}));
return newWord.id;
};
// Adds a fully filled-in entry for an original word picked from the interlinear
// (Strong's number, lexeme, transliteration and gloss come with the click).
const addWordFromInterlinear = async (chunkId, w) => {
const chunk = allChunks.find((c) => c.id === chunkId);
const key = String(w?.s ?? '').toUpperCase();
if (!chunk || !key) return null;
if (chunk.greekWords.some((x) => (x.strongNumber || '').toUpperCase() === key)) {
notify('That word is already in this chunk.', 1800);
return chunk.greekWords.find((x) => (x.strongNumber || '').toUpperCase() === key).id;
}
let entry = null;
try { entry = (key.startsWith('H') ? await loadHebrewDict() : await loadGreekDict())[key] ?? null; } catch { /* dictionary unavailable — keep what the click gave us */ }
const newWord = {
id: makeId(),
query: key,
strongNumber: key,
englishGloss: w.g || '',
lexeme: w.o || entry?.lemma || '',
transliteration: w.t || entry?.translit || entry?.xlit || '',
partOfSpeech: '',
shortDefinition: entry?.kjv_def || w.g || '',
definitionHtml: entry ? buildGreekDefinitionHtml(key, entry) : '',
loading: false,
};
updateProject((current) => ({
...current,
chapters: current.chapters.map((ch) => ({
...ch,
chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, greekWords: [...c.greekWords, newWord] } : c)),
})),
}));
notify(`Added ${newWord.lexeme || key} to word study.`, 1800);
return newWord.id;
};
const removeGreekWord = (chunkId, wordId) => {
@@ -332,6 +369,7 @@ export function useWordStudy({ project, allChunks, updateProject, notify }) {
suggestSelection, setSuggestSelection,
updateChunkWord,
addGreekWord,
addWordFromInterlinear,
removeGreekWord,
lookupWord,
suggestGreekWordsForChunk,