Files
study-bible-project/src/pages/ImportPage.jsx
T
nmemmertandClaude Sonnet 5.5 ec59f7c82d
Build and Push Docker Image / build-and-push (push) Failing after 20s
Import: downloadable Word template for the session list
A blank three-column session list (Session #, Title, Passage) with instructions and
example rows, built in the browser on demand. A test imports the generated file back
through the real importer.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-09-29 11:18:24 -04:00

246 lines
11 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (
<PageShell title="Import Session List" active="studies" onBack={() => setCurrentPage('home')} maxWidth="max-w-3xl">
<main className="mx-auto max-w-3xl space-y-6 px-4 py-8">
<section className="space-y-5 rounded-2xl border border-[var(--r-line)] bg-[var(--r-bar)] p-6">
<div className="space-y-2 rounded-xl bg-[var(--r-bg)] p-4 text-sm text-[var(--r-sub)]">
<p className="font-medium text-[var(--r-text)]">How this works</p>
<p>
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.
</p>
<p>
Upload a .docx containing a table with three columns: session number, title, and passage
(e.g. <span className="font-mono text-xs">1:1–2</span>). Each row becomes one chunk, grouped
automatically by chapter. If you don't have a document like this, just use{' '}
<span className="font-medium text-[var(--r-text)]">+ New Project</span> on the home page instead — this import
step is entirely optional.
</p>
<div className="pt-1">
<button type="button" onClick={downloadTemplate} disabled={templateBusy}
className="rounded-full border border-[var(--r-line)] px-4 py-1.5 text-sm font-medium text-[var(--r-text)] transition hover:bg-black/5 disabled:opacity-50">
{templateBusy ? 'Building…' : 'Download template (.docx)'}
</button>
<span className="ml-3 text-xs">A blank session list with example rows to fill in.</span>
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<label className="block text-sm">Book
<select value={importBookAbbrev} onChange={(e) => setImportBookAbbrev(e.target.value)} className={field}>
{bookOptions.map((b) => <option key={b.abbrev} value={b.abbrev}>{b.name}</option>)}
</select>
</label>
<label className="block text-sm">Project title
<input type="text" value={importTitle} onChange={(e) => setImportTitle(e.target.value)}
placeholder={`${bookOptions.find((b) => b.abbrev === importBookAbbrev)?.name ?? ''} Sessions`} className={field} />
</label>
</div>
<p className="text-xs text-[var(--r-sub)]">Text: Berean Standard Bible (BSB).</p>
<label className="block text-sm">Session list (.docx)
<input type="file" accept=".docx" onChange={(e) => handleImportFileChange(e.target.files?.[0] ?? null)}
className="mt-1 block w-full text-sm text-[var(--r-sub)] file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--r-text)] file:px-4 file:py-2 file:text-sm file:text-[var(--r-bg)]" />
</label>
{importBusy && <p className="text-sm text-[var(--r-sub)]">Working…</p>}
{importError && <p className="text-sm text-[var(--r-danger,#b42318)]">{importError}</p>}
{importPreview && !importBusy && (
<div className="space-y-3">
<p className="text-sm font-medium">
{importPreview.length} session{importPreview.length === 1 ? '' : 's'} found
{' · '}
{importPreview.filter((s) => s.parsed && s.parsed !== 'invalid').length} with passages
</p>
<div className="max-h-80 overflow-y-auto rounded-xl border border-[var(--r-line)]">
<table className="w-full text-sm">
<tbody>
{importPreview.map((spec) => (
<tr key={spec.episodeNumber} className="border-b border-[var(--r-line)] last:border-0">
<td className="px-3 py-1.5 text-[var(--r-sub)]">#{spec.episodeNumber}</td>
<td className="px-3 py-1.5">{spec.title}</td>
<td className="px-3 py-1.5 text-right text-[var(--r-sub)]">
{spec.parsed === 'invalid'
? <span className="text-[var(--r-danger,#b42318)]">unrecognized — skipped</span>
: spec.parsed ? spec.passage : <span>marker (no passage)</span>}
</td>
</tr>
))}
</tbody>
</table>
</div>
<button type="button" onClick={runEpisodeImport} disabled={importBusy}
className="rounded-full bg-[var(--r-accent)] px-6 py-2.5 text-sm font-medium text-[var(--r-on)] hover:opacity-90 disabled:opacity-50">Create Project from Import</button>
</div>
)}
</section>
</main>
</PageShell>
);
}