102 lines
3.8 KiB
JavaScript
102 lines
3.8 KiB
JavaScript
'use strict';
|
||||
|
|
|
|||
|
|
/**
|
|||
|
|
* Keyword mappings — reading the admin page's Keywords text and deciding
|
|||
|
|
* which keyword a slide's notes carry.
|
|||
|
|
*
|
|||
|
|
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
|
|||
|
|
*
|
|||
|
|
* parseKeywordLines(text) → [{ line, keyword, destination, groups, problem }]
|
|||
|
|
* keywordMatches(notes, keyword, mode) → boolean
|
|||
|
|
* matchKeyword(notes, mappings, mode) → the first mapping whose keyword the notes carry, or null
|
|||
|
|
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
|
|||
|
|
*
|
|||
|
|
* The Keywords text is one mapping per line:
|
|||
|
|
*
|
|||
|
|
* give = Give Online
|
|||
|
|
* connect -> Connect Card @ Lobby, Kids Wing
|
|||
|
|
* # a comment
|
|||
|
|
*
|
|||
|
|
* `=`, `->` and `→` all separate the keyword from the destination. A trailing
|
|||
|
|
* ` @ ` names the Tap groups this line switches (by name or id); without it the
|
|||
|
|
* admin page's chosen groups apply. The destination is the name Overflow shows
|
|||
|
|
* for it, or its id — resolving that against the account is the server's job,
|
|||
|
|
* so a parse never needs the network.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
const ZERO_WIDTH = /[-]/g;
|
|||
|
|
|
|||
|
|
function cleanNotes(raw) {
|
|||
|
|
return String(raw ?? '').replace(ZERO_WIDTH, '').replace(/\r\n?/g, '\n');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function norm(s) {
|
|||
|
|
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function parseKeywordLines(text) {
|
|||
|
|
const out = [];
|
|||
|
|
const seen = new Set();
|
|||
|
|
cleanNotes(text).split('\n').forEach((raw, i) => {
|
|||
|
|
const line = raw.trim();
|
|||
|
|
if (!line || line.startsWith('#')) return;
|
|||
|
|
const m = /^(.*?)\s*(?:=|->|→)\s*(.*)$/.exec(line);
|
|||
|
|
const entry = { line: i + 1, keyword: '', destination: '', groups: [], problem: '' };
|
|||
|
|
if (!m) {
|
|||
|
|
entry.keyword = line;
|
|||
|
|
entry.problem = 'No destination — write it as "keyword = Destination name"';
|
|||
|
|
out.push(entry);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
entry.keyword = m[1].trim();
|
|||
|
|
let rest = m[2].trim();
|
|||
|
|
const at = rest.indexOf('@');
|
|||
|
|
if (at >= 0) {
|
|||
|
|
entry.groups = rest.slice(at + 1).split(',').map((s) => s.trim()).filter(Boolean);
|
|||
|
|
rest = rest.slice(0, at).trim();
|
|||
|
|
}
|
|||
|
|
entry.destination = rest;
|
|||
|
|
if (!entry.keyword) entry.problem = 'No keyword before the =';
|
|||
|
|
else if (!entry.destination) entry.problem = 'No destination after the =';
|
|||
|
|
else if (seen.has(norm(entry.keyword))) entry.problem = 'Repeated keyword — the first line wins';
|
|||
|
|
if (entry.keyword) seen.add(norm(entry.keyword));
|
|||
|
|
out.push(entry);
|
|||
|
|
});
|
|||
|
|
return out;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function escapeRe(s) {
|
|||
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* Does this slide's notes carry the keyword?
|
|||
|
|
* word — the keyword (a word or a phrase) bounded by the start/end of the
|
|||
|
|
* notes or by something that is not a letter, digit or underscore.
|
|||
|
|
* `give` fires on "give" and "give!" but not on "forgiveness".
|
|||
|
|
* anywhere — plain substring, what Overflow's own helper does.
|
|||
|
|
* line — a line of the notes is exactly the keyword.
|
|||
|
|
* All three ignore case and collapse runs of whitespace inside a phrase.
|
|||
|
|
*/
|
|||
|
|
function keywordMatches(notes, keyword, mode = 'word') {
|
|||
|
|
const n = cleanNotes(notes);
|
|||
|
|
const k = norm(keyword);
|
|||
|
|
if (!k || !n.trim()) return false;
|
|||
|
|
if (mode === 'line') return n.split('\n').some((l) => norm(l) === k);
|
|||
|
|
const flat = n.toLowerCase().replace(/\s+/g, ' ');
|
|||
|
|
if (mode === 'anywhere') return flat.includes(k);
|
|||
|
|
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
|
|||
|
|
return re.test(flat);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/** The first mapping (in admin order) whose keyword the notes carry, or null. */
|
|||
|
|
function matchKeyword(notes, mappings, mode = 'word') {
|
|||
|
|
for (const m of mappings || []) {
|
|||
|
|
if (!m || !m.keyword) continue;
|
|||
|
|
if (keywordMatches(notes, m.keyword, mode)) return m;
|
|||
|
|
}
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
module.exports = { cleanNotes, norm, parseKeywordLines, keywordMatches, matchKeyword };
|