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:
co-authored by
Claude Fable 5.1
parent
c9e03398a6
commit
6b51044be6
@@ -0,0 +1,115 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-test for keywords.js — the parsing and matching rules, pinned.
|
||||
*
|
||||
* node modules/overflow-tap/keywords.selftest.js
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { parseKeywordLines, keywordMatches, matchKeyword, cleanNotes } = require('./keywords');
|
||||
|
||||
let n = 0;
|
||||
function test(name, fn) {
|
||||
n += 1;
|
||||
try {
|
||||
fn();
|
||||
console.log(`ok ${name}`);
|
||||
} catch (err) {
|
||||
console.log(`FAIL ${name}\n ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
test('parses keyword = destination, with -> and → too', () => {
|
||||
const m = parseKeywordLines('give = Give Online\nconnect -> Connect Card\nprayer → Prayer Request');
|
||||
assert.deepEqual(m.map((x) => [x.keyword, x.destination, x.problem]), [
|
||||
['give', 'Give Online', ''],
|
||||
['connect', 'Connect Card', ''],
|
||||
['prayer', 'Prayer Request', ''],
|
||||
]);
|
||||
});
|
||||
|
||||
test('a trailing @ names the groups for that line', () => {
|
||||
const [m] = parseKeywordLines('connect = Connect Card @ Lobby, Kids Wing');
|
||||
assert.equal(m.destination, 'Connect Card');
|
||||
assert.deepEqual(m.groups, ['Lobby', 'Kids Wing']);
|
||||
});
|
||||
|
||||
test('blank lines and # comments are skipped, line numbers kept', () => {
|
||||
const m = parseKeywordLines('\n# giving\n\ngive = Give Online\n');
|
||||
assert.equal(m.length, 1);
|
||||
assert.equal(m[0].line, 4);
|
||||
});
|
||||
|
||||
test('a line without a separator is a problem, not a crash', () => {
|
||||
const [m] = parseKeywordLines('give');
|
||||
assert.equal(m.keyword, 'give');
|
||||
assert.match(m.problem, /No destination/);
|
||||
});
|
||||
|
||||
test('missing keyword or destination is reported', () => {
|
||||
const [a, b] = parseKeywordLines('= Give Online\ngive =');
|
||||
assert.match(a.problem, /No keyword/);
|
||||
assert.match(b.problem, /No destination/);
|
||||
});
|
||||
|
||||
test('a repeated keyword is flagged and the first line wins', () => {
|
||||
const m = parseKeywordLines('give = A\nGIVE = B');
|
||||
assert.equal(m[0].problem, '');
|
||||
assert.match(m[1].problem, /Repeated/);
|
||||
assert.equal(matchKeyword('give', m).destination, 'A');
|
||||
});
|
||||
|
||||
test('keywords and destinations may be phrases', () => {
|
||||
const [m] = parseKeywordLines('next steps = Next Steps Card');
|
||||
assert.equal(m.keyword, 'next steps');
|
||||
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps', 'word'), true);
|
||||
});
|
||||
|
||||
test('whole word: give does not fire on forgiveness, does on "give!"', () => {
|
||||
assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'word'), false);
|
||||
assert.equal(keywordMatches('give!', 'give', 'word'), true);
|
||||
assert.equal(keywordMatches('Give', 'give', 'word'), true);
|
||||
assert.equal(keywordMatches('#give now', 'give', 'word'), true);
|
||||
assert.equal(keywordMatches('giveaway', 'give', 'word'), false);
|
||||
assert.equal(keywordMatches('give_now', 'give', 'word'), false);
|
||||
});
|
||||
|
||||
test('anywhere: plain substring, like the Overflow helper', () => {
|
||||
assert.equal(keywordMatches('a sermon on forgiveness', 'give', 'anywhere'), true);
|
||||
assert.equal(keywordMatches('nothing here', 'give', 'anywhere'), false);
|
||||
});
|
||||
|
||||
test('line: the keyword on its own line only', () => {
|
||||
assert.equal(keywordMatches('speaker note\ngive\nmore', 'give', 'line'), true);
|
||||
assert.equal(keywordMatches('give now', 'give', 'line'), false);
|
||||
assert.equal(keywordMatches(' Give ', 'give', 'line'), true);
|
||||
});
|
||||
|
||||
test('regex characters in a keyword are literal', () => {
|
||||
assert.equal(keywordMatches('go to (give) now', '(give)', 'word'), true);
|
||||
assert.equal(keywordMatches('give.now', 'give.now', 'anywhere'), true);
|
||||
assert.equal(keywordMatches('giveXnow', 'give.now', 'anywhere'), false);
|
||||
});
|
||||
|
||||
test('empty notes never match; zero-width padding is ignored', () => {
|
||||
assert.equal(keywordMatches('', 'give'), false);
|
||||
assert.equal(keywordMatches('', 'give', 'anywhere'), false);
|
||||
assert.equal(cleanNotes('give'), 'give');
|
||||
assert.equal(keywordMatches('give', 'give', 'line'), true);
|
||||
});
|
||||
|
||||
test('matchKeyword returns the first mapping in admin order', () => {
|
||||
const m = parseKeywordLines('connect = Connect Card\ngive = Give Online');
|
||||
assert.equal(matchKeyword('give and connect', m).keyword, 'connect');
|
||||
assert.equal(matchKeyword('just worship', m), null);
|
||||
assert.equal(matchKeyword('give', []), null);
|
||||
});
|
||||
|
||||
test('a mapping with a parse problem can still be the match (the caller decides)', () => {
|
||||
const m = parseKeywordLines('baptism');
|
||||
assert.equal(matchKeyword('baptism', m).problem.length > 0, true);
|
||||
});
|
||||
|
||||
console.log(`${n} tests, exit ${process.exitCode || 0}`);
|
||||
Reference in New Issue
Block a user