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