Files
bible-app/scripture-scan.js
T
christianandClaude Opus 5.5 e7e527197f Add the Browser tab: a live web page with no browser chrome
A new tab (globe, after Maps) shows one web page full-bleed in the content area —
no address bar, bookmarks or scrollbars — built for It Is Written but usable with
any page. It loads at startup and keeps running in the background.

Stage (browser-tab.js, browser-guest-preload.cjs):
- An Electron <webview> per Browser tab, in its own sandboxed process and persistent
  session. The pages live in one persistent layer inside .tab-content: every tab
  switch rebuilds .tab-content and moving a webview reloads it, so app.js now clears
  around that layer (clearTabContentKeepingBrowser).
- Tapping a text field brings up the Prayer tab's keyboard (createPrayerKeyboard with
  a sink) and types into the page with trusted input; Enter submits; the keyboard puts
  itself away when the page moves focus on (e.g. after a search). The OS touch
  keyboard is suppressed with inputmode="none" while a field is focused.
- Tapping a scripture reference opens it in a new Bible tab, in the translation
  printed beside it when that one is switched on. References are found with the app's
  own scanner: scripture-scan.js gains scanAll() (every match with its offsets),
  translationAfter() and isTranslation(); scanText/parseSlide are unchanged.
- Input inside a webview never reaches the app, so the page reports activity (idle
  splash) and five-finger touches (blank the screen) itself.
- main.cjs: webviewTag on; every webview is forced sandboxed, context-isolated, no Node,
  http(s) only, with the guest preload. No new windows (opened in place), no permission
  prompts, no downloads, no pinch-zoom, no alert/confirm/prompt boxes, and "Leave
  site?" can never block Reload or Start page. The session reports plain Chrome.
- server.js CSP: frame-src allows http(s) (a webview is a frame to CSP); style-src and
  font-src allow https (the follower's srcdoc copy inherits this policy).

Everywhere a tab kind is listed:
- app.js: create at startup and on Reset, icon, visibility defaults, + menu, fixed
  mode, no ink layer, tool handling, OSC kind + information line (the page's search
  term), phone remote scroll / tap / page buttons, OSC show / reload / home actions,
  live setting changes, translation for tapped references.
- quick-dock, tool-wheel, presentation-tools: no drawing tools on the tab.
- Phone remote: tab label, one finger scrolls the page (taps still tap), hint text;
  mirror-stream.js reports the page's scroll for the phone's local preview.
- Follower screen: the stage publishes a script-free copy of the page's DOM (only while
  a follower is linked), its scroll and form fields; the follower shows it in
  sandboxed, double-buffered frames; the relay keeps the page for late joiners.
- OSC: /bibleapp/browser, /bibleapp/browser/reload, /bibleapp/browser/home;
  tab_kind/browser feedback. Admin and phone allow-lists updated.
- Settings: Tab Visibility, a Browser Tab section (start page, page size, scripture
  taps, return to start page), Service Profiles carry its settings, OSC references.
- Admin console: show the tab, reload / start page, start page and page size.
- BROWSER_TAB.md documents it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:25:15 -04:00

201 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
* scanAll(text) — EVERY such reference, each with the [start, end) it occupies in the text as
* given. Same rules as scanText; used by the Browser tab to find the reference
* under a finger on a web page.
*
* 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;
}
/*
* scanText's rules, applied to the whole text, keeping WHERE each reference sits. The normalisation is
* character-for-character (a dash stays one dash, every odd space stays one space — nothing collapses,
* nothing is trimmed), so start/end index the caller's own string and map straight back onto the text
* nodes of a web page. `start` skips punctuation glued to the book ("(John 3:16" starts at "John").
*/
function scanAll(text) {
var B = books();
var s = String(text || '').replace(/[‐-―−]/g, '-').replace(/[  -​\s]/g, ' ');
var out = [];
if (!B || !s.trim()) return out;
var re = new RegExp(CHAPTER_VERSE.source, 'g'); // never share scanText's lastIndex
var m;
while ((m = re.exec(s))) {
var before = s.slice(0, m.index).replace(/[\s"'“”‘’(\[]+$/, '');
var found = null;
// Longest run first, exactly as scanText: "1 John" before "John", "Song of Songs" before "Songs".
for (var n = 4; n >= 1 && !found; n--) {
var tail = before.match(new RegExp('(\\S+(?:\\s+\\S+){' + (n - 1) + '})$'));
if (!tail) continue;
var run = tail[1].split(/\s+/);
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;
var book = resolveCandidate(run);
if (book) found = { book: book, start: tail.index + (run[0].length - head.length) };
}
if (!found) continue;
var ref = makeRef(found.book, m[1], m[2], m[3] ? null : m[4]);
if (ref) out.push({ ref: ref, start: found.start, end: m.index + m[0].length });
}
return out;
}
// The translation printed right after a reference — "John 3:16 (NIV)", "Isaiah 41:10NIV" — or null.
var TRANSLATION_AFTER = new RegExp('^\\s*[-,:;]?\\s*[\\(\\[]?\\s*(' + TRANSLATIONS + ')(?![A-Za-z0-9])', 'i');
function translationAfter(text, index) {
var m = String(text || '').slice(index || 0).match(TRANSLATION_AFTER);
return m ? m[1].toUpperCase() : null;
}
function isTranslation(code) {
return new RegExp('^(?:' + TRANSLATIONS + ')$', 'i').test(String(code || '').trim());
}
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, scanAll: scanAll, parseSlide: parseSlide, stripTranslation: stripTranslation,
translationAfter: translationAfter, isTranslation: isTranslation };
if (typeof module === 'object' && module.exports) module.exports = API;
root.ScriptureScan = API;
})(typeof globalThis !== 'undefined' ? globalThis : this);