Files
ProdDash/modules/overflow-tap/keywords.js
T
Waters ChurchandClaude Fable 5.1 6b51044be6 Overflow Tap 1.0.0: switch Overflow Tap destinations from keywords in ProPresenter slide notes
A new module that does what Overflow's "ProPresenter Integration" helper app
does, inside ProdDash: the live slide's notes are read from ProPresenter
(GET /v1/status/slide, twice a second) and when they carry one of the admin
page's keywords, the chosen Overflow Tap groups are pointed at that keyword's
destination. One tile: the keyword Tap is on (and since when), its
destination and the groups moved, a chip saying whether the slide on screen
carries a keyword, and the last four switches. A failed switch turns the tile
red with the reason, retries three times, and can flash an alert channel.

Admin config: ProPresenter endpoint + password; Overflow client ID + API key
(x-client-id / x-api-key, the public API's two headers); Tap groups to switch
(multiselect read from the account over /groups); keywords as text, one per
line (`give = Give Online`, optionally `@ Lobby, Kids Wing`); match mode
(whole word / anywhere / own line); alerts switch + channel.

The one unverified piece: Overflow's public API documents only reads for Tap
(OpenAPI checked 2026-10-07), while Overflow's helper uses the Dashboard's
private API. The switch is sent as PATCH /api/v3/tap/groups/{id} with the new
destinationId — the natural shape for this API — and every switch is confirmed
by reading the group back, so a refusal shows on the tile instead of passing
silently. overflow.js is the one place to change if Overflow names a
different route.

Also: tools/overflow-tap-mock.js (a ProPresenter whose slide notes carry
keywords on :1602 plus the Overflow Tap API on :24720, --read-only to fail
every switch), keywords.selftest.js (14 cases), README + CLAUDE.md entries.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-07 14:00:37 -04:00

102 lines
3.8 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.
'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 };