import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import DOMPurify from 'dompurify'; import mammoth from 'mammoth'; import { AlignmentType, BorderStyle, Document, HeadingLevel, Packer, Paragraph, ShadingType, Table, TableCell, TableRow, TextRun, WidthType, } from 'docx'; import { saveRemoteProject, deleteRemoteProject, listRemoteProjects, loadRemoteProject, } from './syncService.js'; const COMMENTARY_OPTIONS = [ { id: 'matthew-henry', name: 'Matthew Henry' }, { id: 'john-gill', name: 'John Gill' }, { id: 'jamieson-fausset-brown', name: 'Jamieson-Fausset-Brown' }, { id: 'adam-clarke', name: 'Adam Clarke' }, { id: 'keil-delitzsch', name: 'Keil & Delitzsch (OT)' }, { id: 'tyndale', name: 'Tyndale Open Study Notes' }, ]; const STUDY_TABS = [ { id: 'notes', label: 'Notes' }, { id: 'crossRefs', label: 'Cross-Refs' }, { id: 'wordStudy', label: 'Word Study' }, { id: 'commentary', label: 'Commentary' }, { id: 'script', label: 'Script' }, ]; const bookOptions = [ { name: 'Genesis', abbrev: 'GEN' }, { name: 'Exodus', abbrev: 'EXO' }, { name: 'Leviticus', abbrev: 'LEV' }, { name: 'Numbers', abbrev: 'NUM' }, { name: 'Deuteronomy', abbrev: 'DEU' }, { name: 'Joshua', abbrev: 'JOS' }, { name: 'Judges', abbrev: 'JDG' }, { name: 'Ruth', abbrev: 'RUT' }, { name: '1 Samuel', abbrev: '1SA' }, { name: '2 Samuel', abbrev: '2SA' }, { name: '1 Kings', abbrev: '1KI' }, { name: '2 Kings', abbrev: '2KI' }, { name: '1 Chronicles', abbrev: '1CH' }, { name: '2 Chronicles', abbrev: '2CH' }, { name: 'Ezra', abbrev: 'EZR' }, { name: 'Nehemiah', abbrev: 'NEH' }, { name: 'Esther', abbrev: 'EST' }, { name: 'Job', abbrev: 'JOB' }, { name: 'Psalms', abbrev: 'PSA' }, { name: 'Proverbs', abbrev: 'PRO' }, { name: 'Ecclesiastes', abbrev: 'ECC' }, { name: 'Song', abbrev: 'SNG' }, { name: 'Isaiah', abbrev: 'ISA' }, { name: 'Jeremiah', abbrev: 'JER' }, { name: 'Lamentations', abbrev: 'LAM' }, { name: 'Ezekiel', abbrev: 'EZK' }, { name: 'Daniel', abbrev: 'DAN' }, { name: 'Hosea', abbrev: 'HOS' }, { name: 'Joel', abbrev: 'JOL' }, { name: 'Amos', abbrev: 'AMO' }, { name: 'Obadiah', abbrev: 'OBA' }, { name: 'Jonah', abbrev: 'JON' }, { name: 'Micah', abbrev: 'MIC' }, { name: 'Nahum', abbrev: 'NAM' }, { name: 'Habakkuk', abbrev: 'HAB' }, { name: 'Zephaniah', abbrev: 'ZEP' }, { name: 'Haggai', abbrev: 'HAG' }, { name: 'Zechariah', abbrev: 'ZEC' }, { name: 'Malachi', abbrev: 'MAL' }, { name: 'Matthew', abbrev: 'MAT' }, { name: 'Mark', abbrev: 'MRK' }, { name: 'Luke', abbrev: 'LUK' }, { name: 'John', abbrev: 'JHN' }, { name: 'Acts', abbrev: 'ACT' }, { name: 'Romans', abbrev: 'ROM' }, { name: '1 Corinthians', abbrev: '1CO' }, { name: '2 Corinthians', abbrev: '2CO' }, { name: 'Galatians', abbrev: 'GAL' }, { name: 'Ephesians', abbrev: 'EPH' }, { name: 'Philippians', abbrev: 'PHP' }, { name: 'Colossians', abbrev: 'COL' }, { name: '1 Thessalonians', abbrev: '1TH' }, { name: '2 Thessalonians', abbrev: '2TH' }, { name: '1 Timothy', abbrev: '1TI' }, { name: '2 Timothy', abbrev: '2TI' }, { name: 'Titus', abbrev: 'TIT' }, { name: 'Philemon', abbrev: 'PHM' }, { name: 'Hebrews', abbrev: 'HEB' }, { name: 'James', abbrev: 'JAS' }, { name: '1 Peter', abbrev: '1PE' }, { name: '2 Peter', abbrev: '2PE' }, { name: '1 John', abbrev: '1JN' }, { name: '2 John', abbrev: '2JN' }, { name: '3 John', abbrev: '3JN' }, { name: 'Jude', abbrev: 'JUD' }, { name: 'Revelation', abbrev: 'REV' }, ]; const NT_BOOK_NUMBER = { MAT: 40, MRK: 41, LUK: 42, JHN: 43, ACT: 44, ROM: 45, '1CO': 46, '2CO': 47, GAL: 48, EPH: 49, PHP: 50, COL: 51, '1TH': 52, '2TH': 53, '1TI': 54, '2TI': 55, TIT: 56, PHM: 57, HEB: 58, JAS: 59, '1PE': 60, '2PE': 61, '1JN': 62, '2JN': 63, '3JN': 64, JUD: 65, REV: 66, }; // --------------------------------------------------------------------------- // Stable utility exports (used by tests and other modules) // --------------------------------------------------------------------------- export const storageKey = (translation, book, chapter) => `bible-study-${translation}-${book}-${chapter}`; export const makeId = () => crypto.randomUUID?.() ?? `${Date.now()}-${Math.random()}`; // --------------------------------------------------------------------------- // 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, }; } function chunkSpansNextChapter(project, startChapterIndex, chunk) { if (!project || !Array.isArray(project.chapters)) return false; if (!Number.isInteger(chunk?.spilloverEndVerse)) return false; if (startChapterIndex < 0 || startChapterIndex >= project.chapters.length - 1) return false; const startChapter = project.chapters[startChapterIndex]; const nextChapter = project.chapters[startChapterIndex + 1]; return startChapter?.bookAbbrev && startChapter.bookAbbrev === nextChapter?.bookAbbrev; } function formatChunkReference(project, startChapterIndex, chunk, separator = '-') { if (!project || !chunk) return ''; const startChapter = project.chapters?.[startChapterIndex]; if (!startChapter) return ''; if (chunkSpansNextChapter(project, startChapterIndex, chunk)) { const nextChapter = project.chapters[startChapterIndex + 1]; return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${nextChapter.chapter}:${chunk.spilloverEndVerse}`; } if (chunk.startVerse === chunk.endVerse) { return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}`; } return `${startChapter.book} ${startChapter.chapter}:${chunk.startVerse}${separator}${chunk.endVerse}`; } function getChunkVerseEntries(project, startChapterIndex, chunk) { if (!project || !chunk) return []; const startChapter = project.chapters?.[startChapterIndex]; if (!startChapter) return []; const startVerses = (startChapter.verses ?? []) .filter((verse) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse) .map((verse) => ({ book: startChapter.book, chapter: startChapter.chapter, ...verse, })); if (!chunkSpansNextChapter(project, startChapterIndex, chunk)) { return startVerses; } const nextChapter = project.chapters[startChapterIndex + 1]; const spilloverVerses = (nextChapter.verses ?? []) .filter((verse) => verse.number >= 1 && verse.number <= chunk.spilloverEndVerse) .map((verse) => ({ book: nextChapter.book, chapter: nextChapter.chapter, ...verse, })); return [...startVerses, ...spilloverVerses]; } 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 // --------------------------------------------------------------------------- const INDEX_KEY = 'bible-study-index'; const projectKey = (id) => `bible-study-project-${id}`; function loadProjectIndex() { try { const raw = window.localStorage.getItem(INDEX_KEY); return raw ? JSON.parse(raw) : []; } catch { return []; } } 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(INDEX_KEY, JSON.stringify(index)); return updated; // caller can use this exact object for the server PUT } function deleteProjectFromStorage(id) { window.localStorage.removeItem(projectKey(id)); const index = loadProjectIndex().filter((e) => e.id !== id); window.localStorage.setItem(INDEX_KEY, JSON.stringify(index)); } function loadProjectById(id) { try { const raw = window.localStorage.getItem(projectKey(id)); return raw ? migrateProject(JSON.parse(raw)) : null; } catch { return null; } } function migrateOldStorageKeys() { const keys = Object.keys(window.localStorage); keys.forEach((key) => { if (!key.startsWith('bible-study-') || key === INDEX_KEY || 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 } }); } function buildChapterSummary(project) { if (!Array.isArray(project.chapters)) return ''; return project.chapters .map((ch) => `${ch.book} ${ch.chapter}`) .join(', '); } function formatRelativeDate(ts) { if (!ts) return ''; const diff = Date.now() - ts; const mins = Math.floor(diff / 60000); if (mins < 1) return 'just now'; if (mins < 60) return `${mins}m ago`; const hours = Math.floor(mins / 60); if (hours < 24) return `${hours}h ago`; const days = Math.floor(hours / 24); return `${days}d ago`; } // --------------------------------------------------------------------------- // Export / prompt builders (exported for testing) // --------------------------------------------------------------------------- export function buildExportHtml(project) { const style = ` body { font-family: Georgia, serif; color: #0f172a; margin: 0; padding: 32px; } .page { max-width: 900px; margin: auto; } h1, h2 { font-family: Georgia, serif; } h1 { margin-bottom: 0.5rem; } .chapter-heading { margin: 2rem 0 0.5rem; font-size: 1.2rem; font-weight: 700; border-bottom: 1px solid #cbd5e1; padding-bottom: 0.25rem; } .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 { 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; } .greek th { background: #f8fafc; } .definition { margin-top: 0.75rem; font-size: 0.95rem; line-height: 1.6; } .definition-block { margin-top: 1rem; padding: 1rem; border: 1px solid #e2e8f0; border-radius: 0.75rem; background: #f8fafc; } `; const chapters = Array.isArray(project.chapters) ? project.chapters : []; const chunksHtml = chapters.map((ch, chapterIndex) => { const chapterHeader = `

