Teaching tools: word occurrences, blanks handout, templates, series progress, book info, comments
Build and Push Docker Image / build-and-push (push) Successful in 6m41s

- 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>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 15:44:37 -04:00
1 parent d9891ff9fe
commit a6af9bd1c6
24 files changed
+670 -9

No files matched your search

+89
View File
@@ -0,0 +1,89 @@
// "Where else does this word appear?": every verse that uses a Strong's number, grouped by book. Built once, in
// memory, from the interlinear files the Reader already ships (public/interlinear, or dist/interlinear in a build),
// the first time anyone asks. Greek (G…) and Hebrew (H…) numbers both work.
import { existsSync } from 'fs';
import { readdir, readFile } from 'fs/promises';
import { fileURLToPath } from 'url';
import { dirname, join } from 'path';
import { bookNameFor } from './bibleApi.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Strong's numbers come as "G4102", "g4102", "H7225" or sometimes with a letter suffix ("H1234a").
export function normalizeStrongs(input) {
const m = String(input ?? '').trim().match(/^([GH])0*(\d{1,5})[a-z]?$/i);
return m ? `${m[1].toUpperCase()}${Number(m[2])}` : null;
}
// One book's interlinear file -> adds each verse it uses a number in to the index (once per verse).
export function indexBook(index, book, data) {
for (const [chapter, verses] of Object.entries(data ?? {})) {
for (const [verse, words] of Object.entries(verses ?? {})) {
const seen = new Set();
for (const w of Array.isArray(words) ? words : []) {
const s = normalizeStrongs(w?.s);
if (!s || seen.has(s)) continue;
seen.add(s);
let entry = index.get(s);
if (!entry) { entry = { count: 0, books: new Map() }; index.set(s, entry); }
let refs = entry.books.get(book);
if (!refs) { refs = []; entry.books.set(book, refs); }
refs.push([Number(chapter), Number(verse)]);
entry.count += 1;
}
}
}
}
// The answer for one number: { strongs, total, books: [{ book, name, count, refs: [[chapter, verse], ...] }] },
// books in the order the index was built (Bible order when files are indexed in canon order).
export function occurrences(index, strongs) {
const key = normalizeStrongs(strongs);
const entry = key && index.get(key);
if (!entry) return { strongs: key, total: 0, books: [] };
const books = [...entry.books].map(([book, refs]) => ({
book, name: bookNameFor(book) ?? book, count: refs.length,
refs: refs.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]),
}));
return { strongs: key, total: entry.count, books };
}
// Canon order so the books come back Genesis → Revelation, whatever order the directory lists them.
const CANON = ['GEN','EXO','LEV','NUM','DEU','JOS','JDG','RUT','1SA','2SA','1KI','2KI','1CH','2CH','EZR','NEH','EST','JOB','PSA','PRO','ECC','SNG','ISA','JER','LAM','EZK','DAN','HOS','JOL','AMO','OBA','JON','MIC','NAM','HAB','ZEP','HAG','ZEC','MAL','MAT','MRK','LUK','JHN','ACT','ROM','1CO','2CO','GAL','EPH','PHP','COL','1TH','2TH','1TI','2TI','TIT','PHM','HEB','JAS','1PE','2PE','1JN','2JN','3JN','JUD','REV'];
export async function buildIndex(dir) {
const index = new Map();
const files = new Set(await readdir(dir));
for (const book of CANON) {
if (!files.has(`${book}.json`)) continue;
try { indexBook(index, book, JSON.parse(await readFile(join(dir, `${book}.json`), 'utf8'))); } catch { /* a damaged file just leaves that book out */ }
}
return index;
}
function interlinearDir() {
return [join(__dirname, '..', 'dist', 'interlinear'), join(__dirname, '..', 'public', 'interlinear')].find(existsSync) ?? null;
}
let pending = null;
const getIndex = () => {
if (!pending) {
const dir = interlinearDir();
pending = (dir ? buildIndex(dir) : Promise.resolve(new Map())).catch((err) => { pending = null; throw err; });
}
return pending;
};
export function registerStrongsRoutes(app, requireAuth) {
// GET /api/strongs/:number/occurrences
app.get('/api/strongs/:number/occurrences', requireAuth, async (req, res) => {
const key = normalizeStrongs(req.params.number);
if (!key) return res.status(400).json({ error: 'Use a Strong’s number like G4102 or H7225.' });
try {
res.json(occurrences(await getIndex(), key));
} catch (err) {
console.error('GET /api/strongs occurrences error:', err);
res.status(500).json({ error: 'Could not look up that word.' });
}
});
}