154 lines
7.1 KiB
JavaScript
154 lines
7.1 KiB
JavaScript
/*
|
||||
|
|
* 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);
|