Move import state into ImportPage; share translation list via hook

The session-list import form, preview, and runEpisodeImport now live in
ImportPage. useAvailableTranslations fetches the translation list once per
visit for both Import and Setup. Drops the never-read importFile state.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Opus 5.5 committed 2026-09-22 14:50:21 -04:00
1 parent d7bbbe60de
commit 8bb3b104c8
5 files changed
+189 -190

No files matched your search

+147 -15
View File
@@ -1,21 +1,153 @@
import { useState } from 'react';
import { useApp } from '../context/AppContext.js';
import { bookOptions } from '../utils/bibleUtils.jsx';
import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js';
import { bookOptions, makeId, parseBibleChapter } from '../utils/bibleUtils.jsx';
import { parseEpisodeListDocx, parseEpisodePassage } from '../utils/importParsers.js';
export default function ImportPage() {
const {
authStatus,
setCurrentPage,
availableTranslations,
importBookAbbrev, setImportBookAbbrev,
importTranslation, setImportTranslation,
importTitle, setImportTitle,
importFile,
importPreview,
importBusy,
importError,
handleImportFileChange,
runEpisodeImport,
} = useApp();
const { authStatus, setCurrentPage, setProject, setActiveChapterIndex } = useApp();
const availableTranslations = useAvailableTranslations();
const [importBookAbbrev, setImportBookAbbrev] = useState(bookOptions[0].abbrev);
const [importTranslation, setImportTranslation] = useState('BSB');
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 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);
setActiveChapterIndex(0);
setCurrentPage('setup');
} catch (error) {
setImportError(error.message || 'Import failed.');
} finally {
setImportBusy(false);
}
};
return (
<div className="min-h-screen bg-slate-50 text-slate-900">