'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 };