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
+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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user