First commit on the local Gitea server, made after moving the tree off the Windows machine. Build outputs, node_modules and the AI models stay out (see .gitignore); everything the installer build needs is tracked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
251 lines
11 KiB
JavaScript
251 lines
11 KiB
JavaScript
/*
|
||
* 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);
|