${ch.book} ${ch.chapter}

`; 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(''); const observation = (chunk.observation ?? '').trim().replace(/\n/g, '
') || 'No observation.'; const interpretation = (chunk.interpretation ?? '').trim().replace(/\n/g, '
') || 'No interpretation.'; const application = (chunk.application ?? '').trim().replace(/\n/g, '
') || 'No application.'; const crossRefsHtml = (chunk.crossReferences ?? []).length > 0 ? `
CROSS-REFERENCES: ${chunk.crossReferences.join(', ')}
` : ''; const greekRows = chunk.greekWords.map((word) => ` ${word.strongNumber} ${word.lexeme || ''} ${word.transliteration || ''} ${word.partOfSpeech || ''} ${word.shortDefinition || ''} `).join(''); const greekTable = wordTableHtml(greekRows); const extendedDefinitions = chunk.greekWords .map((word) => { if (word.definitionHtml) { return `

${word.strongNumber} — ${word.lexeme || ''}

${word.definitionHtml}
`; } if ((word.shortDefinition === 'No definition found.' || word.shortDefinition === 'Lookup failed.') && !word.lexeme && !word.definitionHtml && word.query) { const raw = word.query.trim(); const normalized = /^\d+$/.test(raw) ? `G${raw}` : raw.toUpperCase(); const isStrongs = /^G\d+$/.test(normalized); const num = isStrongs ? normalized.slice(1) : null; const links = isStrongs && num ? `BibleHub · Blue Letter Bible · StudyLight` : `BibleHub · Blue Letter Bible`; return `

${raw} — definition not found in auto-lookup

Look it up manually: ${links}

`; } return ''; }) .join(''); return `
${scripture}
${versesText}
OBSERVATION:

${observation}

INTERPRETATION:

${interpretation}

APPLICATION:

${application}

