Import: downloadable Word template for the session list
Build and Push Docker Image / build-and-push (push) Failing after 20s
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>
This commit is contained in:
1 parent
7a010fcda8
commit
ec59f7c82d
4 files changed
+136
-1
No files matched your search
+15
-1
@@ -541,7 +541,7 @@ describe('Import page', () => {
|
|||||||
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
|
expect(await screen.findByRole('heading', { name: /import session list/i })).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
|
expect(screen.queryByRole('option', { name: 'KJV' })).not.toBeInTheDocument();
|
||||||
expect(screen.getByText(/Berean Standard Bible/)).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();
|
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');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -12,6 +12,20 @@ export default function ImportPage() {
|
|||||||
const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters
|
const [importPreview, setImportPreview] = useState(null); // parsed episode specs, before fetching chapters
|
||||||
const [importBusy, setImportBusy] = useState(false);
|
const [importBusy, setImportBusy] = useState(false);
|
||||||
const [importError, setImportError] = useState('');
|
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) => {
|
const handleImportFileChange = async (file) => {
|
||||||
setImportPreview(null);
|
setImportPreview(null);
|
||||||
@@ -166,6 +180,13 @@ export default function ImportPage() {
|
|||||||
<span className="font-medium text-[var(--r-text)]">+ New Project</span> on the home page instead — this import
|
<span className="font-medium text-[var(--r-text)]">+ New Project</span> on the home page instead — this import
|
||||||
step is entirely optional.
|
step is entirely optional.
|
||||||
</p>
|
</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>
|
||||||
|
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<div className="grid gap-4 sm:grid-cols-2">
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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']);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user