Files
study-bible-project/src/utils/handout.js
T
nmemmertandClaude Sonnet 5.5 a6af9bd1c6
Build and Push Docker Image / build-and-push (push) Successful in 6m41s
Teaching tools: word occurrences, blanks handout, templates, series progress, book info, comments
- Words tab: "Where else" lists every verse using a Strong's number, by book
  (new /api/strongs/:number/occurrences, indexed from the interlinear files)
- Handout: fill-in-the-blanks layout with a word bank
- Four new study templates: character, topical, word, verse by verse
- Finish: series progress (not started / studied / ready / taught)
- Background: book at a glance for all 66 books
- Chunk comments for co-teachers, merged between copies
- TODO: correct BSB audio note, tick co-teacher comments

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-27 15:44:37 -04:00

128 lines
8.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// A one-page-per-session handout for a group: the passage, the outline, discussion questions and room to write.
// It opens as a print-friendly page ("Save as PDF" in the print dialog).
import { formatChunkReference, getChunkVerseEntries } from './bibleUtils.jsx';
import { flatten } from './outline.js';
const esc = (s) => String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const lines = (text) => String(text ?? '').split('\n').map((l) => l.replace(/^\s*(?:[-*•–]|\d{1,2}[.)])\s+/, '').trim()).filter(Boolean);
// Every session (chunk) of the study, in order, with where it sits.
export function sessionsFor(project) {
const out = [];
(project?.chapters ?? []).forEach((chapter, chapterIndex) => {
for (const chunk of chapter.chunks ?? []) out.push({ chunk, chapterIndex, number: out.length + 1, reference: formatChunkReference(project, chapterIndex, chunk, '–') });
});
return out;
}
// The outline points for a session: its own outline, else the study outline's points linked to it.
export function outlineForSession(project, chunk) {
if ((chunk.outline ?? []).length) return chunk.outline;
const linked = (project.outline ?? []).filter((n) => n.chunkId === chunk.id);
return linked.length ? linked : [];
}
// The questions to print: the ones written for the session, else its Application notes as "Think about" prompts.
export function questionsFor(chunk) {
const own = lines(chunk.discussionQuestions);
if (own.length) return { kind: 'questions', items: own };
const fallback = lines(chunk.application);
return fallback.length ? { kind: 'think', items: fallback } : { kind: 'none', items: [] };
}
const STOP = new Set(['the','and','but','for','nor','yet','who','whom','whose','that','this','these','those','with','from','into','unto','upon','shall','will','have','has','had','was','were','been','are','not','his','her','him','their','them','they','you','your','our','out','all','when','then','than','what','which','there','here','also','because','therefore','said','says']);
// The words to leave blank in a fill-in passage: the English words you studied in this session (the word-study
// glosses) that appear in it; if there are none, the longer words that repeat, since a repeated word is usually
// what the passage is about. Returns lowercase words, most useful first, at most 12.
export function blankWordsFor(chunk, verses) {
const text = verses.map((v) => v.text).join(' ');
const counts = new Map();
for (const w of text.toLowerCase().match(/[a-z]+(?:['’][a-z]+)?/g) ?? []) counts.set(w, (counts.get(w) ?? 0) + 1);
const glossed = (chunk.greekWords ?? []).flatMap((w) => String(w.englishGloss ?? '').toLowerCase().match(/[a-z]+/g) ?? [])
.filter((w) => w.length > 2 && !STOP.has(w) && counts.has(w));
const chosen = [...new Set(glossed)];
if (chosen.length === 0) {
const repeated = [...counts].filter(([w, n]) => n >= 2 && w.length >= 5 && !STOP.has(w)).sort((a, b) => b[1] - a[1] || b[0].length - a[0].length).map(([w]) => w);
chosen.push(...repeated);
}
return chosen.slice(0, 12);
}
// A verse's text with the chosen words replaced by a blank line of about the same length (each occurrence).
export function blankOut(text, words) {
const set = new Set(words);
// split keeps the words (odd entries) apart from the punctuation between them, so both are escaped
return String(text ?? '').split(/([A-Za-z]+(?:['’][A-Za-z]+)?)/).map((part, i) => (
i % 2 === 1 && set.has(part.toLowerCase()) ? `<span class="blank">${'&nbsp;'.repeat(Math.max(8, part.length + 2))}</span>` : esc(part)
)).join('');
}
// layout: 'standard' (everything), 'questions' (passage + questions only, one page), 'large' (big print), 'notes' (passage and a blank page to write).
export const HANDOUT_LAYOUTS = [
{ id: 'standard', label: 'Standard: passage, outline, questions' },
{ id: 'questions', label: 'Questions only: passage and discussion questions' },
{ id: 'large', label: 'Large print: passage and questions' },
{ id: 'notes', label: 'Note-taking: passage and lots of lines' },
{ id: 'blanks', label: 'Fill in the blanks: key words left out of the passage' },
];
function sessionHtml(project, session, { answerLines, layout = 'standard' }) {
const { chunk, chapterIndex, number, reference } = session;
const series = (project.series ?? '').trim();
const title = (chunk.episodeTitle ?? '').trim();
const num = (chunk.episodeNumber ?? '').toString().trim() || number;
const verses = getChunkVerseEntries(project, chapterIndex, chunk);
const outline = layout === 'standard' ? flatten(outlineForSession(project, chunk)) : [];
const q = layout === 'notes' ? { kind: 'none', items: [] } : questionsFor(chunk);
const blanks = layout === 'blanks' ? blankWordsFor(chunk, verses) : [];
const blank = (n) => Array.from({ length: n }, () => '<div class="rule"></div>').join('');
return `<section class="page${layout === 'standard' ? '' : ` ${layout}`}">
<header>
<p class="kicker">${esc(series || project.title)}</p>
<h1>Session ${esc(num)}${title ? `: ${esc(title)}` : ''}</h1>
<p class="ref">${esc(reference)} <span>(${esc(project.translation ?? 'BSB')})</span></p>
<p class="meta">Name: ____________________________ &nbsp;&nbsp; Date: ________________</p>
</header>
${verses.length ? `<div class="passage">${verses.map((v) => `<p><sup>${esc(v.number)}</sup> ${layout === 'blanks' && blanks.length ? blankOut(v.text, blanks) : esc(v.text)}</p>`).join('')}</div>` : ''}
${blanks.length ? `<p class="bank"><b>Word bank:</b> ${[...blanks].sort().map(esc).join(' &middot; ')}</p>` : ''}
${outline.length ? `<h2>Outline</h2><ul class="outline">${outline.map(({ node, depth, label }) => `<li style="margin-left:${depth * 1.2}em"><b>${esc(label)}</b> ${esc(node.text || '')}${node.ref ? ` <i>(${esc(node.ref)})</i>` : ''}</li>`).join('')}</ul>` : ''}
${q.items.length ? `<h2>${q.kind === 'questions' ? 'Discussion questions' : 'Think about it'}</h2><ol class="questions">${q.items.map((item) => `<li>${esc(item)}${answerLines ? blank(2) : ''}</li>`).join('')}</ol>` : ''}
${answerLines || layout === 'notes' ? `<h2>Notes</h2>${blank(layout === 'notes' ? 16 : q.items.length ? 4 : 10)}` : ''}
<footer>${esc(project.title)}</footer>
</section>`;
}
// ids: the chunk ids to include, or null for every session
export function buildHandoutHtml(project, ids = null, { answerLines = true, layout = 'standard' } = {}) {
const sessions = sessionsFor(project).filter((s) => !ids || ids.includes(s.chunk.id));
return `<!doctype html><html><head><meta charset="utf-8"><title>${esc(project.title)} — handout</title><style>
@page { margin: 18mm 16mm; }
body { font-family: Georgia, 'Times New Roman', serif; color: #1f1b16; line-height: 1.5; margin: 0; font-size: 12.5pt; }
.page { page-break-after: always; min-height: 95vh; position: relative; }
.page:last-child { page-break-after: auto; }
.kicker { margin: 0; font: 600 9.5pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .14em; text-transform: uppercase; color: #8a4a2a; }
h1 { margin: .15em 0 0; font-size: 22pt; font-weight: 600; }
.ref { margin: .2em 0 .4em; font-size: 13pt; color: #8a4a2a; font-weight: 600; } .ref span { color: #6b6458; font-weight: 400; font-size: 10pt; }
.meta { color: #6b6458; font: 10pt -apple-system, Helvetica, Arial, sans-serif; margin: .2em 0 1em; }
h2 { font: 600 10pt -apple-system, Helvetica, Arial, sans-serif; letter-spacing: .12em; text-transform: uppercase; color: #6b6458; border-bottom: 1px solid #d8cfbb; padding-bottom: 3px; margin: 1.3em 0 .5em; }
.passage { background: #f6f1e6; border-left: 3px solid #a84e27; padding: .4em 1em; } .passage p { margin: .35em 0; } sup { color: #a84e27; font-size: 70%; font-family: -apple-system, Helvetica, Arial, sans-serif; }
.outline { list-style: none; padding: 0; margin: 0; } .outline li { margin-bottom: .25em; } .outline b { color: #a84e27; }
.questions { padding-left: 1.4em; } .questions li { margin-bottom: .7em; }
.blank { display: inline-block; border-bottom: 1.5px solid #1f1b16; line-height: 1; vertical-align: baseline; }
.bank { font: 10.5pt -apple-system, Helvetica, Arial, sans-serif; color: #6b6458; margin: .6em 0 0; }
.rule { border-bottom: 1px solid #c9bfa8; height: 1.7em; }
.large { font-size: 17pt; line-height: 1.55; } .large h1 { font-size: 30pt; } .large .rule { height: 2.2em; }
footer { position: absolute; bottom: 0; left: 0; right: 0; text-align: center; color: #9b9484; font: 9pt -apple-system, Helvetica, Arial, sans-serif; }
</style></head><body>${sessions.map((s) => sessionHtml(project, s, { answerLines, layout })).join('')}</body></html>`;
}
export function printHandout(project, ids = null, options) {
const win = window.open('', '_blank');
if (!win) return false;
win.document.write(buildHandoutHtml(project, ids, options));
win.document.close();
win.onload = () => { win.focus(); win.print(); };
return true;
}