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:
1 parent
d7bbbe60de
commit
8bb3b104c8
5 files changed
+189
-190
No files matched your search
-174
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
+147
-15
@@ -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">
|
||||
|
||||
@@ -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 ?? [];
|
||||
|
||||
|
||||
Reference in new issue
Block a user