import { useState } from 'react'; import { useApp } from '../context/AppContext.js'; import PageShell from '../components/study/PageShell.jsx'; import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx'; import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js'; export default function ImportPage() { const { setCurrentPage, setProject } = useApp(); const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev); const importTranslation = 'BSB'; // studies use the Berean Standard Bible const [importTitle, setImportTitle] = useState(''); const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters const [importBusy, setImportBusy] = useState(false); const [importError, setImportError] = useState(''); const [templateBusy, setTemplateBusy] = useState(false); // The template's Word library is only fetched when someone asks for it. const downloadTemplate = async () => { setTemplateBusy(true); try { const { downloadImportTemplate } = await import('../utils/importTemplate.js'); await downloadImportTemplate(); } catch { setImportError('Couldn’t build the template. Please try again.'); } finally { setTemplateBusy(false); } }; const handleImportFileChange = async (file) => { setImportPreview(null); setImportError(''); if (!file) return; setImportBusy(true); try { const episodes = await parseEpisodeListDocx(file); if (episodes.length === 0) { throw new Error('No "Session # / 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} Sessions`, translation: importTranslation, lastEdited: Date.now(), selectedChunkId: null, chapters, }; setProject(newProject); setCurrentPage('setup'); } catch (error) { setImportError(error.message || 'Import failed.'); } finally { setImportBusy(false); } }; const field = 'mt-1 w-full rounded-xl border border-[var(--r-line)] bg-[var(--r-bg)] px-3 py-2 text-sm text-[var(--r-text)] focus:outline-none focus:ring-2 focus:ring-[var(--r-line)]'; return ( setCurrentPage('home')} maxWidth="max-w-3xl">

How this works

This is a shortcut for setting up a multi-part study — a teaching series, sermon series, class curriculum, or podcast — all at once, instead of building each chapter and chunk by hand.

Upload a .docx containing a table with three columns: session number, title, and passage (e.g. 1:1–2). Each row becomes one chunk, grouped automatically by chapter. If you don't have a document like this, just use{' '} + New Project on the home page instead — this import step is entirely optional.

A blank session list with example rows to fill in.

Text: Berean Standard Bible (BSB).

{importBusy &&

Working…

} {importError &&

{importError}

} {importPreview && !importBusy && (

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

{importPreview.map((spec) => ( ))}
#{spec.episodeNumber} {spec.title} {spec.parsed === 'invalid' ? unrecognized — skipped : spec.parsed ? spec.passage : marker (no passage)}
)}
); }