Build and Push Docker Image / build-and-push (push) Failing after 20s
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>
246 lines
11 KiB
React
246 lines
11 KiB
React
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>
|
||
);
|
||
}
|