${crossRefsHtml}
GREEK WORDS: ${greekRows ? greekTable : '

No Greek word notes.

'} ${extendedDefinitions}
`; }).join(''); return chapterHeader + chunkSections; }).join(''); return ` ${project.title}

${project.title}

${project.translation}

${chunksHtml}
`; } export function wordTableHtml(rows) { return ` ${rows}
Strong's Greek Transliteration Part of Speech Short Definition
`; } export function extractPartOfSpeech(definitionHtml) { const match = /Part\(s\) of speech:\s*([^<]+)/i.exec(definitionHtml || ''); if (match && match[1]) { return match[1].trim(); } return ''; } export function htmlToPlainText(html = '') { const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); const lines = []; doc.body.childNodes.forEach((node) => { if (node.nodeType === Node.TEXT_NODE) { lines.push(node.textContent || ''); return; } if (node.nodeName === 'BR' || node.nodeName === 'P') { lines.push(node.textContent || ''); lines.push('\n'); return; } lines.push(node.textContent || ''); }); return lines.join('').replace(/\n{2,}/g, '\n').trim(); } 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 function renderVerseContent(content) { if (typeof content === 'string') { return content; } if (Array.isArray(content)) { return content.map(renderVerseContent).join(''); } if (content && typeof content === 'object') { if (content.lineBreak) { return '\n'; } if (content.type === 'line_break') { return '\n'; } if (Array.isArray(content.content)) { return renderVerseContent(content.content); } return ''; } return ''; } 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() ? `Episode: ${[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 buildPronunciationGuide(project) { const chapters = Array.isArray(project.chapters) ? project.chapters : []; const lines = []; chapters.forEach((ch) => { ch.chunks.forEach((chunk) => { (chunk.greekWords ?? []).forEach((word) => { const gloss = word.shortDefinition || word.englishGloss || ''; const lexeme = word.lexeme || ''; const translit = word.transliteration || ''; if (!lexeme && !translit) return; const label = gloss ? `${gloss} — ` : ''; lines.push(`${label}${lexeme}${translit ? ` (${translit})` : ''}`); }); }); }); if (lines.length === 0) return 'No Greek/Hebrew words collected yet.'; return lines.join('\n'); } export function buildPodcastPrompt(project) { const chapters = Array.isArray(project.chapters) ? project.chapters : []; const chapterLabel = chapters.map((ch) => `${ch.book} ${ch.chapter}`).join(', '); const episodeLabel = 'EPISODE'; const header = `I'm recording an episode of my Bible study podcast "Verse by Verse with Nate: A Journey Through Scripture" 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: VERSE BY VERSE WITH NATE A Journey Through Scripture ${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 — hands off to Nate at the end] A few short punchy lines previewing the passage and its hook, ending with a hand-off line introducing Nate 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 Nate 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 sign off with: "I'm Nate, and this is Verse by Verse with Nate: A Journey Through Scripture." "Until next time — keep studying verse by verse, and nugget by nugget." — End of Episode — Verse by Verse with Nate · ${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); } export function parseBibleChapter(data) { if (data?.verses && Array.isArray(data.verses)) { return data.verses.map((verse) => ({ number: verse.number, text: verse.text, })); } const chapterContents = data?.chapter?.content; if (!Array.isArray(chapterContents)) { return []; } return chapterContents .filter((item) => item?.type === 'verse') .map((item) => ({ number: item.number, text: renderVerseContent(item.content).replace(/\s+/g, ' ').trim(), })); } // Common short-form aliases for typed-in cross-references (e.g. "Rom 3:23") 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 } 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), }; } // A cross-reference chip that fetches and shows the referenced verse text on hover function CrossRefChip({ label, onRemove, loadVerseText }) { const [hovered, setHovered] = useState(false); const [verseText, setVerseText] = useState(null); const [error, setError] = useState(''); useEffect(() => { if (!hovered || verseText || error) return; let cancelled = false; loadVerseText(label) .then((text) => { if (!cancelled) setVerseText(text || 'Verse text unavailable.'); }) .catch(() => { if (!cancelled) setError('Verse text unavailable.'); }); return () => { cancelled = true; }; }, [hovered]); return ( setHovered(true)} onMouseLeave={() => setHovered(false)} > {label} {onRemove && ( )} {hovered && ( {error || verseText || 'Loading...'} )} ); } // --------------------------------------------------------------------------- // App component // --------------------------------------------------------------------------- const App = () => { const [availableTranslations, setAvailableTranslations] = useState(['BSB']); const [setup, setSetup] = useState({ translation: 'BSB', book: 'Titus', bookAbbrev: 'TIT', chapter: '1', title: 'Titus 1 Study', }); const [titleEdited, setTitleEdited] = useState(false); const [project, setProject] = useState(null); // 'home' | 'setup' | 'study' const [currentPage, setCurrentPage] = useState('home'); const [projectIndex, setProjectIndex] = useState([]); const [activeChapterIndex, setActiveChapterIndex] = useState(0); const [showAddChapterForm, setShowAddChapterForm] = useState(false); const [crossRefInput, setCrossRefInput] = useState(''); const [saveStatus, setSaveStatus] = useState(''); const [rangeStart, setRangeStart] = useState(null); const [rangeEnd, setRangeEnd] = useState(null); const [loadingChapter, setLoadingChapter] = useState(false); const [errorMessage, setErrorMessage] = useState(''); const [statusMessage, setStatusMessage] = useState(''); const [typedChunkStart, setTypedChunkStart] = useState(''); const [typedChunkEnd, setTypedChunkEnd] = useState(''); const [typedChunkNextEnd, setTypedChunkNextEnd] = useState(''); const [clickedSpanNextEnd, setClickedSpanNextEnd] = useState(''); const [typedChunkBulk, setTypedChunkBulk] = useState(''); const saveTimerRef = useRef(null); const [syncStatus, setSyncStatus] = useState(''); // '' | 'syncing' | 'synced' | 'error' const [remoteOnlyProjects, setRemoteOnlyProjects] = useState([]); // projects on server not in localStorage const [staleLocalProjects, setStaleLocalProjects] = useState([]); // projects where server is newer const [suggestingGreekForChunkId, setSuggestingGreekForChunkId] = useState(null); const [suggestingHebrewForChunkId, setSuggestingHebrewForChunkId] = useState(null); // suggestModal: null | { chunkId, words: [{ strongKey, lexeme, translit, def }] } const [suggestModal, setSuggestModal] = useState(null); const [suggestSelection, setSuggestSelection] = useState(new Set()); const [homeSearch, setHomeSearch] = useState(''); const [homeSort, setHomeSort] = useState('recent'); // 'recent' | 'title' | 'passage' const [homeTagFilter, setHomeTagFilter] = useState(''); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const [studyLayout, setStudyLayout] = useState( () => localStorage.getItem('studyLayout') || 'stacked', ); // 'stacked' | 'split' const [activeStudyTab, setActiveStudyTab] = useState( () => localStorage.getItem('activeStudyTab') || 'notes', ); useEffect(() => { localStorage.setItem('studyLayout', studyLayout); }, [studyLayout]); useEffect(() => { localStorage.setItem('activeStudyTab', activeStudyTab); }, [activeStudyTab]); const [verseSearch, setVerseSearch] = useState(''); const [collapsedSections, setCollapsedSections] = useState({}); const [commentarySource, setCommentarySource] = useState('matthew-henry'); const [commentaryData, setCommentaryData] = useState(null); // { content: [...] } for current chapter const [commentaryLoading, setCommentaryLoading] = useState(false); const [commentaryError, setCommentaryError] = useState(''); const _commentaryCacheRef = useRef({}); const [interlinearData, setInterlinearData] = useState(null); const [interlinearLoading, setInterlinearLoading] = useState(false); const [interlinearError, setInterlinearError] = useState(''); const _interlinearCacheRef = useRef({}); const loadInterlinearChapter = async (bookAbbrev, chapterNumber) => { if (_interlinearCacheRef.current[bookAbbrev]) return _interlinearCacheRef.current[bookAbbrev]; const res = await fetch(`/interlinear/${bookAbbrev}.json`); if (!res.ok) throw new Error('No interlinear data for this book.'); const data = await res.json(); _interlinearCacheRef.current[bookAbbrev] = data; return data; }; const loadCommentaryChapter = async (commentaryId, bookAbbrev, chapterNumber) => { const cacheKey = `${commentaryId}/${bookAbbrev}/${chapterNumber}`; if (_commentaryCacheRef.current[cacheKey]) return _commentaryCacheRef.current[cacheKey]; const res = await fetch(`https://bible.helloao.org/api/c/${commentaryId}/${bookAbbrev}/${chapterNumber}.json`); if (!res.ok) throw new Error(`No ${commentaryId} commentary for this chapter.`); const data = await res.json(); _commentaryCacheRef.current[cacheKey] = data; return data; }; // --------------------------------------------------------------------------- // Startup: migrate old keys and load index // --------------------------------------------------------------------------- useEffect(() => { migrateOldStorageKeys(); const localIndex = loadProjectIndex(); setProjectIndex(localIndex); listRemoteProjects().then((result) => { if (!result.ok) return; const localMap = new Map(localIndex.map((e) => [e.id, e])); const missing = result.data.filter((e) => !localMap.has(e.id)); if (missing.length > 0) setRemoteOnlyProjects(missing); const stale = result.data.filter((e) => { const local = localMap.get(e.id); return local && (e.lastEdited ?? 0) > (local.lastEdited ?? 0); }); if (stale.length > 0) setStaleLocalProjects(stale); }); }, []); 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) => ({ ...current, title: `${current.book} ${current.chapter} Study`, })); } }, [setup.book, setup.chapter, titleEdited]); // Reset cross-ref input when selected chunk changes const allChunks = project ? project.chapters.flatMap((ch) => ch.chunks) : []; const activeChapter = project?.chapters[activeChapterIndex] ?? null; const selectedChunk = allChunks.find((c) => c.id === project?.selectedChunkId) ?? allChunks[0] ?? null; const selectedChunkChapterIndex = selectedChunk ? project.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === selectedChunk.id)) : -1; useEffect(() => { if (collapsedSections.commentary) return; if (selectedChunkChapterIndex < 0) return; const chapter = project.chapters[selectedChunkChapterIndex]; setCommentaryLoading(true); setCommentaryError(''); loadCommentaryChapter(commentarySource, chapter.bookAbbrev, chapter.chapter) .then((data) => setCommentaryData(data)) .catch((err) => { setCommentaryData(null); setCommentaryError(err.message); }) .finally(() => setCommentaryLoading(false)); }, [commentarySource, selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.commentary]); useEffect(() => { if (collapsedSections.interlinear) return; if (selectedChunkChapterIndex < 0) return; const chapter = project.chapters[selectedChunkChapterIndex]; setInterlinearLoading(true); setInterlinearError(''); loadInterlinearChapter(chapter.bookAbbrev, chapter.chapter) .then((data) => setInterlinearData(data?.[String(chapter.chapter)] ?? null)) .catch((err) => { setInterlinearData(null); setInterlinearError(err.message); }) .finally(() => setInterlinearLoading(false)); }, [selectedChunk?.id, selectedChunkChapterIndex, collapsedSections.interlinear]); useEffect(() => { setCrossRefSuggestions([]); setTagInput(''); }, [selectedChunk?.id]); const selectedChunkChapter = selectedChunkChapterIndex >= 0 ? project.chapters[selectedChunkChapterIndex] : null; const selectedChunkVerses = selectedChunk ? getChunkVerseEntries(project, selectedChunkChapterIndex, selectedChunk) : []; const selectedChunkGlobalIndex = allChunks.findIndex((c) => c.id === project?.selectedChunkId); useEffect(() => { setCrossRefInput(''); }, [project?.selectedChunkId]); // Keyboard shortcuts: arrow keys navigate chunks on the study page, Esc closes modals useEffect(() => { const handleKeyDown = (event) => { const tag = event.target?.tagName; const isEditable = tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || event.target?.isContentEditable; if (event.key === 'Escape') { if (suggestModal) { setSuggestModal(null); setSuggestSelection(new Set()); } return; } if (currentPage !== 'study' || isEditable || suggestModal) return; if (event.key === 'ArrowLeft') { event.preventDefault(); goToPreviousChunk(); } else if (event.key === 'ArrowRight') { event.preventDefault(); goToNextChunk(); } }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [currentPage, suggestModal, selectedChunkGlobalIndex, allChunks]); // Autosave useEffect(() => { if (!project) return; if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); saveTimerRef.current = window.setTimeout(async () => { // Stamp lastEdited once so localStorage and server receive the same value const toSave = { ...project, lastEdited: Date.now() }; // 1. Save locally saveProjectToStorage(toSave); setProjectIndex(loadProjectIndex()); setSaveStatus('Saved'); window.setTimeout(() => setSaveStatus(''), 1400); // 2. Sync to server with the same timestamped object setSyncStatus('syncing'); const result = await saveRemoteProject(toSave); setSyncStatus(result.ok ? 'synced' : 'error'); // On success clear after 2.5s; on error keep visible until next successful sync if (result.ok) window.setTimeout(() => setSyncStatus(''), 2500); }, 1000); return () => { if (saveTimerRef.current) window.clearTimeout(saveTimerRef.current); }; }, [project]); // --------------------------------------------------------------------------- // Setup helpers // --------------------------------------------------------------------------- const handleSetupField = (field, value) => { if (field === 'book') { const selected = bookOptions.find((book) => book.abbrev === value) ?? bookOptions[0]; setSetup((current) => ({ ...current, book: selected.name, bookAbbrev: selected.abbrev, })); } else { setSetup((current) => ({ ...current, [field]: value })); } }; const handleLoadChapter = async () => { if (!setup.bookAbbrev || !setup.chapter) { setErrorMessage('Please choose a book and chapter.'); return; } setErrorMessage(''); setLoadingChapter(true); try { const response = await fetch( `https://bible.helloao.org/api/${setup.translation}/${setup.bookAbbrev}/${setup.chapter}.json`, ); if (!response.ok) throw new Error('Unable to load chapter.'); const data = await response.json(); const verses = parseBibleChapter(data); if (!data || !Array.isArray(verses) || verses.length === 0) { throw new Error('Invalid Bible data returned.'); } if (project) { // Adding a chapter to an existing project const alreadyExists = project.chapters.some( (ch) => ch.bookAbbrev === setup.bookAbbrev && ch.chapter === setup.chapter, ); if (alreadyExists) { setErrorMessage('That chapter is already in this project.'); setLoadingChapter(false); return; } const newChapter = { book: setup.book, bookAbbrev: setup.bookAbbrev, chapter: setup.chapter, verses, chunks: [], }; updateProject((current) => { const updatedChapters = [...current.chapters, newChapter]; return { ...current, chapters: updatedChapters }; }); setActiveChapterIndex(project.chapters.length); setShowAddChapterForm(false); setErrorMessage(''); } else { // Creating a brand-new project const newProject = { id: makeId(), title: setup.title, translation: setup.translation, lastEdited: Date.now(), selectedChunkId: null, chapters: [ { book: setup.book, bookAbbrev: setup.bookAbbrev, chapter: setup.chapter, verses, chunks: [], }, ], }; setProject(newProject); setActiveChapterIndex(0); setCurrentPage('setup'); } } catch (error) { setErrorMessage(error.message || 'Failed to load chapter.'); } finally { setLoadingChapter(false); } }; const updateProject = (updater) => { setProject((current) => (current ? updater(current) : current)); }; const beginStudying = () => { if (!project || allChunks.length === 0) return; updateProject((current) => ({ ...current, selectedChunkId: current.selectedChunkId || allChunks[0]?.id || null, })); setCurrentPage('study'); }; // --------------------------------------------------------------------------- // Chunk navigation // --------------------------------------------------------------------------- const goToPreviousChunk = () => { if (selectedChunkGlobalIndex <= 0) return; updateProject((current) => ({ ...current, selectedChunkId: allChunks[selectedChunkGlobalIndex - 1].id, })); }; const goToNextChunk = () => { if (selectedChunkGlobalIndex < 0 || selectedChunkGlobalIndex >= allChunks.length - 1) return; updateProject((current) => ({ ...current, selectedChunkId: allChunks[selectedChunkGlobalIndex + 1].id, })); }; // --------------------------------------------------------------------------- // Chunk CRUD (operates on activeChapter in setup view, on selectedChunkChapter in study) // --------------------------------------------------------------------------- const addChunkToChapter = (chapterIndex, start, end, spilloverEndVerse = null) => { updateProject((current) => { const chapters = current.chapters.map((ch, idx) => { if (idx !== chapterIndex) return ch; const overlap = ch.chunks.find( (c) => c.startVerse === start && c.endVerse === end && (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null), ); if (overlap) return ch; const newChunk = { id: makeId(), startVerse: start, endVerse: end, spilloverEndVerse, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], generalNotes: '', episodeNumber: '', episodeTitle: '', finalScript: '', tags: [], }; return { ...ch, chunks: [...ch.chunks, newChunk] }; }); // Find the newly added chunk id const newChunk = chapters[chapterIndex].chunks.at(-1); return { ...current, chapters, selectedChunkId: newChunk?.id ?? current.selectedChunkId }; }); }; const addChunk = (start, end, spilloverEndVerse = null) => { if (!project) return; const chapter = activeChapter; if (!chapter) return; const overlap = chapter.chunks.find( (c) => c.startVerse === start && c.endVerse === end && (c.spilloverEndVerse ?? null) === (spilloverEndVerse ?? null), ); if (overlap) { setStatusMessage('That chunk already exists.'); window.setTimeout(() => setStatusMessage(''), 1800); return; } addChunkToChapter(activeChapterIndex, start, end, spilloverEndVerse); }; const addChunkRanges = (ranges) => { if (!project || !activeChapter || ranges.length === 0) { return { addedCount: 0, skippedCount: 0 }; } let addedCount = 0; let skippedCount = 0; let lastAddedChunkId = null; updateProject((current) => { const chapters = current.chapters.map((ch, idx) => { if (idx !== activeChapterIndex) return ch; const existing = new Set( ch.chunks.map((c) => `${c.startVerse}-${c.endVerse}-${c.spilloverEndVerse ?? ''}`), ); const newChunks = []; ranges.forEach(({ start, end, spilloverEndVerse = null }) => { const key = `${start}-${end}-${spilloverEndVerse ?? ''}`; if (existing.has(key)) { skippedCount += 1; return; } const newChunk = { id: makeId(), startVerse: start, endVerse: end, spilloverEndVerse, observation: '', interpretation: '', application: '', crossReferences: [], greekWords: [], tags: [], }; existing.add(key); newChunks.push(newChunk); addedCount += 1; lastAddedChunkId = newChunk.id; }); return newChunks.length > 0 ? { ...ch, chunks: [...ch.chunks, ...newChunks] } : ch; }); return { ...current, chapters, selectedChunkId: lastAddedChunkId ?? current.selectedChunkId, }; }); return { addedCount, skippedCount }; }; const addTypedChunk = () => { if (!activeChapter) return; const start = Number.parseInt(typedChunkStart, 10); const end = Number.parseInt(typedChunkEnd, 10); const nextEndRaw = typedChunkNextEnd.trim(); const nextEnd = nextEndRaw ? Number.parseInt(nextEndRaw, 10) : null; const maxVerse = activeChapter.verses.at(-1)?.number ?? 0; const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0; if (!Number.isInteger(start) || !Number.isInteger(end)) { setStatusMessage('Type a valid start and end verse.'); window.setTimeout(() => setStatusMessage(''), 2200); return; } if (start < 1 || end < 1 || start > maxVerse || end > maxVerse) { setStatusMessage(`Verse range must be between 1 and ${maxVerse}.`); window.setTimeout(() => setStatusMessage(''), 2200); return; } if (start > end) { setStatusMessage('Start verse must be less than or equal to end verse.'); window.setTimeout(() => setStatusMessage(''), 2200); return; } if (nextEndRaw) { if (!nextChapter) { setStatusMessage('Add the next chapter first to span chunks across chapters.'); window.setTimeout(() => setStatusMessage(''), 2400); return; } if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) { setStatusMessage('Chapter spanning only works into the next chapter of the same book.'); window.setTimeout(() => setStatusMessage(''), 2400); return; } if (!Number.isInteger(nextEnd) || nextEnd < 1 || nextEnd > nextMaxVerse) { setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`); window.setTimeout(() => setStatusMessage(''), 2400); return; } if (end !== maxVerse) { setStatusMessage('To span chapters, set End to the last verse of this chapter.'); window.setTimeout(() => setStatusMessage(''), 2400); return; } } const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse: nextEnd }]); if (addedCount === 0) { setStatusMessage('That chunk already exists.'); window.setTimeout(() => setStatusMessage(''), 2200); return; } setTypedChunkStart(''); setTypedChunkEnd(''); setTypedChunkNextEnd(''); }; const addBulkTypedChunks = () => { if (!activeChapter) return; const raw = typedChunkBulk.trim(); if (!raw) { setStatusMessage('Type chunk ranges first. Example: 1-6, 7-12'); window.setTimeout(() => setStatusMessage(''), 2200); return; } const maxVerse = activeChapter.verses.at(-1)?.number ?? 0; const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; const nextMaxVerse = nextChapter?.verses?.at(-1)?.number ?? 0; const parts = raw .split(/[\n,;]+/) .map((part) => part.trim()) .filter(Boolean); const validRanges = []; let invalidCount = 0; parts.forEach((part) => { const single = /^(\d+)$/.exec(part); if (single) { const v = Number.parseInt(single[1], 10); if (v >= 1 && v <= maxVerse) { validRanges.push({ start: v, end: v }); } else { invalidCount += 1; } return; } const range = /^(\d+)\s*-\s*(\d+)(?:\s*:\s*(\d+))?$/.exec(part); if (!range) { invalidCount += 1; return; } const start = Number.parseInt(range[1], 10); const end = Number.parseInt(range[2], 10); const spilloverEndVerse = range[3] ? Number.parseInt(range[3], 10) : null; if (start < 1 || end < 1 || start > end || start > maxVerse || end > maxVerse) { invalidCount += 1; return; } if (spilloverEndVerse !== null) { if (!nextChapter || nextChapter.bookAbbrev !== activeChapter.bookAbbrev || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse || end !== maxVerse) { invalidCount += 1; return; } } validRanges.push({ start, end, spilloverEndVerse }); }); if (validRanges.length === 0) { setStatusMessage(`No valid ranges found. Use 1-${maxVerse} and format like 1-6, 7-12.`); window.setTimeout(() => setStatusMessage(''), 2600); return; } const { addedCount, skippedCount } = addChunkRanges(validRanges); const notes = []; if (addedCount > 0) notes.push(`Added ${addedCount} chunk${addedCount > 1 ? 's' : ''}`); if (skippedCount > 0) notes.push(`skipped ${skippedCount} duplicate${skippedCount > 1 ? 's' : ''}`); if (invalidCount > 0) notes.push(`ignored ${invalidCount} invalid`); setStatusMessage(notes.join(' • ')); window.setTimeout(() => setStatusMessage(''), 2800); if (addedCount > 0) { setTypedChunkBulk(''); } }; const addClickSpanChunk = () => { if (!activeChapter || rangeStart === null) { setStatusMessage('Click a start verse first, then set next chapter end.'); window.setTimeout(() => setStatusMessage(''), 2400); return; } const nextChapter = project?.chapters?.[activeChapterIndex + 1] ?? null; if (!nextChapter) { setStatusMessage('Add the next chapter first to span into it.'); window.setTimeout(() => setStatusMessage(''), 2400); return; } if (nextChapter.bookAbbrev !== activeChapter.bookAbbrev) { setStatusMessage('Click-based spanning only works into the next chapter of the same book.'); window.setTimeout(() => setStatusMessage(''), 2600); return; } const nextMaxVerse = nextChapter.verses.at(-1)?.number ?? 0; const spilloverEndVerse = Number.parseInt(clickedSpanNextEnd, 10); if (!Number.isInteger(spilloverEndVerse) || spilloverEndVerse < 1 || spilloverEndVerse > nextMaxVerse) { setStatusMessage(`Next chapter end must be between 1 and ${nextMaxVerse}.`); window.setTimeout(() => setStatusMessage(''), 2400); return; } const start = rangeStart; const end = activeChapter.verses.at(-1)?.number ?? start; const { addedCount } = addChunkRanges([{ start, end, spilloverEndVerse }]); if (addedCount === 0) { setStatusMessage('That cross-chapter chunk already exists.'); window.setTimeout(() => setStatusMessage(''), 2400); return; } setClickedSpanNextEnd(''); setRangeStart(null); setRangeEnd(null); setStatusMessage('Cross-chapter chunk added.'); window.setTimeout(() => setStatusMessage(''), 1800); }; const handleVerseClick = (verseNumber, event) => { if (event.shiftKey && rangeStart !== null) { const start = Math.min(rangeStart, verseNumber); const end = Math.max(rangeStart, verseNumber); addChunk(start, end); setRangeStart(null); setRangeEnd(null); return; } setRangeStart(verseNumber); setRangeEnd(verseNumber); }; const updateChunk = (chunkId, patch) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => (c.id === chunkId ? { ...c, ...patch } : c)), })), })); }; const moveChunk = (chunkId, direction) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => { const index = ch.chunks.findIndex((c) => c.id === chunkId); if (index < 0) return ch; const nextIndex = index + direction; if (nextIndex < 0 || nextIndex >= ch.chunks.length) return ch; const updated = [...ch.chunks]; const [removed] = updated.splice(index, 1); updated.splice(nextIndex, 0, removed); return { ...ch, chunks: updated }; }), })); }; const deleteChunk = (chunkId) => { updateProject((current) => { const newAllChunks = current.chapters .flatMap((ch) => ch.chunks) .filter((c) => c.id !== chunkId); const nextSelected = current.selectedChunkId === chunkId ? newAllChunks[0]?.id ?? null : current.selectedChunkId; return { ...current, selectedChunkId: nextSelected, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.filter((c) => c.id !== chunkId), })), }; }); }; // --------------------------------------------------------------------------- // Cross-references // --------------------------------------------------------------------------- const addCrossRef = (chunkId) => { const ref = crossRefInput.trim(); if (!ref) return; updateChunk(chunkId, { crossReferences: [...(selectedChunk?.crossReferences ?? []), ref], }); setCrossRefInput(''); }; const removeCrossRef = (chunkId, ref) => { updateChunk(chunkId, { crossReferences: (selectedChunk?.crossReferences ?? []).filter((r) => r !== ref), }); }; const [tagInput, setTagInput] = useState(''); const addTag = (chunkId) => { const tag = tagInput.trim(); if (!tag) return; const existing = selectedChunk?.tags ?? []; if (existing.some((t) => t.toLowerCase() === tag.toLowerCase())) { setTagInput(''); return; } updateChunk(chunkId, { tags: [...existing, tag] }); setTagInput(''); }; const removeTag = (chunkId, tag) => { updateChunk(chunkId, { tags: (selectedChunk?.tags ?? []).filter((t) => t !== tag), }); }; const importFinalScriptDocx = async (chunkId, file) => { if (!file) return; try { const arrayBuffer = await file.arrayBuffer(); const { value } = await mammoth.extractRawText({ arrayBuffer }); updateChunk(chunkId, { finalScript: value.trim() }); setStatusMessage('Imported script from DOCX.'); window.setTimeout(() => setStatusMessage(''), 1800); } catch (err) { setStatusMessage('Could not read that DOCX file.'); window.setTimeout(() => setStatusMessage(''), 1800); } }; const [suggestingCrossRefs, setSuggestingCrossRefs] = useState(false); const [crossRefSuggestions, setCrossRefSuggestions] = useState([]); const _crossRefDatasetCacheRef = useRef({}); const formatCrossRef = (ref) => { const book = bookOptions.find((b) => b.abbrev === ref.book); const bookName = book?.name ?? ref.book; const verseRange = ref.endVerse && ref.endVerse !== ref.verse ? `${ref.verse}-${ref.endVerse}` : `${ref.verse}`; return `${bookName} ${ref.chapter}:${verseRange}`; }; const suggestCrossRefsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); const chapterIndex = project?.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunkId) ) ?? -1; const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null; if (!chunk || !chapter) return; setSuggestingCrossRefs(true); setCrossRefSuggestions([]); try { const cacheKey = `${chapter.bookAbbrev}/${chapter.chapter}`; let chapterData = _crossRefDatasetCacheRef.current[cacheKey]; if (!chapterData) { const res = await fetch(`https://bible.helloao.org/api/d/open-cross-ref/${chapter.bookAbbrev}/${chapter.chapter}.json`); if (!res.ok) throw new Error('No cross-reference data found for this chapter.'); chapterData = await res.json(); _crossRefDatasetCacheRef.current[cacheKey] = chapterData; } const verseEntries = (chapterData.chapter?.content ?? []).filter( (item) => item.verse >= chunk.startVerse && item.verse <= chunk.endVerse, ); const existing = new Set(chunk.crossReferences ?? []); const seen = new Set(); const refs = []; for (const v of verseEntries) { for (const ref of v.references ?? []) { const formatted = formatCrossRef(ref); if (existing.has(formatted) || seen.has(formatted)) continue; seen.add(formatted); refs.push({ formatted, score: ref.score ?? 0 }); } } refs.sort((a, b) => b.score - a.score); if (refs.length === 0) { setStatusMessage('No new cross-reference suggestions found for this passage.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setCrossRefSuggestions(refs.slice(0, 12).map((r) => r.formatted)); } catch (err) { setStatusMessage(`Cross-reference suggest failed: ${err.message}`); window.setTimeout(() => setStatusMessage(''), 3000); } finally { setSuggestingCrossRefs(false); } }; const _verseChapterCacheRef = useRef({}); const loadVerseText = async (refString) => { const parsed = parseCrossRefString(refString); if (!parsed) throw new Error('Could not parse reference.'); const translation = project?.translation ?? 'BSB'; const cacheKey = `${translation}/${parsed.bookAbbrev}/${parsed.chapter}`; let verses = _verseChapterCacheRef.current[cacheKey]; if (!verses) { const res = await fetch(`https://bible.helloao.org/api/${translation}/${parsed.bookAbbrev}/${parsed.chapter}.json`); if (!res.ok) throw new Error('Could not load verse text.'); const data = await res.json(); verses = parseBibleChapter(data); _verseChapterCacheRef.current[cacheKey] = verses; } const text = verses .filter((v) => v.number >= parsed.verse && v.number <= parsed.endVerse) .map((v) => v.text) .join(' '); return text; }; const addSuggestedCrossRef = (chunkId, ref) => { updateChunk(chunkId, { crossReferences: [...(selectedChunk?.crossReferences ?? []), ref], }); setCrossRefSuggestions((cur) => cur.filter((r) => r !== ref)); }; // --------------------------------------------------------------------------- // Greek word helpers // --------------------------------------------------------------------------- const updateChunkWord = (chunkId, wordId, patch) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => { if (chunk.id !== chunkId) return chunk; return { ...chunk, greekWords: chunk.greekWords.map((word) => word.id === wordId ? { ...word, ...patch } : word, ), }; }), })), })); }; const addGreekWord = (chunkId) => { const newWord = { id: makeId(), query: '', strongNumber: '', englishGloss: '', lexeme: '', transliteration: '', partOfSpeech: '', shortDefinition: '', definitionHtml: '', loading: false, }; updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: [...chunk.greekWords, newWord] } : chunk, ), })), })); }; const removeGreekWord = (chunkId, wordId) => { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((chunk) => chunk.id === chunkId ? { ...chunk, greekWords: chunk.greekWords.filter((w) => w.id !== wordId) } : chunk, ), })), })); }; // Cache for the NT Strong's concordance (verse → Strong's numbers), loaded once const _concordanceRef = useRef(null); const _concordanceLoadingRef = useRef(null); // Cache for the NT Strong's→English gloss map (from macula-greek dataset), loaded once const _glossRef = useRef(null); const _glossLoadingRef = useRef(null); // Cache for the Hebrew Strong's dictionary (loaded once) const _hebrewDictRef = useRef(null); const _hebrewDictLoadingRef = useRef(null); const loadNtGloss = async () => { if (_glossRef.current) return _glossRef.current; if (_glossLoadingRef.current) return _glossLoadingRef.current; _glossLoadingRef.current = fetch('/nt-strongs-gloss.json') .then((res) => res.json()) .then((data) => { _glossRef.current = data; _glossLoadingRef.current = null; return data; }); return _glossLoadingRef.current; }; const loadNtConcordance = async () => { if (_concordanceRef.current) return _concordanceRef.current; if (_concordanceLoadingRef.current) return _concordanceLoadingRef.current; _concordanceLoadingRef.current = fetch('/nt-strongs-concordance.json') .then((res) => res.json()) .then((data) => { _concordanceRef.current = data; _concordanceLoadingRef.current = null; return data; }); return _concordanceLoadingRef.current; }; const loadHebrewDict = async () => { if (_hebrewDictRef.current) return _hebrewDictRef.current; if (_hebrewDictLoadingRef.current) return _hebrewDictLoadingRef.current; _hebrewDictLoadingRef.current = fetch( 'https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/hebrew/strongs-hebrew-dictionary.js', ) .then((res) => res.text()) .then((text) => { const start = text.indexOf('{'); const end = text.lastIndexOf('}') + 1; const dict = JSON.parse(text.slice(start, end)); _hebrewDictRef.current = dict; _hebrewDictLoadingRef.current = null; return dict; }); return _hebrewDictLoadingRef.current; }; // Common Greek NT function words to pre-uncheck in the picker // (articles, prepositions, conjunctions, particles, common pronouns) const GREEK_FUNCTION_WORDS = new Set([ 'G3588','G3739','G3748', // ὁ (the), ὅς (who/which), ὅστις 'G2532','G1161','G1063','G235','G3767','G2443','G5037','G3303', // καί, δέ, γάρ, ἀλλά, οὖν, ἵνα, τέ, μέν 'G1487','G1437','G3754','G5613','G2531','G5618', // εἰ, ἐάν, ὅτι, ὡς, καθώς, ὥσπερ 'G1722','G1519','G1537','G575','G4314','G3326','G2596','G1223', // ἐν, εἰς, ἐκ, ἀπό, πρός, μετά, κατά, διά 'G1909','G5228','G5259','G4862','G3844','G4253','G4012','G1722', // ἐπί, ὑπέρ, ὑπό, σύν, παρά, πρό, περί 'G3756','G3361','G3780','G3762','G3763', // οὐ, μή, οὐχί, οὐδείς, οὐδέποτε 'G846','G1473','G4771','G2249','G5210', // αὐτός, ἐγώ, σύ, ἡμεῖς, ὑμεῖς 'G3778','G1565','G3588', // οὗτος, ἐκεῖνος 'G5100','G5101','G3956','G3745','G3748', // τις, τίς, πᾶς, ὅσος 'G3779','G3568','G5119','G1161','G3767', // οὕτως, νῦν, τότε ]); const suggestGreekWordsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); const chapterIndex = project?.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunkId) ) ?? -1; const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null; if (!chunk || !chapter) return; if (!NT_BOOK_NUMBER[chapter.bookAbbrev]) { setStatusMessage('Auto-suggest only works for New Testament books.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setSuggestingGreekForChunkId(chunkId); try { const [concordance, dict, gloss] = await Promise.all([loadNtConcordance(), loadGreekDict(), loadNtGloss()]); const bookData = concordance[chapter.bookAbbrev] ?? {}; const chapterData = bookData[chapter.chapter] ?? {}; const nextChapterData = chunkSpansNextChapter(project, chapterIndex, chunk) ? bookData[project.chapters[chapterIndex + 1].chapter] ?? {} : {}; const strongsInRange = new Set(); for (let v = chunk.startVerse; v <= chunk.endVerse; v++) { for (const s of chapterData[String(v)] ?? []) strongsInRange.add(s); } if (chunkSpansNextChapter(project, chapterIndex, chunk)) { for (let v = 1; v <= chunk.spilloverEndVerse; v++) { for (const s of nextChapterData[String(v)] ?? []) strongsInRange.add(s); } } if (strongsInRange.size === 0) { setStatusMessage("No Strong's data found for this passage."); window.setTimeout(() => setStatusMessage(''), 3000); return; } const existingNumbers = new Set( chunk.greekWords.map((w) => w.strongNumber.toUpperCase()).filter(Boolean) ); const modalWords = []; for (const strongKey of strongsInRange) { if (existingNumbers.has(strongKey)) continue; const entry = dict[strongKey]; modalWords.push({ strongKey, lexeme: entry?.lemma ?? '', translit: entry?.translit ?? '', def: gloss[strongKey] ?? entry?.kjv_def ?? 'No definition found.', entry, }); } if (modalWords.length === 0) { setStatusMessage('All words from this passage are already added.'); window.setTimeout(() => setStatusMessage(''), 2000); return; } // Pre-select content words; pre-uncheck known function words const preSelected = new Set( modalWords .filter((w) => !GREEK_FUNCTION_WORDS.has(w.strongKey)) .map((w) => w.strongKey) ); setSuggestModal({ chunkId, language: 'greek', helperText: "Content words are pre-checked. Uncheck any you don't need.", words: modalWords, }); setSuggestSelection(preSelected); } catch (err) { setStatusMessage(`Suggest failed: ${err.message}`); window.setTimeout(() => setStatusMessage(''), 3000); } finally { setSuggestingGreekForChunkId(null); } }; const ENGLISH_STOP_WORDS = new Set([ 'the', 'and', 'for', 'that', 'with', 'this', 'from', 'were', 'was', 'have', 'has', 'had', 'are', 'but', 'not', 'you', 'your', 'his', 'her', 'their', 'they', 'them', 'our', 'out', 'into', 'over', 'under', 'upon', 'then', 'than', 'who', 'what', 'when', 'where', 'why', 'how', 'also', 'there', 'here', 'all', 'any', 'one', 'two', 'three', 'he', 'she', 'it', 'we', 'i', 'me', 'my', 'mine', 'ours', 'theirs', 'its', ]); const suggestHebrewWordsForChunk = async (chunkId) => { const chunk = allChunks.find((c) => c.id === chunkId); const chapterIndex = project?.chapters.findIndex((ch) => ch.chunks.some((c) => c.id === chunkId) ) ?? -1; const chapter = chapterIndex >= 0 ? project.chapters[chapterIndex] : null; if (!chunk || !chapter) return; if (NT_BOOK_NUMBER[chapter.bookAbbrev]) { setStatusMessage('Hebrew suggestions are for Old Testament passages.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setSuggestingHebrewForChunkId(chunkId); try { const dict = await loadHebrewDict(); const versesInChunk = getChunkVerseEntries(project, chapterIndex, chunk) .map((v) => v.text) .join(' ') .toLowerCase(); const candidateWords = Array.from(new Set( versesInChunk .replace(/[^a-z\s]/g, ' ') .split(/\s+/) .map((w) => w.trim()) .filter((w) => w.length >= 4 && !ENGLISH_STOP_WORDS.has(w)), )); if (candidateWords.length === 0) { setStatusMessage('No Hebrew suggestion candidates found in this passage.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } const existingNumbers = new Set( chunk.greekWords.map((w) => (w.strongNumber || '').toUpperCase()).filter(Boolean) ); const seen = new Set(); const modalWords = []; const entries = Object.entries(dict); for (const token of candidateWords) { const match = entries.find(([, entry]) => { const defs = `${entry.kjv_def || ''} ${entry.strongs_def || ''}`.toLowerCase(); return defs.split(/[,;()\s]+/).includes(token); }); if (!match) continue; const [strongKey, entry] = match; if (!/^H\d+$/i.test(strongKey)) continue; const normalized = strongKey.toUpperCase(); if (existingNumbers.has(normalized) || seen.has(normalized)) continue; seen.add(normalized); modalWords.push({ strongKey: normalized, lexeme: entry?.lemma ?? '', translit: entry?.xlit ?? '', def: entry?.kjv_def ?? 'No definition found.', entry, }); if (modalWords.length >= 20) break; } if (modalWords.length === 0) { setStatusMessage('No Hebrew suggestions found from this passage text.'); window.setTimeout(() => setStatusMessage(''), 2500); return; } setSuggestModal({ chunkId, language: 'hebrew', helperText: 'Heuristic matches from passage text; uncheck anything not useful.', words: modalWords, }); setSuggestSelection(new Set(modalWords.map((w) => w.strongKey))); } catch (err) { setStatusMessage(`Hebrew suggest failed: ${err.message}`); window.setTimeout(() => setStatusMessage(''), 3000); } finally { setSuggestingHebrewForChunkId(null); } }; const confirmSuggestWords = () => { if (!suggestModal) return; const { chunkId, words } = suggestModal; const newWords = words .filter((w) => suggestSelection.has(w.strongKey)) .map((w) => ({ id: makeId(), query: w.strongKey, strongNumber: w.strongKey, englishGloss: w.def, lexeme: w.lexeme, transliteration: w.translit, partOfSpeech: '', shortDefinition: w.def, definitionHtml: w.entry ? buildGreekDefinitionHtml(w.strongKey, w.entry) : '', loading: false, })); if (newWords.length > 0) { updateProject((current) => ({ ...current, chapters: current.chapters.map((ch) => ({ ...ch, chunks: ch.chunks.map((c) => c.id === chunkId ? { ...c, greekWords: [...c.greekWords, ...newWords] } : c ), })), })); const label = suggestModal?.language === 'hebrew' ? 'Hebrew' : 'Greek'; setStatusMessage(`Added ${newWords.length} ${label} word${newWords.length > 1 ? 's' : ''}.`); window.setTimeout(() => setStatusMessage(''), 2000); } setSuggestModal(null); setSuggestSelection(new Set()); }; const isGreekStrongNumber = (query) => /^G\d+$/i.test(query.trim()); const isHebrewStrongNumber = (query) => /^H\d+$/i.test(query.trim()); const externalLookupLinks = (query) => { const raw = query.trim(); const normalized = raw.toUpperCase(); const isGreek = /^G\d+$/.test(normalized); const isHebrew = /^H\d+$/.test(normalized); const num = (isGreek || isHebrew) ? normalized.slice(1) : null; if (isGreek && num) { return [ { label: 'BibleHub', url: `https://biblehub.com/greek/${num}.htm` }, { label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/g${num}/esv/0-1/` }, { label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/greek/${num}.html` }, ]; } if (isHebrew && num) { return [ { label: 'BibleHub', url: `https://biblehub.com/hebrew/${num}.htm` }, { label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/lexicon/h${num}/kjv/wlc/0-1/` }, { label: 'StudyLight', url: `https://www.studylight.org/lexicons/eng/hebrew/${num}.html` }, ]; } const enc = encodeURIComponent(raw); return [ { label: 'BibleHub', url: `https://biblehub.com/search.php?q=${enc}` }, { label: 'Blue Letter Bible', url: `https://www.blueletterbible.org/search/search.cfm?Criteria=${enc}&t=KJV#s=s_lexiconc` }, ]; }; // Module-level cache for the Greek Strong's dictionary (loaded once, ~1.2 MB) const _greekDictRef = useRef(null); const _greekDictLoadingRef = useRef(null); const loadGreekDict = async () => { if (_greekDictRef.current) return _greekDictRef.current; if (_greekDictLoadingRef.current) return _greekDictLoadingRef.current; _greekDictLoadingRef.current = fetch( 'https://cdn.jsdelivr.net/gh/openscriptures/strongs@master/greek/strongs-greek-dictionary.js', ) .then((res) => res.text()) .then((text) => { const start = text.indexOf('{'); const end = text.lastIndexOf('}') + 1; const dict = JSON.parse(text.slice(start, end)); _greekDictRef.current = dict; _greekDictLoadingRef.current = null; return dict; }); return _greekDictLoadingRef.current; }; const buildGreekDefinitionHtml = (key, entry) => { let html = ''; if (entry.strongs_def) html += `

${entry.strongs_def.trim()}

`; if (entry.derivation) html += `

Derivation: ${entry.derivation}

`; if (entry.kjv_def) html += `

KJV uses: ${entry.kjv_def}

`; return html; }; const fetchBollsDefinition = async (query) => { const encoded = encodeURIComponent(query.trim()); if (isHebrewStrongNumber(query)) { const response = await fetch(`https://bolls.life/dictionary-definition/BDBT/${encoded}/`); if (!response.ok) return null; let defs; try { defs = await response.json(); } catch { return null; } return Array.isArray(defs) && defs.length > 0 ? defs : null; } // Greek — look up in the cached OpenScriptures Strong's dictionary. const dict = await loadGreekDict(); const key = isGreekStrongNumber(query) ? query.trim().toUpperCase() : null; if (key) { const entry = dict[key]; if (!entry) return null; return [{ topic: key, lexeme: entry.lemma || '', transliteration: entry.translit || '', short_definition: entry.kjv_def || '', definition: buildGreekDefinitionHtml(key, entry), }]; } // English word search — scan kjv_def and strongs_def for the query term. const lowerQ = query.trim().toLowerCase(); const match = Object.entries(dict).find(([, entry]) => (entry.kjv_def || '').toLowerCase().split(/[,\s]+/).some((w) => w === lowerQ) || (entry.strongs_def || '').toLowerCase().includes(lowerQ), ); if (match) { const [matchKey, entry] = match; return [{ topic: matchKey, lexeme: entry.lemma || '', transliteration: entry.translit || '', short_definition: entry.kjv_def || '', definition: buildGreekDefinitionHtml(matchKey, entry), }]; } return null; }; const lookupWord = async (chunkId, wordId, language = 'greek') => { const chunk = allChunks.find((c) => c.id === chunkId); const word = chunk?.greekWords.find((w) => w.id === wordId); if (!word || !word.query.trim()) return; updateChunkWord(chunkId, wordId, { loading: true }); try { // Normalize bare numbers based on selected lookup language. const raw = word.query.trim(); const inferredPrefix = language === 'hebrew' ? 'H' : 'G'; const normalized = /^\d+$/.test(raw) ? `${inferredPrefix}${raw}` : /^[gGhH]\d+$/.test(raw) ? raw.toUpperCase() : raw; const [definitions, gloss] = await Promise.all([ fetchBollsDefinition(normalized), loadNtGloss().catch(() => ({})), ]); if (!definitions || definitions.length === 0) { updateChunkWord(chunkId, wordId, { strongNumber: '', shortDefinition: 'No definition found.', definitionHtml: '', }); return; } const first = definitions[0]; const strongKey = (first.topic || normalized).toUpperCase(); const extractedPartOfSpeech = extractPartOfSpeech(first.definition || ''); updateChunkWord(chunkId, wordId, { strongNumber: first.topic || normalized, englishGloss: gloss[strongKey] || '', lexeme: first.lexeme || '', transliteration: first.transliteration || '', partOfSpeech: extractedPartOfSpeech || word.partOfSpeech || '', shortDefinition: first.short_definition || '', definitionHtml: first.definition || '', }); } catch { updateChunkWord(chunkId, wordId, { shortDefinition: 'Lookup failed.' }); } finally { updateChunkWord(chunkId, wordId, { loading: false }); } }; // --------------------------------------------------------------------------- // Export // --------------------------------------------------------------------------- const exportChapter = () => { if (!project) return; const html = buildExportHtml(project); const blob = new Blob([html], { type: 'text/html;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${project.title.replace(/\s+/g, '-')}-study.html`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const exportChapterDocx = async () => { if (!project) return; const children = [ new Paragraph({ text: project.title, heading: HeadingLevel.TITLE }), new Paragraph({ children: [ new TextRun({ text: `${project.translation} — ${buildChapterSummary(project)}`, 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: ` (Ep. ${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 }], }); const blob = await Packer.toBlob(doc); const url = URL.createObjectURL(blob); const link = document.createElement('a'); link.href = url; link.download = `${project.title.replace(/\s+/g, '-')}-study.docx`; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); }; const copyForClaude = () => { if (!project) return; const prompt = buildClaudePrompt(project); navigator.clipboard.writeText(prompt).then(() => { setSaveStatus('Copied for Claude!'); window.setTimeout(() => setSaveStatus(''), 2000); }); }; const copyForPodcast = () => { if (!project) return; const prompt = buildPodcastPrompt(project); navigator.clipboard.writeText(prompt).then(() => { setSaveStatus('Copied podcast prep!'); window.setTimeout(() => setSaveStatus(''), 2000); }); }; const copyPronunciationGuide = () => { if (!project) return; const guide = buildPronunciationGuide(project); navigator.clipboard.writeText(guide).then(() => { setSaveStatus('Copied pronunciation guide!'); window.setTimeout(() => setSaveStatus(''), 2000); }); }; // --------------------------------------------------------------------------- // Home page helpers // --------------------------------------------------------------------------- const openNewProject = () => { setProject(null); setActiveChapterIndex(0); setShowAddChapterForm(false); setRangeStart(null); setRangeEnd(null); setErrorMessage(''); setStatusMessage(''); setTitleEdited(false); setCurrentPage('setup'); }; const resumeProject = (id) => { const loaded = loadProjectById(id); if (!loaded) return; setProject(loaded); setActiveChapterIndex(0); const loadedChunks = loaded.chapters.flatMap((ch) => ch.chunks); const hasSelectedChunk = loaded.selectedChunkId && loadedChunks.some((c) => c.id === loaded.selectedChunkId); setCurrentPage(hasSelectedChunk ? 'study' : 'setup'); }; const renameProjectInStorage = (id, title) => { const loaded = loadProjectById(id); if (!loaded) return; const updated = { ...loaded, title, lastEdited: Date.now() }; saveProjectToStorage(updated); setProjectIndex(loadProjectIndex()); saveRemoteProject(updated); if (project?.id === id) { setProject((current) => (current ? { ...current, title } : current)); } }; const deleteProject = (id) => { if (!window.confirm('Delete this project? This cannot be undone.')) return; deleteProjectFromStorage(id); setProjectIndex(loadProjectIndex()); deleteRemoteProject(id); // fire-and-forget }; const restoreRemoteProject = async (id) => { const result = await loadRemoteProject(id); if (!result.ok) { alert('Could not restore project from server.'); return; } saveProjectToStorage(result.data); setProjectIndex(loadProjectIndex()); setRemoteOnlyProjects((prev) => prev.filter((e) => e.id !== id)); }; const pullLatestFromServer = async (id) => { const result = await loadRemoteProject(id); if (!result.ok) { alert('Could not pull latest version from server.'); return; } saveProjectToStorage(result.data); setProjectIndex(loadProjectIndex()); setStaleLocalProjects((prev) => prev.filter((e) => e.id !== id)); }; const goHome = () => { setProjectIndex(loadProjectIndex()); setCurrentPage('home'); setProject(null); setRangeStart(null); setRangeEnd(null); setErrorMessage(''); setStatusMessage(''); }; // --------------------------------------------------------------------------- // Shared header // --------------------------------------------------------------------------- const headerButtons = (
{currentPage !== 'home' && ( )} {project && currentPage === 'study' && ( )} {project && (
)}
{loadingChapter ? ( Loading… ) : saveStatus ? ( {saveStatus} ) : (   )} {syncStatus === 'syncing' &&
Syncing…
} {syncStatus === 'synced' &&
Synced ✓
} {syncStatus === 'error' && (
⚠ Sync failed
)}
); // --------------------------------------------------------------------------- // HOME PAGE // --------------------------------------------------------------------------- if (currentPage === 'home') { return (

Bible Study Project

My Studies

{remoteOnlyProjects.length > 0 && (

📥 {remoteOnlyProjects.length} project{remoteOnlyProjects.length > 1 ? 's' : ''} found on the server that aren't saved locally:

{remoteOnlyProjects.map((entry) => ( ))}
)} {staleLocalProjects.length > 0 && (

☁️ {staleLocalProjects.length} project{staleLocalProjects.length > 1 ? 's have' : ' has'} a newer version on the server:

{staleLocalProjects.map((entry) => ( ))}
)} {projectIndex.length === 0 ? (

No projects yet

Start a new Bible study to get going.

) : ( <>
setHomeSearch(e.target.value)} placeholder="Search projects by title or passage…" className="w-full max-w-sm rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" /> {(() => { const allTags = Array.from( new Set(projectIndex.flatMap((entry) => entry.tags ?? [])), ).sort((a, b) => a.localeCompare(b)); if (allTags.length === 0) return null; return ( ); })()}
{projectIndex .slice() .filter((entry) => { if (homeTagFilter && !(entry.tags ?? []).includes(homeTagFilter)) return false; const q = homeSearch.trim().toLowerCase(); if (!q) return true; return entry.title?.toLowerCase().includes(q) || entry.chapterSummary?.toLowerCase().includes(q); }) .sort((a, b) => { if (homeSort === 'title') return (a.title ?? '').localeCompare(b.title ?? ''); if (homeSort === 'passage') return (a.chapterSummary ?? '').localeCompare(b.chapterSummary ?? ''); return (b.lastEdited ?? 0) - (a.lastEdited ?? 0); }) .map((entry) => (
{renamingId === entry.id ? (
setRenameValue(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const trimmed = renameValue.trim(); if (trimmed) renameProjectInStorage(entry.id, trimmed); setRenamingId(null); } else if (e.key === 'Escape') { setRenamingId(null); } }} className="flex-1 rounded-lg border border-slate-300 px-2 py-1 text-base font-semibold text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" />
) : (

{entry.title}

)} {entry.chapterSummary && (

{entry.chapterSummary}

)} {entry.tags?.length > 0 && (
{entry.tags.map((tag) => ( {tag} ))}
)}

{formatRelativeDate(entry.lastEdited)}

))}
)}
); } // --------------------------------------------------------------------------- // SETUP PAGE (chunk builder) // --------------------------------------------------------------------------- if (currentPage === 'setup') { const chapterTabs = project?.chapters ?? []; return (

