Speed up the app, run production build, remove chunk ink, start App.jsx split
Performance: - Lazy-load pages, docx, and mammoth (initial JS 1,298 KB -> ~290 KB) - Gzip responses and cache hashed assets for a year - Memoize DrawCanvas strokes so ink isn't redrawn on every render - Precompute whole-Bible search index and defer the query while typing - Reload once if a lazy chunk is missing after a deploy Deployment: - setup.sh builds and runs one production server on APP_PORT (default 5173); --dev keeps the old Vite dev setup - Session cookie uses secure: 'auto' so login works behind any proxy Chunk editor: - Remove the Draw layout, Ink Notes panel, and ink in HTML export (existing inkStrokes data is left in saved projects) Refactor: - Move module-level helpers out of App.jsx into src/utils/* - Extract Bible reader state and logic into hooks/useBibleReader Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
00932dee4a
commit
ade7f1af6f
20 files changed
+1314
-1252
No files matched your search
@@ -0,0 +1,46 @@
|
||||
// IndexedDB cache for the whole-Bible search index.
|
||||
const BIBLE_IDB_NAME = 'study-app-bible-index';
|
||||
const BIBLE_IDB_STORE = 'bible-index';
|
||||
export const BIBLE_IDB_TTL = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function openBibleIndexDB() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(BIBLE_IDB_NAME, 1);
|
||||
req.onupgradeneeded = (e) => {
|
||||
const db = e.target.result;
|
||||
if (!db.objectStoreNames.contains(BIBLE_IDB_STORE)) {
|
||||
db.createObjectStore(BIBLE_IDB_STORE, { keyPath: 'id' });
|
||||
}
|
||||
};
|
||||
req.onsuccess = (e) => resolve(e.target.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
export async function getBibleIndexFromIDB(translation) {
|
||||
try {
|
||||
const db = await openBibleIndexDB();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(BIBLE_IDB_STORE, 'readonly');
|
||||
const req = tx.objectStore(BIBLE_IDB_STORE).get(translation);
|
||||
req.onsuccess = () => resolve(req.result ?? null);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveBibleIndexToIDB(translation, flat) {
|
||||
try {
|
||||
const db = await openBibleIndexDB();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(BIBLE_IDB_STORE, 'readwrite');
|
||||
const req = tx.objectStore(BIBLE_IDB_STORE).put({ id: translation, flat, cachedAt: Date.now() });
|
||||
req.onsuccess = () => resolve();
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
} catch {
|
||||
// Non-fatal; in-memory cache still works for this session
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { getStroke } from 'perfect-freehand';
|
||||
import { pathFromStroke } from './inkRender.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Study constants
|
||||
@@ -192,28 +190,6 @@ export function getChunkVerseEntries(project, startChapterIndex, chunk) {
|
||||
|
||||
const escHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
|
||||
function inkStrokesToSvgHtml(strokes) {
|
||||
if (!strokes?.length) return '';
|
||||
const W = 700, H = 500;
|
||||
const lines = [];
|
||||
for (let y = 32; y < H; y += 32)
|
||||
lines.push(`<line x1="0" y1="${y}" x2="${W}" y2="${y}" stroke="#dde3ec" stroke-width="1"/>`);
|
||||
lines.push(`<line x1="40" y1="0" x2="40" y2="${H}" stroke="#fca5a5" stroke-width="1"/>`);
|
||||
const paths = strokes.map((s) => {
|
||||
const pts = s.points.map(([x, y, p]) => [x * W, y * H, p]);
|
||||
const outline = getStroke(pts, {
|
||||
size: s.size * H,
|
||||
thinning: s.tool === 'highlighter' ? 0 : 0.5,
|
||||
smoothing: 0.5,
|
||||
streamline: 0.4,
|
||||
});
|
||||
const d = pathFromStroke(outline);
|
||||
if (!d) return '';
|
||||
return `<path d="${d}" fill="${escHtml(s.color)}" opacity="${s.tool === 'highlighter' ? '0.35' : '1'}"/>`;
|
||||
}).filter(Boolean).join('');
|
||||
return `<div class="ink-notes"><strong>INK NOTES:</strong><div style="margin-top:8px;border:1px solid #e2e8f0;border-radius:8px;overflow:hidden;background:#fff;"><svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${H}" viewBox="0 0 ${W} ${H}" style="display:block;max-width:100%;height:auto;">${lines.join('')}${paths}</svg></div></div>`;
|
||||
}
|
||||
|
||||
export function buildExportHtml(project) {
|
||||
const style = `
|
||||
body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; }
|
||||
@@ -224,7 +200,7 @@ export function buildExportHtml(project) {
|
||||
.chunk { margin-bottom: 2rem; padding: 1.25rem 1.5rem; border: 1px solid #cbd5e1; border-radius: 0.75rem; background: #ffffff; }
|
||||
.verse { margin: 0 0 0.75rem; line-height: 1.7; }
|
||||
.scripture-ref { font-weight: 700; margin-bottom: 0.75rem; }
|
||||
.oia, .cross-refs, .greek, .ink-notes { margin-top: 1rem; }
|
||||
.oia, .cross-refs, .greek { margin-top: 1rem; }
|
||||
.oia-section { margin-bottom: 0.75rem; }
|
||||
.greek table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
|
||||
.greek th, .greek td { border: 1px solid #d1d5db; padding: 0.65rem; text-align: left; }
|
||||
@@ -295,8 +271,6 @@ export function buildExportHtml(project) {
|
||||
})
|
||||
.join('');
|
||||
|
||||
const inkHtml = inkStrokesToSvgHtml(chunk.inkStrokes);
|
||||
|
||||
return `
|
||||
<section class="chunk">
|
||||
<div class="scripture-ref">${escHtml(scripture)}</div>
|
||||
@@ -311,7 +285,6 @@ export function buildExportHtml(project) {
|
||||
${greekRows ? greekTable : '<p><em>No Greek word notes.</em></p>'}
|
||||
${extendedDefinitions}
|
||||
</div>
|
||||
${inkHtml}
|
||||
</section>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
@@ -0,0 +1,217 @@
|
||||
// DOCX export — kept in its own module so the (large) `docx` library is only
|
||||
// downloaded when the user actually exports, instead of on every page load.
|
||||
import {
|
||||
BorderStyle,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from 'docx';
|
||||
import { formatChunkReference, getChunkVerseEntries, htmlToPlainText } from './bibleUtils.jsx';
|
||||
|
||||
export function createParagraphsFromText(text) {
|
||||
return text
|
||||
.split(/\n+/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => new Paragraph({ children: [new TextRun({ text: line })] }));
|
||||
}
|
||||
|
||||
const DOCX_ACCENT = 'C9A84C'; // antique gold
|
||||
const DOCX_HEADING_FONT = 'Cormorant Garamond';
|
||||
const DOCX_BODY_FONT = 'Lora';
|
||||
|
||||
export function sectionLabel(text) {
|
||||
return new Paragraph({
|
||||
spacing: { before: 240, after: 80 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: text.toUpperCase(),
|
||||
bold: true,
|
||||
color: DOCX_ACCENT,
|
||||
font: DOCX_BODY_FONT,
|
||||
size: 20,
|
||||
characterSpacing: 20,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export async function buildStudyDocxBlob(project, chapterSummary) {
|
||||
const children = [
|
||||
new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${project.translation} — ${chapterSummary}`,
|
||||
italics: true,
|
||||
color: '7A7060',
|
||||
}),
|
||||
],
|
||||
spacing: { after: 300 },
|
||||
}),
|
||||
];
|
||||
|
||||
const noBorder = { style: BorderStyle.NONE, size: 0, color: 'FFFFFF' };
|
||||
|
||||
project.chapters.forEach((ch, chapterIndex) => {
|
||||
children.push(new Paragraph({
|
||||
text: `${ch.book} ${ch.chapter}`,
|
||||
heading: HeadingLevel.HEADING_1,
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 6, color: DOCX_ACCENT, space: 4 } },
|
||||
spacing: { before: 360, after: 160 },
|
||||
}));
|
||||
ch.chunks.forEach((chunk) => {
|
||||
const scriptureHeading = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||
|
||||
const headingRuns = [new TextRun({ text: scriptureHeading, bold: true, size: 28, color: DOCX_ACCENT, font: DOCX_HEADING_FONT })];
|
||||
if (chunk.episodeNumber || chunk.episodeTitle) {
|
||||
headingRuns.push(new TextRun({
|
||||
text: ` (Session ${chunk.episodeNumber || '—'}${chunk.episodeTitle ? `: ${chunk.episodeTitle}` : ''})`,
|
||||
italics: true,
|
||||
size: 22,
|
||||
color: '7A7060',
|
||||
}));
|
||||
}
|
||||
children.push(new Paragraph({ children: headingRuns, spacing: { before: 320, after: 120 } }));
|
||||
|
||||
// Scripture text in a left-bordered table cell for a "callout" look (no fill)
|
||||
const scriptureParas = getChunkVerseEntries(project, chapterIndex, chunk).map((verse) => new Paragraph({
|
||||
children: [
|
||||
new TextRun({ text: `${verse.chapter}:${verse.number} `, bold: true, color: DOCX_ACCENT }),
|
||||
new TextRun({ text: verse.text, italics: true }),
|
||||
],
|
||||
spacing: { after: 80 },
|
||||
}));
|
||||
children.push(new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows: [
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
margins: { top: 120, bottom: 120, left: 180, right: 180 },
|
||||
borders: {
|
||||
...{ top: noBorder, right: noBorder, bottom: noBorder },
|
||||
left: { style: BorderStyle.SINGLE, size: 24, color: DOCX_ACCENT },
|
||||
},
|
||||
children: scriptureParas,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}));
|
||||
children.push(new Paragraph({ text: '', spacing: { after: 120 } }));
|
||||
|
||||
if (chunk.generalNotes?.trim()) {
|
||||
children.push(sectionLabel('Background / General Notes'));
|
||||
children.push(...createParagraphsFromText(chunk.generalNotes));
|
||||
}
|
||||
|
||||
children.push(sectionLabel('Observation'));
|
||||
children.push(...createParagraphsFromText(chunk.observation || 'No observation.'));
|
||||
children.push(sectionLabel('Interpretation'));
|
||||
children.push(...createParagraphsFromText(chunk.interpretation || 'No interpretation.'));
|
||||
children.push(sectionLabel('Application'));
|
||||
children.push(...createParagraphsFromText(chunk.application || 'No application.'));
|
||||
|
||||
if ((chunk.tags ?? []).length > 0) {
|
||||
children.push(sectionLabel('Tags'));
|
||||
children.push(new Paragraph({
|
||||
children: chunk.tags.map((tag, i) => new TextRun({
|
||||
text: i === 0 ? `#${tag}` : ` #${tag}`,
|
||||
bold: true,
|
||||
color: DOCX_ACCENT,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
if ((chunk.crossReferences ?? []).length > 0) {
|
||||
children.push(sectionLabel('Cross-References'));
|
||||
children.push(new Paragraph({ text: chunk.crossReferences.join(' • ') }));
|
||||
}
|
||||
|
||||
if (chunk.greekWords.length > 0) {
|
||||
children.push(sectionLabel('Word Studies'));
|
||||
const headerCellStyle = (label) => new TableCell({
|
||||
width: { size: 20, type: WidthType.PERCENTAGE },
|
||||
borders: { bottom: { style: BorderStyle.SINGLE, size: 8, color: DOCX_ACCENT } },
|
||||
children: [new Paragraph({ children: [new TextRun({ text: label, bold: true, color: DOCX_ACCENT })] })],
|
||||
});
|
||||
const tableRows = [
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: ['Strong', 'Greek', 'Transliteration', 'Part of Speech', 'Short Definition'].map(headerCellStyle),
|
||||
}),
|
||||
...chunk.greekWords.map((word) => new TableRow({
|
||||
children: [
|
||||
new TableCell({ children: [new Paragraph(word.strongNumber || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.lexeme || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.transliteration || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.partOfSpeech || '')] }),
|
||||
new TableCell({ children: [new Paragraph(word.shortDefinition || '')] }),
|
||||
],
|
||||
})),
|
||||
];
|
||||
children.push(new Table({ rows: tableRows, width: { size: 100, type: WidthType.PERCENTAGE } }));
|
||||
chunk.greekWords.forEach((word) => {
|
||||
if (word.definitionHtml) {
|
||||
children.push(new Paragraph({
|
||||
spacing: { before: 180, after: 80 },
|
||||
children: [new TextRun({ text: `${word.strongNumber} — ${word.lexeme || ''}`, bold: true, color: DOCX_ACCENT })],
|
||||
}));
|
||||
children.push(...createParagraphsFromText(htmlToPlainText(word.definitionHtml)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.finalScript?.trim()) {
|
||||
children.push(sectionLabel('Final Script'));
|
||||
children.push(...createParagraphsFromText(chunk.finalScript));
|
||||
}
|
||||
|
||||
children.push(new Paragraph({
|
||||
text: '',
|
||||
spacing: { after: 200 },
|
||||
border: { bottom: { style: BorderStyle.SINGLE, size: 4, color: '2A2518', space: 8 } },
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: { run: { font: DOCX_BODY_FONT, color: '0A0A08' } },
|
||||
},
|
||||
paragraphStyles: [
|
||||
{
|
||||
id: 'Title',
|
||||
name: 'Title',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 56, bold: true, color: '0A0A08' },
|
||||
},
|
||||
{
|
||||
id: 'Heading1',
|
||||
name: 'Heading 1',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 36, bold: true, color: '0A0A08' },
|
||||
},
|
||||
{
|
||||
id: 'Heading2',
|
||||
name: 'Heading 2',
|
||||
basedOn: 'Normal',
|
||||
next: 'Normal',
|
||||
run: { font: DOCX_HEADING_FONT, size: 30, bold: true, color: '0A0A08' },
|
||||
},
|
||||
],
|
||||
},
|
||||
sections: [{ children }],
|
||||
});
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// Markdown export and Claude prompt builders.
|
||||
import {
|
||||
formatChunkReference,
|
||||
getChunkVerseEntries,
|
||||
htmlToPlainText,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Export / prompt builders (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function buildMarkdownExport(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
|
||||
const chapterSections = chapters.map((ch, chapterIndex) => {
|
||||
const chunkSections = ch.chunks.map((chunk) => {
|
||||
const scripture = formatChunkReference(project, chapterIndex, chunk, '-');
|
||||
const versesText = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||
.map((verse) => `> **${verse.chapter}:${verse.number}** ${verse.text}`)
|
||||
.join('\n>\n');
|
||||
|
||||
const observation = (chunk.observation ?? '').trim() || '_No observation._';
|
||||
const interpretation = (chunk.interpretation ?? '').trim() || '_No interpretation._';
|
||||
const application = (chunk.application ?? '').trim() || '_No application._';
|
||||
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||
|
||||
const crossRefsLine = (chunk.crossReferences ?? []).length > 0
|
||||
? `**Cross-References:** ${chunk.crossReferences.join(', ')}\n\n`
|
||||
: '';
|
||||
|
||||
const greekSection = chunk.greekWords.length === 0
|
||||
? ''
|
||||
: `**Greek/Hebrew Words**\n\n${chunk.greekWords.map((word) => {
|
||||
const summary = [
|
||||
word.strongNumber,
|
||||
word.lexeme,
|
||||
word.transliteration && `(${word.transliteration})`,
|
||||
word.partOfSpeech,
|
||||
word.shortDefinition,
|
||||
].filter(Boolean).join(' — ');
|
||||
const definition = word.definitionHtml ? `\n ${htmlToPlainText(word.definitionHtml)}` : '';
|
||||
return `- ${summary}${definition}`;
|
||||
}).join('\n')}\n\n`;
|
||||
|
||||
return `### ${scripture}\n\n${versesText}\n\n${generalNotes ? `**Background / Notes**\n\n${generalNotes}\n\n` : ''}**Observation**\n\n${observation}\n\n**Interpretation**\n\n${interpretation}\n\n**Application**\n\n${application}\n\n${crossRefsLine}${greekSection}`;
|
||||
}).join('---\n\n');
|
||||
|
||||
return `## ${ch.book} ${ch.chapter}\n\n${chunkSections}`;
|
||||
}).join('\n');
|
||||
|
||||
return `# ${project.title}\n\n*${project.translation}*\n\n${chapterSections}`;
|
||||
}
|
||||
|
||||
export function buildChunkBodies(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
let chunkIndex = 0;
|
||||
return chapters.map((ch, chapterIndex) => {
|
||||
return ch.chunks.map((chunk) => {
|
||||
chunkIndex += 1;
|
||||
const ref = formatChunkReference(project, chapterIndex, chunk, '–');
|
||||
|
||||
const verses = getChunkVerseEntries(project, chapterIndex, chunk)
|
||||
.map((v) => `${v.chapter}:${v.number} ${v.text}`)
|
||||
.join('\n');
|
||||
|
||||
const observation = (chunk.observation ?? '').trim() || 'No observation.';
|
||||
const interpretation = (chunk.interpretation ?? '').trim() || 'No interpretation.';
|
||||
const application = (chunk.application ?? '').trim() || 'No application.';
|
||||
|
||||
const crossRefs = (chunk.crossReferences ?? []).length > 0
|
||||
? chunk.crossReferences.join(', ')
|
||||
: 'None.';
|
||||
|
||||
const generalNotes = (chunk.generalNotes ?? '').trim();
|
||||
const episodeLabelLine = (chunk.episodeNumber ?? '').trim() || (chunk.episodeTitle ?? '').trim()
|
||||
? `Session: ${[chunk.episodeNumber?.trim(), chunk.episodeTitle?.trim()].filter(Boolean).join(' — ')}\n`
|
||||
: '';
|
||||
|
||||
const greekWords = chunk.greekWords.length === 0
|
||||
? 'None.'
|
||||
: chunk.greekWords.map((word) => {
|
||||
const summary = [
|
||||
word.strongNumber,
|
||||
word.lexeme,
|
||||
word.transliteration && `(${word.transliteration})`,
|
||||
word.partOfSpeech,
|
||||
word.shortDefinition,
|
||||
].filter(Boolean).join(' | ');
|
||||
const definition = word.definitionHtml
|
||||
? `\n ${htmlToPlainText(word.definitionHtml)}`
|
||||
: '';
|
||||
return `• ${summary}${definition}`;
|
||||
}).join('\n');
|
||||
|
||||
return `===
|
||||
|
||||
CHUNK ${chunkIndex} — ${ref}
|
||||
${episodeLabelLine}
|
||||
Scripture:
|
||||
${verses}
|
||||
${generalNotes ? `\nBackground / General Notes:\n${generalNotes}\n` : ''}
|
||||
Observation:
|
||||
${observation}
|
||||
|
||||
Interpretation:
|
||||
${interpretation}
|
||||
|
||||
Application:
|
||||
${application}
|
||||
|
||||
Cross-References: ${crossRefs}
|
||||
|
||||
Greek Words:
|
||||
${greekWords}
|
||||
`;
|
||||
}).join('\n');
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
export function buildClaudePrompt(project) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||
|
||||
const header = `I've prepared a Bible study on ${chapterLabel} (${project.translation}) and need your help turning my notes into a polished study guide.
|
||||
|
||||
Below is my work organised by passage chunk, including my OIA notes and Greek word research. Please create a clear, structured study guide that:
|
||||
- Synthesises my notes into coherent teaching points
|
||||
- Naturally integrates the Greek word insights
|
||||
- Includes 2–3 reflection questions per chunk
|
||||
- Preserves the passage-by-passage structure
|
||||
|
||||
---
|
||||
|
||||
PROJECT: ${project.title}
|
||||
TRANSLATION: ${project.translation}
|
||||
PASSAGE: ${chapterLabel}
|
||||
`;
|
||||
|
||||
return header + buildChunkBodies(project);
|
||||
}
|
||||
|
||||
export function buildPodcastPrompt(project, podcastName) {
|
||||
const chapters = Array.isArray(project.chapters) ? project.chapters : [];
|
||||
const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', ');
|
||||
const episodeLabel = 'EPISODE';
|
||||
const showName = (podcastName ?? '').trim();
|
||||
const showLine = showName || 'MY BIBLE STUDY PODCAST';
|
||||
const signOffLine = showName
|
||||
? `Introduce yourself, then sign off with: "...and this is ${showName}."`
|
||||
: 'Introduce yourself and the name of the show as a sign-off.';
|
||||
|
||||
const header = `I'm recording an episode of my Bible study podcast${showName ? ` "${showName}"` : ''} and need a full script written from my study notes below.
|
||||
|
||||
Please write the script in this exact structure, using the section markers and tone shown:
|
||||
|
||||
${showLine.toUpperCase()}
|
||||
${episodeLabel}
|
||||
${chapterLabel} (${project.translation}) · [estimate XX–XX minutes based on content]
|
||||
|
||||
✝ OPENING PRAYER
|
||||
[Pause before opening the text — invite God into the study]
|
||||
A short prayer (4-6 sentences) tying into the themes of this passage.
|
||||
|
||||
— ✦ —
|
||||
|
||||
🎙️ COLD OPEN
|
||||
[Co-host delivers the cold open solo, if there is one — hands off to the host at the end]
|
||||
A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing the host and the show.
|
||||
|
||||
— ✦ —
|
||||
|
||||
📖 SEGMENT [N] — [SEGMENT TITLE]
|
||||
[Brief stage direction in brackets]
|
||||
One segment per passage chunk (use the chunk reference, observation, interpretation, and application notes below as the raw material). Conversational, spoken-word style — not academic prose. Work through the text the way the host would talk it through out loud, weaving in the OIA notes and cross-references naturally.
|
||||
|
||||
— ✦ —
|
||||
|
||||
📖 SEGMENT [N] — GREEK WORD STUDY
|
||||
[Work through the key terms — keep it vivid, help the words come alive]
|
||||
For each Greek/Hebrew word collected below, a block in this format:
|
||||
|
||||
[English Gloss] — [English meaning]
|
||||
[DO NOT READ: original-language word (transliteration) — part of speech]
|
||||
2-4 sentences unpacking the word's meaning and why it matters for this passage, conversational tone.
|
||||
|
||||
— ✦ —
|
||||
|
||||
💬 DISCUSSION QUESTIONS
|
||||
[From the study guide — ${chapterLabel}]
|
||||
3 reflection questions drawn from the application notes, numbered.
|
||||
|
||||
— ✦ —
|
||||
|
||||
✦ CLOSING
|
||||
[Grounded and direct — send them away with something to carry]
|
||||
A closing reflection that ties the segments together, a short pull-quote from the passage with its reference, then close out. ${signOffLine}
|
||||
"Until next time — keep studying verse by verse."
|
||||
|
||||
— End of Episode —
|
||||
|
||||
${showLine} · ${episodeLabel} · ${chapterLabel}
|
||||
|
||||
---
|
||||
|
||||
Here are my study notes, organised by passage chunk, including my OIA notes, cross-references, and Greek/Hebrew word research:
|
||||
|
||||
PROJECT: ${project.title}
|
||||
TRANSLATION: ${project.translation}
|
||||
PASSAGE: ${chapterLabel}
|
||||
`;
|
||||
|
||||
return header + buildChunkBodies(project);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
// Parsers for typed cross-references and imported DOCX session lists.
|
||||
import {
|
||||
bookOptions,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23")
|
||||
export const BOOK_NAME_ALIASES = {
|
||||
'gen': 'GEN', 'genesis': 'GEN', 'exo': 'EXO', 'exod': 'EXO', 'exodus': 'EXO',
|
||||
'lev': 'LEV', 'leviticus': 'LEV', 'num': 'NUM', 'numbers': 'NUM',
|
||||
'deut': 'DEU', 'deuteronomy': 'DEU', 'josh': 'JOS', 'joshua': 'JOS',
|
||||
'judg': 'JDG', 'judges': 'JDG', 'ruth': 'RUT',
|
||||
'1 sam': '1SA', '1 samuel': '1SA', '2 sam': '2SA', '2 samuel': '2SA',
|
||||
'1 kgs': '1KI', '1 kings': '1KI', '2 kgs': '2KI', '2 kings': '2KI',
|
||||
'1 chr': '1CH', '1 chronicles': '1CH', '2 chr': '2CH', '2 chronicles': '2CH',
|
||||
'ezra': 'EZR', 'neh': 'NEH', 'nehemiah': 'NEH', 'esth': 'EST', 'esther': 'EST',
|
||||
'job': 'JOB', 'ps': 'PSA', 'psa': 'PSA', 'psalm': 'PSA', 'psalms': 'PSA',
|
||||
'prov': 'PRO', 'proverbs': 'PRO', 'eccl': 'ECC', 'ecclesiastes': 'ECC',
|
||||
'song': 'SNG', 'isa': 'ISA', 'isaiah': 'ISA', 'jer': 'JER', 'jeremiah': 'JER',
|
||||
'lam': 'LAM', 'lamentations': 'LAM', 'ezek': 'EZK', 'ezekiel': 'EZK',
|
||||
'dan': 'DAN', 'daniel': 'DAN', 'hos': 'HOS', 'hosea': 'HOS', 'joel': 'JOL',
|
||||
'amos': 'AMO', 'obad': 'OBA', 'obadiah': 'OBA', 'jonah': 'JON', 'mic': 'MIC',
|
||||
'micah': 'MIC', 'nah': 'NAM', 'nahum': 'NAM', 'hab': 'HAB', 'habakkuk': 'HAB',
|
||||
'zeph': 'ZEP', 'zephaniah': 'ZEP', 'hag': 'HAG', 'haggai': 'HAG',
|
||||
'zech': 'ZEC', 'zechariah': 'ZEC', 'mal': 'MAL', 'malachi': 'MAL',
|
||||
'matt': 'MAT', 'mt': 'MAT', 'matthew': 'MAT', 'mark': 'MRK', 'mk': 'MRK',
|
||||
'luke': 'LUK', 'lk': 'LUK', 'john': 'JHN', 'jn': 'JHN', 'acts': 'ACT',
|
||||
'rom': 'ROM', 'romans': 'ROM', '1 cor': '1CO', '1 corinthians': '1CO',
|
||||
'2 cor': '2CO', '2 corinthians': '2CO', 'gal': 'GAL', 'galatians': 'GAL',
|
||||
'eph': 'EPH', 'ephesians': 'EPH', 'phil': 'PHP', 'philippians': 'PHP',
|
||||
'col': 'COL', 'colossians': 'COL', '1 thess': '1TH', '1 thessalonians': '1TH',
|
||||
'2 thess': '2TH', '2 thessalonians': '2TH', '1 tim': '1TI', '1 timothy': '1TI',
|
||||
'2 tim': '2TI', '2 timothy': '2TI', 'titus': 'TIT', 'phlm': 'PHM', 'philemon': 'PHM',
|
||||
'heb': 'HEB', 'hebrews': 'HEB', 'jas': 'JAS', 'james': 'JAS',
|
||||
'1 pet': '1PE', '1 peter': '1PE', '2 pet': '2PE', '2 peter': '2PE',
|
||||
'1 jn': '1JN', '1 john': '1JN', '2 jn': '2JN', '2 john': '2JN',
|
||||
'3 jn': '3JN', '3 john': '3JN', 'jude': 'JUD', 'rev': 'REV', 'revelation': 'REV',
|
||||
};
|
||||
|
||||
bookOptions.forEach((b) => {
|
||||
BOOK_NAME_ALIASES[b.name.toLowerCase()] = b.abbrev;
|
||||
});
|
||||
|
||||
// Parses strings like "Romans 3:23", "1 Timothy 3:2-7" into { bookAbbrev, chapter, verse, endVerse }
|
||||
export function parseCrossRefString(ref) {
|
||||
const match = ref.trim().match(/^(.+?)\s+(\d+):(\d+)(?:-(\d+))?$/);
|
||||
if (!match) return null;
|
||||
const [, bookPart, chapter, verse, endVerse] = match;
|
||||
const abbrev = BOOK_NAME_ALIASES[bookPart.trim().toLowerCase()];
|
||||
if (!abbrev) return null;
|
||||
return {
|
||||
bookAbbrev: abbrev,
|
||||
chapter: Number(chapter),
|
||||
verse: Number(verse),
|
||||
endVerse: endVerse ? Number(endVerse) : Number(verse),
|
||||
};
|
||||
}
|
||||
|
||||
// Parses a "Passage" cell like "1:1–2", "9:32–10:23", "9:1–19a", or "—" (no specific passage)
|
||||
export function parseEpisodePassage(raw) {
|
||||
const str = (raw ?? '').trim().replace(/(\d)[a-z]\b/g, '$1'); // strip "19a"/"19b" verse-letter suffixes
|
||||
if (!str || /^[-–—]+$/.test(str)) return null;
|
||||
const match = str.match(/^(\d+):(\d+)\s*[-–—]\s*(?:(\d+):)?(\d+)$/)
|
||||
?? str.match(/^(\d+):(\d+)$/);
|
||||
if (!match) return 'invalid';
|
||||
const [, ch1, v1, ch2, v2raw] = match;
|
||||
const endChapter = ch2 ? Number(ch2) : Number(ch1);
|
||||
const endVerse = v2raw !== undefined ? Number(v2raw) : Number(v1);
|
||||
return {
|
||||
startChapter: Number(ch1),
|
||||
startVerse: Number(v1),
|
||||
endChapter,
|
||||
endVerse,
|
||||
};
|
||||
}
|
||||
|
||||
// Extracts a "Session # / Title / Passage" list from a docx, including both
|
||||
// table rows and standalone "Ep. N — Title" / "Session N — Title" paragraphs (e.g. part intros).
|
||||
export async function parseEpisodeListDocx(file) {
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const { default: mammoth } = await import('mammoth');
|
||||
const { value: html } = await mammoth.convertToHtml({ arrayBuffer });
|
||||
const parsedDoc = new DOMParser().parseFromString(html, 'text/html');
|
||||
|
||||
const episodes = new Map(); // episodeNumber -> { episodeNumber, title, passage }
|
||||
|
||||
parsedDoc.querySelectorAll('table tr').forEach((row) => {
|
||||
const cells = Array.from(row.querySelectorAll('td,th')).map((c) => c.textContent.trim());
|
||||
if (cells.length < 3) return;
|
||||
const [epRaw, title, passage] = cells;
|
||||
if (!/^\d+$/.test(epRaw)) return; // header row
|
||||
episodes.set(epRaw, { episodeNumber: epRaw, title, passage });
|
||||
});
|
||||
|
||||
parsedDoc.querySelectorAll('p').forEach((p) => {
|
||||
const match = p.textContent.trim().match(/^(?:Ep\.|Episode|Session)\s*(\d+)\s*[-–—]\s*(.+)$/i);
|
||||
if (!match) return;
|
||||
const [, epRaw, title] = match;
|
||||
if (!episodes.has(epRaw)) {
|
||||
episodes.set(epRaw, { episodeNumber: epRaw, title: title.trim(), passage: '—' });
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(episodes.values()).sort((a, b) => Number(a.episodeNumber) - Number(b.episodeNumber));
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Project persistence in localStorage, plus migrations from older data shapes.
|
||||
import {
|
||||
makeId,
|
||||
indexKey,
|
||||
projectKey,
|
||||
loadProjectIndex,
|
||||
} from './bibleUtils.jsx';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Migration helpers (exported for testing)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function migrateChunk(chunk) {
|
||||
if (chunk.observation !== undefined) {
|
||||
return {
|
||||
...chunk,
|
||||
spilloverEndVerse: Number.isInteger(chunk.spilloverEndVerse) ? chunk.spilloverEndVerse : null,
|
||||
generalNotes: chunk.generalNotes ?? '',
|
||||
episodeNumber: chunk.episodeNumber ?? '',
|
||||
episodeTitle: chunk.episodeTitle ?? '',
|
||||
finalScript: chunk.finalScript ?? '',
|
||||
tags: chunk.tags ?? [],
|
||||
}; // already new format
|
||||
}
|
||||
return {
|
||||
...chunk,
|
||||
observation: chunk.notes ?? '',
|
||||
interpretation: '',
|
||||
application: '',
|
||||
crossReferences: [],
|
||||
tags: [],
|
||||
spilloverEndVerse: null,
|
||||
generalNotes: '',
|
||||
episodeNumber: '',
|
||||
episodeTitle: '',
|
||||
finalScript: '',
|
||||
notes: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function migrateProject(raw) {
|
||||
if (!raw) return null;
|
||||
// Already new format
|
||||
if (Array.isArray(raw.chapters)) {
|
||||
return {
|
||||
...raw,
|
||||
chapters: raw.chapters.map((ch) => ({
|
||||
...ch,
|
||||
chunks: ch.chunks.map(migrateChunk),
|
||||
})),
|
||||
};
|
||||
}
|
||||
// Old flat format → wrap in chapters array
|
||||
const id = raw.id ?? makeId();
|
||||
return {
|
||||
id,
|
||||
title: raw.title ?? '',
|
||||
translation: raw.translation ?? 'BSB',
|
||||
lastEdited: raw.lastEdited ?? Date.now(),
|
||||
selectedChunkId: raw.selectedChunkId ?? null,
|
||||
chapters: [
|
||||
{
|
||||
book: raw.book ?? '',
|
||||
bookAbbrev: raw.bookAbbrev ?? '',
|
||||
chapter: raw.chapter ?? '1',
|
||||
verses: raw.verses ?? [],
|
||||
chunks: (raw.chunks ?? []).map(migrateChunk),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Storage helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function saveProjectToStorage(project) {
|
||||
// Honour an already-stamped lastEdited (e.g. from autosave) so localStorage
|
||||
// and the server always receive the identical timestamp.
|
||||
const updated = { ...project, lastEdited: project.lastEdited ?? Date.now() };
|
||||
window.localStorage.setItem(projectKey(updated.id), JSON.stringify(updated));
|
||||
const index = loadProjectIndex();
|
||||
const existing = index.findIndex((e) => e.id === updated.id);
|
||||
const tags = Array.from(
|
||||
new Set(
|
||||
(updated.chapters ?? [])
|
||||
.flatMap((ch) => ch.chunks ?? [])
|
||||
.flatMap((chunk) => chunk.tags ?? []),
|
||||
),
|
||||
);
|
||||
const summary = {
|
||||
id: updated.id,
|
||||
title: updated.title,
|
||||
lastEdited: updated.lastEdited,
|
||||
chapterSummary: buildChapterSummary(updated),
|
||||
tags,
|
||||
};
|
||||
if (existing >= 0) {
|
||||
index[existing] = summary;
|
||||
} else {
|
||||
index.push(summary);
|
||||
}
|
||||
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||
return updated; // caller can use this exact object for the server PUT
|
||||
}
|
||||
|
||||
export function deleteProjectFromStorage(id) {
|
||||
window.localStorage.removeItem(projectKey(id));
|
||||
const index = loadProjectIndex().filter((e) => e.id !== id);
|
||||
window.localStorage.setItem(indexKey(), JSON.stringify(index));
|
||||
}
|
||||
|
||||
export function loadProjectById(id) {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(projectKey(id));
|
||||
return raw ? migrateProject(JSON.parse(raw)) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateOldStorageKeys() {
|
||||
const keys = Object.keys(window.localStorage);
|
||||
keys.forEach((key) => {
|
||||
if (!key.startsWith('bible-study-') || key.startsWith('bible-study-index') || key.startsWith('bible-study-project')) return;
|
||||
try {
|
||||
const raw = JSON.parse(window.localStorage.getItem(key));
|
||||
if (!raw || !raw.book) return;
|
||||
const migrated = migrateProject(raw);
|
||||
if (!migrated) return;
|
||||
saveProjectToStorage(migrated);
|
||||
window.localStorage.removeItem(key);
|
||||
} catch {
|
||||
// skip corrupt entries
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function buildChapterSummary(project) {
|
||||
if (!Array.isArray(project.chapters)) return '';
|
||||
return project.chapters
|
||||
.map((ch) => `${ch.book} ${ch.chapter}`)
|
||||
.join(', ');
|
||||
}
|
||||
Reference in new issue
Block a user