Files
bible-app/book-aliases.js
T

251 lines
11 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/*
* book-aliases.js — turn anything the operator types into an exact Bible reference.
*
* Shared by the phone remote (remote.html) and the app (app.js falls back to it in
* __normalizeGroundingBook). Canonical names match the keys inside Translations/*.json exactly, so a
* resolved reference can be handed straight to the loader.
*
* Two rules, because this gets used live on stage:
* 1. NEVER guess between two books. "Jud" could be Judges or Jude, so it resolves to nothing and the
* remote says "not a book" rather than opening the wrong passage in front of the room.
* 2. Aliases are checked before prefixes, so conventional short forms win: "Phil" is Philippians (the
* convention) even though it also prefixes Philemon.
*
* Collisions are impossible to introduce silently: buildIndex() throws if two books claim one alias.
*
* Deliberately a CLASSIC script (window.BibleBooks), not an ES module: index.html and remote.html load
* it with a plain <script src>, and `export` syntax would be a parse error there. This package is
* "type": "module", so Node sees a .js file as ESM and the module.exports line below is inert under
* require() — load it with an eval of the file text if you ever need it in Node.
*/
(function (root) {
'use strict';
var CANON = [
'Genesis', 'Exodus', 'Leviticus', 'Numbers', 'Deuteronomy', 'Joshua', 'Judges', 'Ruth',
'1 Samuel', '2 Samuel', '1 Kings', '2 Kings', '1 Chronicles', '2 Chronicles', 'Ezra', 'Nehemiah',
'Esther', 'Job', 'Psalms', 'Proverbs', 'Ecclesiastes', 'Song of Solomon', 'Isaiah', 'Jeremiah',
'Lamentations', 'Ezekiel', 'Daniel', 'Hosea', 'Joel', 'Amos', 'Obadiah', 'Jonah', 'Micah', 'Nahum',
'Habakkuk', 'Zephaniah', 'Haggai', 'Zechariah', 'Malachi', 'Matthew', 'Mark', 'Luke', 'John', 'Acts',
'Romans', '1 Corinthians', '2 Corinthians', 'Galatians', 'Ephesians', 'Philippians', 'Colossians',
'1 Thessalonians', '2 Thessalonians', '1 Timothy', '2 Timothy', 'Titus', 'Philemon', 'Hebrews',
'James', '1 Peter', '2 Peter', '1 John', '2 John', '3 John', 'Jude', 'Revelation'
];
/*
* Deliberately absent, because each is ambiguous and a wrong book on stage is worse than a retype:
* ez (Ezra / Ezekiel) jud (Judges / Jude) jo (Job/Joel/John/Jonah/Joshua)
* ma (Matthew/Mark/Malachi) 1t (1 Thess / 1 Tim) ju (Judges / Jude) hb (Hebrews / Habakkuk)
*/
var ALIASES = {
'Genesis': ['gen', 'ge', 'gn'],
'Exodus': ['exod', 'exo', 'ex'],
'Leviticus': ['lev', 'le', 'lv'],
'Numbers': ['num', 'nu', 'nm', 'nb'],
'Deuteronomy': ['deut', 'deu', 'dt', 'de'],
'Joshua': ['josh', 'jos', 'jsh'],
'Judges': ['judg', 'jdg', 'jdgs', 'jg'],
'Ruth': ['ru', 'rth'],
'1 Samuel': ['1sam', '1sa', '1sm', '1s'],
'2 Samuel': ['2sam', '2sa', '2sm', '2s'],
'1 Kings': ['1kings', '1kgs', '1kin', '1ki', '1k'],
'2 Kings': ['2kings', '2kgs', '2kin', '2ki', '2k'],
'1 Chronicles': ['1chron', '1chr', '1ch', '1cr'],
'2 Chronicles': ['2chron', '2chr', '2ch', '2cr'],
'Ezra': ['ezr'],
'Nehemiah': ['neh', 'ne'],
'Esther': ['esth', 'est', 'es'],
'Job': ['jb'],
'Psalms': ['psalm', 'psa', 'ps', 'pss', 'pslm', 'psm', 'pslms'],
'Proverbs': ['prov', 'pro', 'prv', 'pr'],
'Ecclesiastes': ['eccles', 'eccl', 'ecc', 'ec', 'qoh'],
'Song of Solomon': ['songofsongs', 'songofsol', 'song', 'sos', 'sng', 'canticles', 'cant'],
'Isaiah': ['isa', 'is', 'isah'],
'Jeremiah': ['jer', 'je', 'jr'],
'Lamentations': ['lam', 'la'],
'Ezekiel': ['ezek', 'eze', 'ezk'],
'Daniel': ['dan', 'da', 'dn'],
'Hosea': ['hos', 'ho'],
'Joel': ['joe', 'jl'],
'Amos': ['amo', 'am'],
'Obadiah': ['obad', 'oba', 'ob'],
'Jonah': ['jon', 'jnh'],
'Micah': ['mic', 'mch', 'mi'],
'Nahum': ['nah', 'nam', 'na'],
'Habakkuk': ['hab', 'hbk'],
'Zephaniah': ['zeph', 'zep', 'zp'],
'Haggai': ['hag', 'hg'],
'Zechariah': ['zech', 'zec', 'zc'],
'Malachi': ['mal', 'ml'],
'Matthew': ['matt', 'mat', 'mt'],
'Mark': ['mrk', 'mk', 'mr'],
'Luke': ['luk', 'lk', 'lu'],
'John': ['jhn', 'joh', 'jn'],
'Acts': ['act', 'ac'],
'Romans': ['rom', 'ro', 'rm'],
'1 Corinthians': ['1cor', '1co'],
'2 Corinthians': ['2cor', '2co'],
'Galatians': ['gal', 'ga'],
'Ephesians': ['ephes', 'eph', 'ep'],
'Philippians': ['philip', 'phil', 'php', 'phi', 'pp'],
'Colossians': ['col', 'cl'],
'1 Thessalonians': ['1thess', '1thes', '1th'],
'2 Thessalonians': ['2thess', '2thes', '2th'],
'1 Timothy': ['1tim', '1ti', '1tm'],
'2 Timothy': ['2tim', '2ti', '2tm'],
'Titus': ['tit', 'ti'],
'Philemon': ['philem', 'phlm', 'phm', 'pm'],
'Hebrews': ['hebr', 'heb', 'hbr'],
'James': ['jas', 'jam', 'jm'],
'1 Peter': ['1pet', '1pe', '1pt', '1p'],
'2 Peter': ['2pet', '2pe', '2pt', '2p'],
'1 John': ['1john', '1jhn', '1jn', '1jo', '1j'],
'2 John': ['2john', '2jhn', '2jn', '2jo', '2j'],
'3 John': ['3john', '3jhn', '3jn', '3jo', '3j'],
'Jude': ['jde'],
'Revelation': ['revelations', 'rev', 'rv', 're', 'apocalypse', 'apoc']
};
// Strip everything that is not a letter or digit, so "1 Cor.", "1cor" and "I Corinthians" converge.
function key(s) { return String(s || '').toLowerCase().replace(/[^a-z0-9]/g, ''); }
// Leading book number in any form the operator might type.
function normalizeLeadingNumber(s) {
return String(s || '')
.replace(/^\s*(?:1st|first|i)\s+/i, '1 ')
.replace(/^\s*(?:2nd|second|ii)\s+/i, '2 ')
.replace(/^\s*(?:3rd|third|iii)\s+/i, '3 ')
// roman numerals with no space: "iijohn", and "icor"
.replace(/^\s*iii(?=[a-z])/i, '3 ')
.replace(/^\s*ii(?=[a-z])/i, '2 ')
.replace(/^\s*i(?=[a-z]{2})/i, '1 ');
}
var INDEX = null;
function buildIndex() {
if (INDEX) return INDEX;
var map = Object.create(null);
var claim = function (alias, book) {
var k = key(alias);
if (!k) return;
if (map[k] && map[k] !== book) {
throw new Error('book alias collision: "' + k + '" claimed by both ' + map[k] + ' and ' + book);
}
map[k] = book;
};
CANON.forEach(function (book) {
claim(book, book); // the full canonical name
(ALIASES[book] || []).forEach(function (a) { claim(a, book); });
});
INDEX = map;
return map;
}
// A prefix must be at least this long to count. Every legitimate 1-2 character short form is an
// explicit alias above ("ge", "jn", "ps", "1p"...), so this only throws away guesses: a bare "s"
// technically prefixes nothing but "Song of Solomon", and opening that because someone typed one
// letter is not the behaviour we want in front of a room.
var MIN_PREFIX = 3;
/*
* exact alias -> unique prefix of exactly one book -> nothing.
* The prefix step is what makes half-typed names work ("zecha", "philipp"); "thessal" is rejected
* because it matches two books. It never resolves when more than one book could be meant.
*/
function lookup(raw) {
var map = buildIndex();
var k = key(raw);
if (!k) return null;
if (map[k]) return map[k];
if (k.length < MIN_PREFIX) return null;
var hits = [];
for (var i = 0; i < CANON.length; i++) {
if (key(CANON[i]).indexOf(k) === 0 && hits.indexOf(CANON[i]) === -1) hits.push(CANON[i]);
}
// One match wins. Several means a numbered book typed without its number ("corinthians") or a
// genuinely shared start ("jud") — both stay unresolved on purpose.
return hits.length === 1 ? hits[0] : null;
}
function resolveBook(input) {
var raw = String(input || '').trim();
var direct = lookup(raw);
if (direct) return direct;
/*
* ONLY now consider a leading roman numeral. Doing it first was a real bug: "Isaiah" begins with
* "I", so it became "1 saiah" and resolved to 1 Samuel — the wrong book, silently. Trying the plain
* name first means any actual book beginning with I/II/III wins before the numeral rule is reached,
* and "I Cor" / "IITim" still work as a fallback.
*/
var roman = normalizeLeadingNumber(raw);
return (roman !== raw) ? lookup(roman) : null;
}
// Why a string failed, so the remote can say something useful instead of just refusing.
function explain(input) {
var raw = String(input || '').trim();
var k = key(raw);
if (!k) return 'empty';
var hits = [];
for (var i = 0; i < CANON.length; i++) if (key(CANON[i]).indexOf(k) === 0) hits.push(CANON[i]);
if (hits.length > 1) return 'did you mean ' + hits.slice(0, 3).join(', ') + '?';
return 'not a book';
}
/*
* Parse a whole reference. Accepts, among others:
* "Heb 11.4" "Heb11:4" "1 Cor 13:4-7" "1cor13" "John 3 16" "Ps 23" "Song of Songs 2:1"
* "II Timothy 2:15" "3jn 1" "Revelation 21:1-4"
* Returns { book, chapter, verse, verseEnd, canonical } or null.
*/
function parseReference(text) {
var s = String(text || '').trim().replace(/[‐-―]/g, '-').replace(/\s+/g, ' ');
if (!s) return null;
/*
* Take the numbers off the END rather than hunting for where the book stops. Splitting at the first
* digit needed a leading-numeral step first, and that step mangled real books beginning with "I":
* "Isaiah 53:5" became "1 saiah 53:5" and resolved to nothing. Anchoring at the end leaves the book
* text untouched, so resolveBook sees "Isaiah", "1cor", "I Cor" or "ps" and decides for itself.
*
* The lazy (.*?) backtracks until the trailing numbers fit, which handles every shape:
* "Isaiah 53:5" -> Isaiah / 53 / 5 "1 John 3:16" -> 1 John / 3 / 16
* "ps23" -> ps / 23 "1cor13:4" -> 1cor / 13 / 4
* "John 3 16" -> John / 3 / 16 "1 John" -> no match here; book-only below
*/
var nm = s.match(/^(.*?)\s*(\d{1,3})(?:\s*[:.\s]\s*(\d{1,3}))?(?:\s*-\s*(\d{1,3}))?\s*$/);
if (!nm) {
var bookOnly = resolveBook(s);
return bookOnly ? { book: bookOnly, chapter: null, verse: null, verseEnd: null, canonical: bookOnly } : null;
}
var book = resolveBook(nm[1]);
if (!book) return null;
var chapter = parseInt(nm[2], 10);
var verse = nm[3] ? parseInt(nm[3], 10) : null;
var verseEnd = nm[4] ? parseInt(nm[4], 10) : null;
if (!chapter || chapter < 1) return null;
// "Heb 11-13" without a verse is a chapter range we do not support; treat the second number as a
// verse range only when a verse was actually given.
if (verseEnd && !verse) verseEnd = null;
if (verseEnd && verse && verseEnd < verse) verseEnd = null;
var canonical = book + ' ' + chapter + (verse ? ':' + verse + (verseEnd ? '-' + verseEnd : '') : '');
return { book: book, chapter: chapter, verse: verse, verseEnd: verseEnd, canonical: canonical };
}
var API = {
books: CANON.slice(),
resolveBook: resolveBook,
parseReference: parseReference,
explain: explain,
_buildIndex: buildIndex
};
if (typeof module === 'object' && module.exports) module.exports = API;
root.BibleBooks = API;
})(typeof globalThis !== 'undefined' ? globalThis : this);