Files
bible-app/scripture-scan.js
T

154 lines
7.1 KiB
JavaScript
Raw Normal View History

2026-09-24 13:05:20 -04:00
/*
* scripture-scan.js — work out which scripture a ProPresenter slide is about.
*
* Used by the ProPresenter Sync tab (propresenter-sync.js). Three sources on a slide, tried in order,
* first hit wins (PDR §04): the slide LABEL, then the slide NOTES, then the DISPLAY TEXT itself.
*
* parseField(text) — the whole field IS a reference ("John 3:16 (NIV)", "1 Cor 13:4–7", "Ps 23",
* "vv. 4-7 Romans 8"), decorations stripped. Falls back to scanText.
* scanText(text) — the FIRST "Book chapter:verse(-verse)" inside free prose ("…as it says in
* Rev. 21:4, God will…"). Needs the colon (or "Heb 11.4"), so a chapter-only
* mention in a sentence never fires — too ambiguous on stage.
*
* Book names go through window.BibleBooks (book-aliases.js), which refuses to guess between two books.
* One extra rule here: a one/two-letter alias found in prose ("is", "am", "re") only counts when it is
* capitalised in the slide ("Is 53:5" yes, "the service is 3:16 long" no).
*
* Classic script (window.ScriptureScan), like book-aliases.js, so a plain <script src> loads it; the
* module.exports line is inert under ESM and only matters when the file is eval'd for a Node test.
*/
(function (root) {
'use strict';
var TRANSLATIONS = 'NIV|ESV|NLT|KJV|NKJV|NASB|NASB1995|NASB2020|CSB|HCSB|MSG|AMP|AMPC|NRSV|NRSVUE|RSV|CEV|GNT|TLB|NET|ASV|WEB|YLT|BSB|LSB|NABRE|NJB|NCV|NIRV|ERV|GW|ICB|TPT|VOICE|CEB|CJB|DARBY|GNV|LEB|MEV|NLV|NIV84|NIV2011|NIVUK|ESVUK|KJ21|AKJV|TLV|ISV|NMB|NTE|EHV|EXB';
var TRAILING_TRANSLATION = new RegExp('\\s*[-,:;]?\\s*[\\(\\[]?\\b(?:' + TRANSLATIONS + ')\\b[\\)\\]]?\\s*$', 'i');
// Leading "1"/"2"/"3" of a numbered book, in any of the forms people type.
var BOOK_NUMBER = /^(?:[1-3]|I{1,3}|1st|2nd|3rd|first|second|third)$/i;
function books() { return root.BibleBooks || null; }
// Unicode dashes and odd spaces to plain ones; keeps everything else.
function norm(s) {
return String(s || '').replace(/[\u2010-\u2015\u2212]/g, '-').replace(/[\u00A0\u2000-\u200B\s]+/g, ' ').trim();
}
// "Romans 8:28 (NIV)" -> "Romans 8:28"; "John 3:16 NLT" -> "John 3:16"; repeats for "ESV NIV".
function stripTranslation(s) {
var t = norm(s), prev;
do { prev = t; t = t.replace(TRAILING_TRANSLATION, ''); } while (t && t !== prev);
return t.replace(/[\s\-,:;.]+$/, '').trim();
}
function makeRef(book, chapter, verse, verseEnd) {
chapter = parseInt(chapter, 10);
verse = verse ? parseInt(verse, 10) : null;
verseEnd = verseEnd ? parseInt(verseEnd, 10) : null;
if (!book || !chapter || chapter < 1) return null;
if (verse !== null && verse < 1) verse = null;
if (verseEnd !== null && (!verse || verseEnd <= verse)) verseEnd = null;
var canonical = book + ' ' + chapter + (verse ? ':' + verse + (verseEnd ? '-' + verseEnd : '') : '');
return { book: book, chapter: chapter, verse: verse, verseEnd: verseEnd, canonical: canonical };
}
/*
* A book candidate taken from prose. `words` are the tokens immediately before "c:v", longest run
* first, so "1 John" is tried before "John" and "Song of Songs" before "Songs".
*/
function resolveCandidate(words) {
var B = books();
if (!B || !words.length) return null;
var cand = words.join(' ').replace(/^[^A-Za-z0-9]+/, '');
if (!/[A-Za-z]/.test(cand)) return null;
var book = B.resolveBook(cand);
if (!book) return null;
// Short alias in prose must be capitalised ("Is 53:5"); a bare number ("1 Jn") is fine.
var letters = cand.replace(/[^A-Za-z]/g, '');
if (letters.length <= 2) {
var firstAlpha = cand.match(/[A-Za-z]/);
if (!firstAlpha || firstAlpha[0] !== firstAlpha[0].toUpperCase()) return null;
}
return book;
}
// "Book c:v", "Book c:v-v", "Book c:v-c:v" (cross-chapter range opens at its start), "Book c.v".
var CHAPTER_VERSE = /(\d{1,3})\s*[:.]\s*(\d{1,3})(?:\s*-\s*(?:(\d{1,3})\s*[:.]\s*)?(\d{1,3}))?/g;
function scanText(text) {
var B = books();
var s = norm(text);
if (!B || !s) return null;
CHAPTER_VERSE.lastIndex = 0;
var m;
while ((m = CHAPTER_VERSE.exec(s))) {
var before = s.slice(0, m.index).replace(/[\s"'“”‘’(\[]+$/, '');
// Up to four words back: "in 1 Corinthians", "the Song of Solomon".
var tail = before.match(/(\S+(?:\s+\S+){0,3})$/);
if (!tail) continue;
var words = tail[1].split(/\s+/);
var book = null;
for (var n = words.length; n >= 1 && !book; n--) {
var run = words.slice(words.length - n);
// The run must start with a letter or a book number — never with a stray word glued by punctuation.
var head = run[0].replace(/^[^A-Za-z0-9]+/, '');
if (!(/^[A-Za-z]/.test(head) || BOOK_NUMBER.test(head.replace(/[^A-Za-z0-9]/g, '')))) continue;
book = resolveCandidate(run);
}
if (!book) continue;
var crossChapter = !!m[3];
return makeRef(book, m[1], m[2], crossChapter ? null : m[4]);
}
return null;
}
function parseField(text) {
var B = books();
var t = stripTranslation(text);
if (!B || !t) return null;
// Decorations a label/notes field often carries: quotes, "vv.", "verses", "ch.", trailing punctuation.
t = t.replace(/\b(?:vv?|verses?|ch(?:apter)?)\.?\s*(?=\d)/gi, '')
.replace(/^[\s"'“”‘’\-:;,.\(\[]+|[\s"'“”‘’\-:;,.\)\]]+$/g, '')
.trim();
if (!t) return null;
var p = null;
try { p = B.parseReference(t); } catch (_) { p = null; }
if (p && p.chapter) return makeRef(p.book, p.chapter, p.verse, p.verseEnd);
return scanText(t);
}
// A field that is one reference per line ("John 3:16\nNIV") is common; try lines before the whole.
function parseLinesThenScan(text) {
var s = String(text || '');
if (!s.trim()) return null;
var lines = s.split(/\r?\n|\u2028|\u2029/);
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line.length > 60) continue; // a long line is prose, not a reference
var r = parseField(line);
if (r) return r;
}
return scanText(s);
}
/*
* slide: { label, notes, text }. Returns { ref, source } or null. Label and notes are treated as
* structured fields; the display text is prose. If the notes hold a reference, the text is never read.
*/
function parseSlide(slide) {
if (!slide || typeof slide !== 'object') return null;
var order = [['label', parseField], ['notes', parseLinesThenScan], ['text', parseLinesThenScan]];
for (var i = 0; i < order.length; i++) {
var key = order[i][0];
var val = slide[key];
if (typeof val !== 'string' || !val.trim()) continue;
var r = null;
try { r = order[i][1](val); } catch (_) { r = null; }
if (r) return { ref: r, source: key };
}
return null;
}
var API = { parseField: parseField, scanText: scanText, parseSlide: parseSlide, stripTranslation: stripTranslation };
if (typeof module === 'object' && module.exports) module.exports = API;
root.ScriptureScan = API;
})(typeof globalThis !== 'undefined' ? globalThis : this);