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>
90 lines
4.0 KiB
JavaScript
90 lines
4.0 KiB
JavaScript
// "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.' });
|
||
}
|
||
});
|
||
}
|