Redesign the study side: Outline, Study, Script and Export stages
Build and Push Docker Image / build-and-push (push) Successful in 4m15s
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:
1 parent
b1bc1c8ea4
commit
31abee0695
31 files changed
+1911
-2460
No files matched your search
@@ -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
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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]]);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user