2026-10-07 14:00:37 -04:00
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* Keyword matching — which of Overflow's keywords a slide's notes carry.
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
|
|
|
|
|
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
|
|
|
|
|
|
*
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
|
|
|
|
|
|
* keywordMatches(notes, keyword) → boolean
|
|
|
|
|
|
* matchKeywords(notes, keywords) → every keyword (in the order given) the notes carry
|
|
|
|
|
|
* sameSet(a, b) → the two keyword lists hold the same keywords
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*
|
2026-10-11 10:25:02 -04:00
|
|
|
|
* The rule is Overflow's own helper's: the notes are split into whitespace-
|
|
|
|
|
|
* separated words, each stripped of surrounding punctuation and lower-cased,
|
|
|
|
|
|
* and a keyword fires when it is one of those words. So `give` fires on
|
|
|
|
|
|
* "give", "Give!", "#give" and "(give)", and not on "forgiveness", "giveaway"
|
|
|
|
|
|
* or "give_now". Phrases work too, bounded the same way on both ends —
|
|
|
|
|
|
* something the helper cannot do, but nothing it would have matched is lost.
|
2026-10-07 14:00:37 -04:00
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
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 escapeRe(s) {
|
|
|
|
|
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
function keywordMatches(notes, keyword) {
|
2026-10-07 14:00:37 -04:00
|
|
|
|
const n = cleanNotes(notes);
|
|
|
|
|
|
const k = norm(keyword);
|
|
|
|
|
|
if (!k || !n.trim()) return false;
|
|
|
|
|
|
const flat = n.toLowerCase().replace(/\s+/g, ' ');
|
|
|
|
|
|
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
|
|
|
|
|
|
return re.test(flat);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
/** Every keyword the notes carry, in the order the list gives them, each once. Entries may be strings or objects with a `keyword`. */
|
|
|
|
|
|
function matchKeywords(notes, keywords) {
|
|
|
|
|
|
const out = [];
|
|
|
|
|
|
const seen = new Set();
|
|
|
|
|
|
for (const raw of keywords || []) {
|
|
|
|
|
|
const k = typeof raw === 'string' ? raw : raw && raw.keyword;
|
|
|
|
|
|
const key = norm(k);
|
|
|
|
|
|
if (!key || seen.has(key)) continue;
|
|
|
|
|
|
if (keywordMatches(notes, k)) {
|
|
|
|
|
|
seen.add(key);
|
|
|
|
|
|
out.push(String(k).trim());
|
|
|
|
|
|
}
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
2026-10-11 10:25:02 -04:00
|
|
|
|
return out;
|
2026-10-07 14:00:37 -04:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-11 10:25:02 -04:00
|
|
|
|
function sameSet(a, b) {
|
|
|
|
|
|
const x = [...(a || [])].map(norm).sort();
|
|
|
|
|
|
const y = [...(b || [])].map(norm).sort();
|
|
|
|
|
|
return x.length === y.length && x.every((v, i) => v === y[i]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
module.exports = { cleanNotes, norm, keywordMatches, matchKeywords, sameSet };
|