Files
bible-app/scripture-scan.js
T
christianandClaude Opus 5 0db5e9b07d Bible App 1.4.34 — full source
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>
2026-09-24 13:05:20 -04:00

154 lines
7.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
* 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);