From 8bb3b104c89cc726441e9f2004f2eb9b77ec043b Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 22 Sep 2026 14:50:21 -0400 Subject: [PATCH] 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 --- src/App.jsx | 174 -------------------------- src/App.test.jsx | 15 +++ src/hooks/useAvailableTranslations.js | 25 ++++ src/pages/ImportPage.jsx | 162 +++++++++++++++++++++--- src/pages/SetupPage.jsx | 3 +- 5 files changed, 189 insertions(+), 190 deletions(-) create mode 100644 src/hooks/useAvailableTranslations.js diff --git a/src/App.jsx b/src/App.jsx index 884e46b..c19a216 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -48,8 +48,6 @@ import { } from './utils/exportBuilders.js'; import { parseCrossRefString, - parseEpisodePassage, - parseEpisodeListDocx, } from './utils/importParsers.js'; import { @@ -83,7 +81,6 @@ const PronunciationPage = lazy(() => import('./pages/PronunciationPage.jsx')); // --------------------------------------------------------------------------- const App = () => { - const [availableTranslations, setAvailableTranslations] = useState(['BSB']); const [setup, setSetup] = useState({ translation: 'BSB', book: 'Titus', @@ -138,13 +135,6 @@ const App = () => { }); 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 reader = useBibleReader(currentPage); const audio = useAudio(reader.readerBookAbbrev, reader.readerChapter); const { authUser, setAuthUser, authServerDown, signIn, logout } = useAuth({ @@ -322,17 +312,6 @@ const App = () => { return () => { cancelled = true; }; }, [authUser]); - useEffect(() => { - fetch('https://bible.helloao.org/api/available_translations.json') - .then((res) => res.json()) - .then((data) => { - if (Array.isArray(data) && data.length > 0) { - setAvailableTranslations(data); - } - }) - .catch(() => {}); - }, []); - useEffect(() => { if (!titleEdited) { setSetup((current) => ({ @@ -1530,151 +1509,9 @@ const App = () => { 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 "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); - } - }; - const resumeProject = (id) => { const loaded = loadProjectById(id); if (!loaded) return; @@ -1957,21 +1794,10 @@ const deleteProject = (id) => { deleteProject, // Audio (book player on Home + chapter player in the Reader) ...audio, - // Import - importBookAbbrev, setImportBookAbbrev, - importTranslation, setImportTranslation, - importTitle, setImportTitle, - importFile, - importPreview, - importBusy, - importError, - handleImportFileChange, - runEpisodeImport, // Reader ...reader, copyVerse, // Setup - availableTranslations, project, setProject, setup, titleEdited, setTitleEdited, diff --git a/src/App.test.jsx b/src/App.test.jsx index d397c42..e8fd97a 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -497,3 +497,18 @@ describe('Admin page', () => { expect(screen.queryByRole('button', { name: /admin/i })).not.toBeInTheDocument(); }); }); + +// --------------------------------------------------------------------------- +// Import page (state is local to ImportPage) +// --------------------------------------------------------------------------- +describe('Import page', () => { + test('opens from Home, lists fetched translations, and goes back', async () => { + const user = userEvent.setup(); + await renderApp(); + await user.click(screen.getByRole('button', { name: /import session list/i })); + expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument(); + expect(await screen.findByRole('option', { name: 'KJV' })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: /back/i })); + expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument(); + }); +}); diff --git a/src/hooks/useAvailableTranslations.js b/src/hooks/useAvailableTranslations.js new file mode 100644 index 0000000..70a04b9 --- /dev/null +++ b/src/hooks/useAvailableTranslations.js @@ -0,0 +1,25 @@ +// The list of Bible translations offered by the HelloAO API, fetched once per +// visit and shared by every page that needs it (Setup, Import). +import { useEffect, useState } from 'react'; + +let cached = null; +let pending = null; + +export function useAvailableTranslations() { + const [translations, setTranslations] = useState(cached ?? ['BSB']); + useEffect(() => { + if (cached) return; + pending ??= fetch('https://bible.helloao.org/api/available_translations.json') + .then((res) => res.json()) + .then((data) => { + if (Array.isArray(data) && data.length > 0) cached = data; + return cached; + }) + .catch(() => null) + .finally(() => { pending = null; }); + let active = true; + pending.then((list) => { if (active && list) setTranslations(list); }); + return () => { active = false; }; + }, []); + return translations; +} diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx index f415de4..8aa81da 100644 --- a/src/pages/ImportPage.jsx +++ b/src/pages/ImportPage.jsx @@ -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 (
diff --git a/src/pages/SetupPage.jsx b/src/pages/SetupPage.jsx index 97c383b..a320f44 100644 --- a/src/pages/SetupPage.jsx +++ b/src/pages/SetupPage.jsx @@ -1,4 +1,5 @@ import { useApp } from '../context/AppContext.js'; +import { useAvailableTranslations } from '../hooks/useAvailableTranslations.js'; import { bookOptions, formatChunkReference } from '../utils/bibleUtils.jsx'; function SetupForm({ setup, availableTranslations, titleEdited, loadingChapter, errorMessage, hideTitle, onField, onTitleChange, onLoad }) { @@ -83,7 +84,6 @@ export default function SetupPage() { headerButtons, project, setup, - availableTranslations, titleEdited, setTitleEdited, activeChapterIndex, setActiveChapterIndex, activeChapter, @@ -111,6 +111,7 @@ export default function SetupPage() { deleteChunk, updateProject, } = useApp(); + const availableTranslations = useAvailableTranslations(); const chapterTabs = project?.chapters ?? [];