From ec59f7c82dc1a03fa2a53d360bd78e7309cdc751 Mon Sep 17 00:00:00 2001 From: nmemmert <67961292+nmemmert@users.noreply.github.com> Date: Tue, 29 Sep 2026 11:18:24 -0400 Subject: [PATCH] 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 --- src/App.test.jsx | 16 +++++++- src/pages/ImportPage.jsx | 21 +++++++++++ src/utils/importTemplate.js | 63 ++++++++++++++++++++++++++++++++ src/utils/importTemplate.test.js | 37 +++++++++++++++++++ 4 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 src/utils/importTemplate.js create mode 100644 src/utils/importTemplate.test.js diff --git a/src/App.test.jsx b/src/App.test.jsx index 35ff732..53bf574 100644 --- a/src/App.test.jsx +++ b/src/App.test.jsx @@ -541,7 +541,7 @@ describe('Import page', () => { expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument(); expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument(); expect(screen.getByText(/Berean Standard Bible/)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /back/i })); + await user.click(screen.getByRole('button', { name: /^back$/i })); expect(await screen.findByRole('button', { name: /import session list/i })).toBeInTheDocument(); }); }); @@ -1391,3 +1391,17 @@ describe('Resizable study column', () => { expect(columns().style.getPropertyValue('--study-right')).toBe('560px'); }); }); + +describe('Import template', () => { + test('the Import page offers a Word template to download', async () => { + const user = userEvent.setup(); + await renderApp(); + await user.click(screen.getByRole('button', { name: /import session list/i })); + await user.click(await screen.findByRole('button', { name: /download template/i })); + await waitFor(() => expect(URL.createObjectURL).toHaveBeenCalled()); + const blob = URL.createObjectURL.mock.calls.at(-1)[0]; + expect(blob.type).toMatch(/wordprocessingml/); + // and the button comes back to normal afterwards + expect(await screen.findByRole('button', { name: /download template/i })).toBeEnabled(); + }); +}); diff --git a/src/pages/ImportPage.jsx b/src/pages/ImportPage.jsx index 69d1832..9ae77b7 100644 --- a/src/pages/ImportPage.jsx +++ b/src/pages/ImportPage.jsx @@ -12,6 +12,20 @@ export default function ImportPage() { 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); @@ -166,6 +180,13 @@ export default function ImportPage() { + New Project on the home page instead — this import step is entirely optional.

+
+ + A blank session list with example rows to fill in. +
diff --git a/src/utils/importTemplate.js b/src/utils/importTemplate.js new file mode 100644 index 0000000..8243ff3 --- /dev/null +++ b/src/utils/importTemplate.js @@ -0,0 +1,63 @@ +// A blank "Session list" Word document for the Import page: a three-column table +// (session number, title, passage) in exactly the shape the importer reads, with a few example +// rows to overwrite. Built in the browser on demand, so the `docx` library only downloads when +// someone actually asks for the template. +import { + Document, Packer, Paragraph, ShadingType, Table, TableCell, TableRow, TextRun, WidthType, HeadingLevel, +} from 'docx'; + +export const TEMPLATE_FILENAME = 'session-list-template.docx'; + +// Example rows show each kind of passage the importer understands. +export const TEMPLATE_ROWS = [ + ['1', 'Paul’s greeting', '1:1–4'], + ['2', 'Appointing elders', '1:5–9'], + ['3', 'Rebuking false teachers', '1:10–16'], + ['4', 'A recap of chapter 1', '—'], // no passage: becomes a divider or intro with no verses + ['5', 'Teaching sound doctrine', '2:1–10'], + ['6', 'A passage that crosses a chapter', '2:11–3:2'], +]; + +const cell = (text, { bold = false, shade = false, width } = {}) => new TableCell({ + width: { size: width, type: WidthType.PERCENTAGE }, + shading: shade ? { type: ShadingType.CLEAR, fill: 'EDE7D9', color: 'auto' } : undefined, + margins: { top: 80, bottom: 80, left: 120, right: 120 }, + children: [new Paragraph({ children: [new TextRun({ text, bold })] })], +}); + +const row = (cells, header = false) => new TableRow({ + tableHeader: header, + children: cells.map((text, i) => cell(text, { bold: header, shade: header, width: [15, 55, 30][i] })), +}); + +const note = (text) => new Paragraph({ spacing: { after: 100 }, children: [new TextRun({ text })] }); + +export function buildImportTemplateDocument() { + return new Document({ + sections: [{ + children: [ + new Paragraph({ heading: HeadingLevel.HEADING_1, children: [new TextRun({ text: 'Session list' })] }), + note('Replace the example rows below with your own, one row per session, then upload this file on the Import page.'), + note('Passage: chapter:verse, like 1:1–4. To cross a chapter, write both chapters, like 2:11–3:2. For a session with no passage (an intro or a recap), write — (a dash).'), + note('You choose the book on the Import page, so leave it out of the Passage column. Keep the three columns, and delete any example rows you don’t need.'), + new Table({ + width: { size: 100, type: WidthType.PERCENTAGE }, + rows: [row(['Session #', 'Title', 'Passage'], true), ...TEMPLATE_ROWS.map((r) => row(r))], + }), + ], + }], + }); +} + +// Builds the template and hands it to the browser as a download. +export async function downloadImportTemplate() { + const blob = await Packer.toBlob(buildImportTemplateDocument()); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = TEMPLATE_FILENAME; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); +} diff --git a/src/utils/importTemplate.test.js b/src/utils/importTemplate.test.js new file mode 100644 index 0000000..be7a8cc --- /dev/null +++ b/src/utils/importTemplate.test.js @@ -0,0 +1,37 @@ +import { test, expect, vi } from 'vitest'; +import { Packer } from 'docx'; +import { buildImportTemplateDocument, TEMPLATE_ROWS } from './importTemplate.js'; +import { parseEpisodeListDocx, parseEpisodePassage } from './importParsers.js'; + +// Real uploads run in the browser, where mammoth takes an ArrayBuffer; its Node build takes a +// Buffer. Adapt so the same importer code can run here. +vi.mock('mammoth', async () => { + const real = await vi.importActual('mammoth'); + const convertToHtml = ({ arrayBuffer }) => real.convertToHtml({ buffer: Buffer.from(arrayBuffer) }); + return { default: { convertToHtml } }; +}); + +// Runs the generated file through the same importer real uploads go through. +async function importTemplate() { + const buf = await Packer.toBuffer(buildImportTemplateDocument()); + const arrayBuffer = buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); + return parseEpisodeListDocx({ arrayBuffer: async () => arrayBuffer }); +} + +test('the template imports back as exactly its example rows (header and instructions are ignored)', async () => { + const sessions = await importTemplate(); + expect(sessions.map((s) => [s.episodeNumber, s.title, s.passage])).toEqual(TEMPLATE_ROWS); +}); + +test('every example passage is one the importer understands', async () => { + const sessions = await importTemplate(); + const parsed = sessions.map((s) => parseEpisodePassage(s.passage)); + expect(parsed).not.toContain('invalid'); + expect(parsed[0]).toEqual({ startChapter: 1, startVerse: 1, endChapter: 1, endVerse: 4 }); + expect(parsed[3]).toBeNull(); // the "—" row has no passage + expect(parsed[5]).toEqual({ startChapter: 2, startVerse: 11, endChapter: 3, endVerse: 2 }); // crosses a chapter +}); + +test('the sessions are numbered in order with no gaps', () => { + expect(TEMPLATE_ROWS.map((r) => r[0])).toEqual(['1', '2', '3', '4', '5', '6']); +});