Bible Study Project

{project ? project.title : 'New Study'}

{headerButtons}
{/* Project setup form — shown when no project loaded yet */} {!project && (
{ setTitleEdited(true); handleSetupField('title', val); }} onLoad={handleLoadChapter} />
)} {/* Chapter tabs + verse/chunk editors */} {project && (
{/* Chapter tabs */}
{chapterTabs.map((ch, idx) => ( ))}
{/* Add-chapter form */} {showAddChapterForm && (

Add another chapter

{ setTitleEdited(true); handleSetupField('title', val); }} onLoad={handleLoadChapter} />
)} {/* Verse + chunk panel for active chapter */} {activeChapter && (

Scripture & Chunks

{activeChapter.book} {activeChapter.chapter} ({project.translation})

{statusMessage || 'Click/shift-click, or type a verse range, to create a chunk.'}
Chapter verses Click a verse, then shift-click an end verse.
setVerseSearch(e.target.value)} placeholder="Search verses in this chapter…" className="mb-3 block w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm text-slate-900 shadow-sm focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200" />
{activeChapter.verses .filter((verse) => verse.text.toLowerCase().includes(verseSearch.trim().toLowerCase())) .map((verse) => { const inRange = rangeStart !== null && verse.number >= Math.min(rangeStart, rangeEnd) && verse.number <= Math.max(rangeStart, rangeEnd); const inOwnChapterChunk = activeChapter.chunks.some( (chunk) => verse.number >= chunk.startVerse && verse.number <= chunk.endVerse, ); const prevChapter = project?.chapters?.[activeChapterIndex - 1] ?? null; const inPrevChapterSpillover = prevChapter ? prevChapter.chunks.some((chunk) => Number.isInteger(chunk.spilloverEndVerse) && prevChapter.bookAbbrev === activeChapter.bookAbbrev && verse.number <= chunk.spilloverEndVerse ) : false; const inChunk = inOwnChapterChunk || inPrevChapterSpillover; return ( ); })}

Chunks

{activeChapter.chunks.length} created

Type chunk range