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>
This commit is contained in:
Waters Church
2026-10-07 14:00:37 -04:00
co-authored by Claude Fable 5.1
parent c9e03398a6
commit 6b51044be6
10 changed files with 1692 additions and 2 deletions
+101
View File
@@ -0,0 +1,101 @@
'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 };