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>
201 lines
9.6 KiB
JavaScript
201 lines
9.6 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.
|
||
* 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);
|