Files
study-bible-project/server/strongs.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

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