From bf83dc7cc4ce239bb17eae74147d398a43a2de3c Mon Sep 17 00:00:00 2001 From: nmemmert Date: Tue, 30 Jun 2026 16:38:42 -0400 Subject: [PATCH] Add docx episode-list import to auto-create chunks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload a Word doc with an Ep./Title/Passage table (or "Ep. N — Title" intro paragraphs) and it parses episodes, fetches the needed chapters, and creates chunks with episode metadata and verse ranges pre-filled, including cross-chapter spillover and zero-length markers for passage-less rows. Co-Authored-By: Claude Sonnet 4.6 --- src/App.jsx | 327 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 327 insertions(+) diff --git a/src/App.jsx b/src/App.jsx index e2fa58e..47887fd 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -808,6 +808,53 @@ function parseCrossRefString(ref) { }; } +// Parses a "Passage" cell like "1:1–2", "9:32–10:23", "9:1–19a", or "—" (no specific passage) +function parseEpisodePassage(raw) { + const str = (raw ?? '').trim().replace(/(\d)[a-z]\b/g, '$1'); // strip "19a"/"19b" verse-letter suffixes + if (!str || /^[-–—]+$/.test(str)) return null; + const match = str.match(/^(\d+):(\d+)\s*[-–—]\s*(?:(\d+):)?(\d+)$/) + ?? str.match(/^(\d+):(\d+)$/); + if (!match) return 'invalid'; + const [, ch1, v1, ch2, v2raw] = match; + const endChapter = ch2 ? Number(ch2) : Number(ch1); + const endVerse = v2raw !== undefined ? Number(v2raw) : Number(v1); + return { + startChapter: Number(ch1), + startVerse: Number(v1), + endChapter, + endVerse, + }; +} + +// Extracts an "Ep. # / Title / Passage" episode list from a docx, including both +// table rows and standalone "Ep. N — Title" paragraphs (e.g. part intros). +async function parseEpisodeListDocx(file) { + const arrayBuffer = await file.arrayBuffer(); + const { value: html } = await mammoth.convertToHtml({ arrayBuffer }); + const parsedDoc = new DOMParser().parseFromString(html, 'text/html'); + + const episodes = new Map(); // episodeNumber -> { episodeNumber, title, passage } + + parsedDoc.querySelectorAll('table tr').forEach((row) => { + const cells = Array.from(row.querySelectorAll('td,th')).map((c) => c.textContent.trim()); + if (cells.length < 3) return; + const [epRaw, title, passage] = cells; + if (!/^\d+$/.test(epRaw)) return; // header row + episodes.set(epRaw, { episodeNumber: epRaw, title, passage }); + }); + + parsedDoc.querySelectorAll('p').forEach((p) => { + const match = p.textContent.trim().match(/^Ep\.\s*(\d+)\s*[-–—]\s*(.+)$/); + if (!match) return; + const [, epRaw, title] = match; + if (!episodes.has(epRaw)) { + episodes.set(epRaw, { episodeNumber: epRaw, title: title.trim(), passage: '—' }); + } + }); + + return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber)); +} + // A cross-reference chip that fetches and shows the referenced verse text on hover function CrossRefChip({ label, onRemove, loadVerseText }) { const [hovered, setHovered] = useState(false); @@ -895,6 +942,13 @@ const App = () => { const [homeTagFilter, setHomeTagFilter] = useState(''); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); + const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev); + const [importTranslation, setImportTranslation] = useState('BSB'); + const [importTitle, setImportTitle] = useState(''); + const [importFile, setImportFile] = useState(null); + const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters + const [importBusy, setImportBusy] = useState(false); + const [importError, setImportError] = useState(''); const [readerBookAbbrev, setReaderBookAbbrev] = useState(bookOptions[0].abbrev); const [readerChapter, setReaderChapter] = useState(1); const [readerVerses, setReaderVerses] = useState([]); @@ -2763,6 +2817,153 @@ const App = () => { setCurrentPage('setup'); }; + const openImportProject = () => { + setProject(null); + setImportBookAbbrev(bookOptions[0].abbrev); + setImportTranslation('BSB'); + setImportTitle(''); + setImportFile(null); + setImportPreview(null); + setImportError(''); + setCurrentPage('import'); + }; + + const handleImportFileChange = async (file) => { + setImportFile(file); + setImportPreview(null); + setImportError(''); + if (!file) return; + setImportBusy(true); + try { + const episodes = await parseEpisodeListDocx(file); + if (episodes.length === 0) { + throw new Error('No "Ep. / Title / Passage" table found in that document.'); + } + const specs = episodes.map((ep) => ({ ...ep, parsed: parseEpisodePassage(ep.passage) })); + setImportPreview(specs); + } catch (error) { + setImportError(error.message || 'Failed to read that document.'); + } finally { + setImportBusy(false); + } + }; + + const runEpisodeImport = async () => { + if (!importPreview || importPreview.length === 0) { + setImportError('Choose a .docx file first.'); + return; + } + setImportBusy(true); + setImportError(''); + try { + const selectedBook = bookOptions.find((b) => b.abbrev === importBookAbbrev) ?? bookOptions[0]; + + const chapterNumbersNeeded = new Set(); + importPreview.forEach((spec) => { + if (spec.parsed && spec.parsed !== 'invalid') { + chapterNumbersNeeded.add(spec.parsed.startChapter); + chapterNumbersNeeded.add(spec.parsed.endChapter); + } + }); + const sortedChapters = Array.from(chapterNumbersNeeded).sort((a, b) => a - b); + if (sortedChapters.length === 0) { + throw new Error('No verse passages found to import.'); + } + + const versesByChapter = {}; + for (const chNum of sortedChapters) { + const response = await fetch( + `https://bible.helloao.org/api/${importTranslation}/${selectedBook.abbrev}/${chNum}.json`, + ); + if (!response.ok) throw new Error(`Unable to load ${selectedBook.name} ${chNum}.`); + const data = await response.json(); + const verses = parseBibleChapter(data); + if (!verses.length) throw new Error(`No verses returned for ${selectedBook.name} ${chNum}.`); + versesByChapter[chNum] = verses; + } + + const chapterIndexByNum = new Map(sortedChapters.map((n, idx) => [n, idx])); + const chapters = sortedChapters.map((chNum) => ({ + book: selectedBook.name, + bookAbbrev: selectedBook.abbrev, + chapter: chNum, + verses: versesByChapter[chNum], + chunks: [], + })); + + // Markers (rows with no passage) attach to the nearest upcoming chapter with a + // real passage, or fall back to the previous one for trailing markers. + const anchorChapterFor = importPreview.map((spec, i) => { + if (spec.parsed && spec.parsed !== 'invalid') return spec.parsed.startChapter; + for (let j = i + 1; j < importPreview.length; j += 1) { + const next = importPreview[j].parsed; + if (next && next !== 'invalid') return next.startChapter; + } + for (let j = i - 1; j >= 0; j -= 1) { + const prev = importPreview[j].parsed; + if (prev && prev !== 'invalid') return prev.startChapter; + } + return sortedChapters[0]; + }); + + importPreview.forEach((spec, i) => { + if (spec.parsed === 'invalid') return; // skip unparseable rows entirely + const chIdx = chapterIndexByNum.get(anchorChapterFor[i]); + if (chIdx == null) return; + const target = chapters[chIdx]; + + const baseChunk = { + id: makeId(), + observation: '', + interpretation: '', + application: '', + crossReferences: [], + greekWords: [], + generalNotes: '', + episodeNumber: spec.episodeNumber, + episodeTitle: spec.title, + finalScript: '', + tags: [], + }; + + if (!spec.parsed) { + // No passage (e.g. a part intro/review) -> zero-length marker chunk. + target.chunks.push({ ...baseChunk, startVerse: 0, endVerse: 0, spilloverEndVerse: null }); + return; + } + + const { startChapter, startVerse, endChapter, endVerse } = spec.parsed; + if (endChapter === startChapter) { + target.chunks.push({ ...baseChunk, startVerse, endVerse, spilloverEndVerse: null }); + } else { + const lastVerseOfStartChapter = versesByChapter[startChapter]?.at(-1)?.number ?? endVerse; + target.chunks.push({ + ...baseChunk, + startVerse, + endVerse: lastVerseOfStartChapter, + spilloverEndVerse: endVerse, + }); + } + }); + + const newProject = { + id: makeId(), + title: importTitle.trim() || `${selectedBook.name} Episodes`, + translation: importTranslation, + lastEdited: Date.now(), + selectedChunkId: null, + chapters, + }; + setProject(newProject); + setActiveChapterIndex(0); + setCurrentPage('setup'); + } catch (error) { + setImportError(error.message || 'Import failed.'); + } finally { + setImportBusy(false); + } + }; + const resumeProject = (id) => { const loaded = loadProjectById(id); if (!loaded) return; @@ -2946,6 +3147,13 @@ const restoreRemoteProject = async (id) => { > 📖 Read Bible + + + + +
+
+

+ Upload a .docx with an episode table (Ep. # / Title / Passage, e.g. "1:1–2") and it'll + create a new project with chapters and chunks already labeled from it. +

+ +
+ + +
+ + + + + + {importBusy &&

Working…

} + {importError &&

{importError}

} + + {importPreview && !importBusy && ( +
+

+ {importPreview.length} episode{importPreview.length === 1 ? '' : 's'} found + {' · '} + {importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages +

+
+ + + {importPreview.map((spec) => ( + + + + + + ))} + +
Ep. {spec.episodeNumber}{spec.title} + {spec.parsed === 'invalid' + ? unrecognized — skipped + : spec.parsed + ? spec.passage + : marker (no passage)} +
+
+ +
+ )} +
+
+ + ); + } + if (currentPage === 'setup') { const chapterTabs = project?.chapters ?? [];