diff --git a/CLAUDE.md b/CLAUDE.md index 572c2c3..a21ee7f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,6 +52,7 @@ title-bar controls). The rules that matter most: `node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`). `node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). `node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`). + `node tools/overflow-tap-mock.js` (ProPresenter slide notes on `127.0.0.1:1602` + the Overflow Tap API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; `--read-only` fails every switch). ## Reference code (read-only forks — don't develop these here) diff --git a/README.md b/README.md index 2fe2a0f..47e18b1 100644 --- a/README.md +++ b/README.md @@ -510,6 +510,26 @@ below. Nothing is installed without someone clicking. read from the account), and a folded **Timing** group for the poll intervals and a position trim. Tile settings: cue name, previous cue, playhead, cue list. +- **Overflow Tap** — what Overflow's own ProPresenter helper app does, + inside ProdDash: the live slide's notes are read from ProPresenter, and + when they carry one of the admin page's keywords, the chosen Overflow Tap + groups are pointed at that keyword's destination — so a tap on a disc + follows what is on screen. A slide with no keyword changes nothing. The + tile is one keyword, big: what 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 with their times; a failed switch + turns the tile red with the reason and, if switched on in admin, flashes + an alert channel. Admin config: the ProPresenter endpoint, the Overflow + API credentials (Developers › API Keys in the Overflow Dashboard), the Tap + groups to switch (read from the account), the keywords — one per line as + `give = Give Online`, optionally `@ Lobby, Kids Wing` to switch only + those groups — and how a keyword matches (whole word, anywhere, or on its + own line). Tile settings are four switches: destination, groups, slide + match, history. **One thing is still unverified:** Overflow's public API + documents only *reads* for Tap, so the call that moves a group (`PATCH + /api/v3/tap/groups/{id}` with the new `destinationId`) is the natural + shape, not a documented one; every switch is confirmed by reading the + group back, so a refusal shows on the tile rather than passing silently. - **Band Lineup** — the backstage stage board: everyone on stage in their mic slot with photo, name and mic/pack label, on one shared board that every screen sees the same way — whoever edits it changes it everywhere, over SSE. @@ -560,8 +580,12 @@ timers + timecode on `127.0.0.1:1600`), `node tools/pco-mock.js` run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`) and `node tools/pco-band-mock.js 24710` (Planning Center for the Band Lineup board — a plan, its team members, birthdays and photos — run ProdDash with -`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of -the [module guide](docs/MODULE-GUIDE.md). +`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and +`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry +keywords on `127.0.0.1:1602` plus the Overflow Tap API on `127.0.0.1:24720` +— run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`; +`--read-only` makes the switch fail the way the public API does today). See +the testing section of the [module guide](docs/MODULE-GUIDE.md). ## Install as a desktop web app diff --git a/modules/overflow-tap/client.js b/modules/overflow-tap/client.js new file mode 100644 index 0000000..29f3a46 --- /dev/null +++ b/modules/overflow-tap/client.js @@ -0,0 +1,231 @@ +/* Overflow Tap — the tile. + * + * One fact matters across a booth: which keyword Tap is on right now, and + * whether the switch actually landed. That is the big line. Under it, the + * destination it maps to and the groups it moved; a small line says whether + * the slide on screen carries a keyword at all; and the last four switches + * sit at the bottom with their times, so a tech can see the service's trail. + * + * Everything comes from the server's `state` stream. The tile never talks to + * ProPresenter or Overflow itself. + */ + +const HISTORY_SHOWN = 4; + +const ICON_REFRESH = ''; + +export default function create({ root, moduleApi }) { + let state = null; + let stream = null; + let refreshBtn = null; + let streamDown = false; // the shared ProdDash stream dropped; cleared by the next state + + const settings = () => moduleApi.instanceSettings || {}; + const on = (key) => settings()[key] !== false; + + function el(tag, className, text) { + const node = document.createElement(tag); + if (className) node.className = className; + if (text !== undefined && text !== null) node.textContent = text; + return node; + } + + function clock(ms) { + if (!ms) return ''; + try { + return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } + } + + /** The one-line reason the tile cannot do its job, or '' when it can. */ + function blocker() { + if (!state) return 'Waiting for ProdDash'; + const pp = state.propresenter || {}; + const of = state.overflow || {}; + if (!pp.configured) return 'No ProPresenter host — set one in /admin'; + if (!of.configured) return 'No Overflow API key — add it in /admin'; + if (!(state.keywords || []).length) return 'No keywords — add them in /admin'; + return ''; + } + + function render() { + root.textContent = ''; + const box = el('div', 'ot-root'); + root.append(box); + + const stop = blocker(); + if (stop) { + box.append(el('div', 'ot-empty', stop)); + return; + } + + const pp = state.propresenter || {}; + const of = state.overflow || {}; + const cur = state.current; + const offline = streamDown || !pp.reachable || !of.reachable; + + if (offline) { + box.classList.add('is-offline'); + const why = streamDown ? 'ProdDash connection lost' + : !of.reachable ? (of.error || 'Overflow unreachable') + : (pp.error ? `ProPresenter unreachable — ${pp.error}` : 'ProPresenter unreachable'); + box.append(el('div', 'ot-offline', why)); + } + + const main = el('div', 'ot-main'); + if (cur) { + // The label carries the state and, when there is one, the moment it + // became true; the small lines under the destination are the groups + // moved — or, when the switch failed or was undone in Overflow, what + // went wrong, in their place (a tile has room for one or the other). + let label = `TAP IS ON · since ${clock(cur.at)}`; + let tone = 'ok'; + let note = ''; + let noteClass = 'ot-error'; + if (cur.pending) { + label = cur.attempts > 1 ? `SWITCHING · attempt ${cur.attempts}` : 'SWITCHING'; + tone = 'muted'; + } else if (!cur.ok) { + label = `NOT SWITCHED · ${clock(cur.at)}`; + tone = 'alert'; + note = cur.error || ''; + } else if (cur.drift) { + label = `TAP WAS ON · since ${clock(cur.at)}`; + tone = 'warn'; + note = cur.drift; + noteClass = 'ot-error is-warn'; + } + main.dataset.tone = tone; + main.append(el('div', 'ot-label', label)); + main.append(el('div', 'ot-keyword', cur.keyword)); + if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination ? cur.destination.name : '')); + if (note) main.append(el('div', noteClass, note)); + else if (on('showGroups') && cur.groups && cur.groups.length) main.append(el('div', 'ot-groups', cur.groups.join(' · '))); + } else { + main.dataset.tone = 'muted'; + main.append(el('div', 'ot-label', 'NO KEYWORD YET')); + main.append(el('div', 'ot-keyword is-none', '—')); + main.append(el('div', 'ot-dest', 'Tap stays where Overflow left it')); + } + box.append(main); + + if (on('showSlide')) { + const slide = state.slide || {}; + let text; + let cls = 'ot-slide'; + if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; } + else if (!slide.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; } + else if (slide.unmapped) { text = `This slide: ${slide.keyword} — ${slide.unmapped}`; cls += ' is-warn'; } + else if (slide.keyword) { text = `This slide: ${slide.keyword}`; cls += ' is-hit'; } + else { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; } + box.append(el('div', cls, text)); + } + + if (on('showHistory')) { + const rows = (state.history || []).slice(0, HISTORY_SHOWN); + const list = el('ol', 'ot-history'); + if (!rows.length) { + list.append(el('li', 'ot-h-empty', cur ? 'No earlier switches' : 'No switches yet')); + } + for (const h of rows) { + const row = el('li', `ot-h-row${h.ok ? '' : ' is-failed'}`); + row.title = h.ok + ? `${h.keyword} → ${h.destination ? h.destination.name : ''}${h.groups && h.groups.length ? ` on ${h.groups.join(', ')}` : ''}` + : `${h.keyword}: ${h.error || 'failed'}`; + row.append(el('span', 'ot-h-time', clock(h.at))); + row.append(el('span', 'ot-h-kw', h.keyword)); + row.append(el('span', 'ot-h-dest', h.destination ? h.destination.name : '')); + row.append(el('span', 'ot-h-mark', h.ok ? '✓' : '✕')); + list.append(row); + } + box.append(list); + } + } + + function applyStatus() { + if (!state) return moduleApi.setStatus('connecting', 'Waiting for ProdDash'); + const s = state.status === 'ok' || state.status === 'connecting' || state.status === 'error' ? state.status : 'connecting'; + moduleApi.setStatus(s, state.message || ''); + } + + function handleState(e) { + try { + state = JSON.parse(e.data); + } catch { + return; + } + streamDown = false; + applyStatus(); + render(); + } + + async function backfill() { + try { + const res = await moduleApi.fetch('/state'); + if (!res.ok) return; + const body = await res.json(); + if (!body || !body.state || !stream) return; + state = body.state; + streamDown = false; + applyStatus(); + render(); + } catch { + /* the stream will bring the next state */ + } + } + + async function refresh() { + if (!refreshBtn) return; + refreshBtn.classList.add('active'); + try { + const res = await moduleApi.fetch('/refresh', { method: 'POST' }); + if (!res.ok) { + let why = `Overflow refresh failed (${res.status})`; + try { why = (await res.json()).error || why; } catch { /* keep the status */ } + moduleApi.setStatus('error', why); + } + } catch (err) { + moduleApi.setStatus('error', `Overflow refresh failed: ${err && err.message ? err.message : err}`); + } finally { + refreshBtn.classList.remove('active'); + } + } + + return { + start() { + moduleApi.setStatus('connecting', 'Connecting'); + refreshBtn = moduleApi.header.addButton({ + icon: ICON_REFRESH, + title: 'Reload Overflow', + onClick: refresh, + }); + stream = moduleApi.sse('/stream', { + // A fresh connection replays the state; a tile joining a stream the + // page already carries gets only `open`, and the server sends state + // on change alone — so backfill, or a second tile waits for the next + // switch to draw anything. + open() { backfill(); }, + error() { + moduleApi.setStatus('error', 'Lost the connection to ProdDash'); + streamDown = true; + render(); + }, + events: { state: handleState }, + }); + render(); + }, + + stop() { + if (stream) stream.close(); + stream = null; + refreshBtn = null; + root.textContent = ''; + }, + + onConfigChange() { + render(); + }, + }; +} diff --git a/modules/overflow-tap/keywords.js b/modules/overflow-tap/keywords.js new file mode 100644 index 0000000..6727b9a --- /dev/null +++ b/modules/overflow-tap/keywords.js @@ -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 }; diff --git a/modules/overflow-tap/keywords.selftest.js b/modules/overflow-tap/keywords.selftest.js new file mode 100644 index 0000000..049dde8 --- /dev/null +++ b/modules/overflow-tap/keywords.selftest.js @@ -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}`); diff --git a/modules/overflow-tap/module.json b/modules/overflow-tap/module.json new file mode 100644 index 0000000..4fec966 --- /dev/null +++ b/modules/overflow-tap/module.json @@ -0,0 +1,144 @@ +{ + "id": "overflow-tap", + "name": "Overflow Tap", + "version": "1.0.0", + "proddash": ">=1.11.0", + "description": "Switches Overflow Tap destinations from keywords in ProPresenter slide notes", + "permissions": [ + { + "kind": "local-network", + "reason": "Reaching ProPresenter on this network" + } + ], + "client": "client.js", + "server": "server.js", + "style": "style.css", + "minSize": { + "w": 2, + "h": 2 + }, + "defaultSize": { + "w": 4, + "h": 3 + }, + "configGroups": { + "ProPresenter": { + "help": "The ProPresenter whose slide notes carry the keywords. Enable the network in ProPresenter's Preferences › Network and enter the host and port shown there. Only the live slide's notes are read — nothing on the stage screens changes." + }, + "Overflow": { + "collapsed": "whenSet", + "help": "API credentials from the Overflow Dashboard: Developers › API Keys (a READ_WRITE key). Tap groups and destinations are read from the account once these are saved; the groups list below fills in from there." + }, + "Keywords": { + "help": "One keyword per line, as `keyword = Destination name` — the destination exactly as it is named in Overflow's Tap module (or its id). Add ` @ Group A, Group B` to a line to switch only those groups instead of the ones chosen above. When a slide's notes carry one of these keywords, every chosen group is pointed at that destination; a slide without a keyword changes nothing. When several match, the first line wins." + }, + "Alerts": { + "toggle": "alertEnabled", + "collapsed": true, + "help": "Flash the dashboards listening on this channel when a switch fails — Overflow unreachable or refusing the change — so someone can fix Tap by hand before the moment passes." + } + }, + "configSchema": { + "propresenter": { + "type": "endpoint", + "label": "ProPresenter", + "group": "ProPresenter", + "default": { + "host": "", + "port": 1025 + } + }, + "password": { + "type": "password", + "label": "API password", + "group": "ProPresenter", + "default": "", + "help": "Only if a password is set in ProPresenter's network preferences." + }, + "clientId": { + "type": "string", + "label": "Client ID", + "group": "Overflow", + "default": "" + }, + "apiKey": { + "type": "password", + "label": "API key", + "group": "Overflow", + "default": "" + }, + "groups": { + "type": "multiselect", + "label": "Tap groups", + "group": "Overflow", + "default": [], + "optionsRoute": "/groups", + "help": "The groups a keyword switches. None chosen means every group on the account." + }, + "keywords": { + "type": "text", + "label": "Keywords", + "group": "Keywords", + "default": "", + "help": "One per line — give = Give Online" + }, + "matching": { + "type": "select", + "label": "Match", + "group": "Keywords", + "default": "word", + "options": [ + { + "value": "word", + "label": "Whole word in the notes" + }, + { + "value": "anywhere", + "label": "Anywhere in the notes" + }, + { + "value": "line", + "label": "On its own line" + } + ], + "help": "Whole word keeps `give` from firing on `forgiveness`; anywhere is what Overflow's own helper does." + }, + "alertEnabled": { + "type": "switch", + "label": "Alert when a switch fails", + "group": "Alerts", + "default": false + }, + "alertChannel": { + "type": "number", + "label": "Channel", + "group": "Alerts", + "default": 1, + "min": 1, + "max": 6, + "help": "1–6; each dashboard chooses the channels it listens to in its Layouts menu." + } + }, + "instanceSchema": { + "showDestination": { + "type": "switch", + "label": "Destination", + "default": true + }, + "showGroups": { + "type": "switch", + "label": "Groups", + "default": true + }, + "showSlide": { + "type": "switch", + "label": "Slide match", + "default": true + }, + "showHistory": { + "type": "switch", + "label": "History", + "default": true + } + } +} diff --git a/modules/overflow-tap/overflow.js b/modules/overflow-tap/overflow.js new file mode 100644 index 0000000..346df7c --- /dev/null +++ b/modules/overflow-tap/overflow.js @@ -0,0 +1,126 @@ +'use strict'; + +/** + * Overflow API client — the Tap part of Overflow's public REST API. + * + * https://docs.overflow.co/api-reference (OpenAPI: server.stage.overflow.co/api/docs/openapi.json) + * + * Authentication is two headers, `x-client-id` and `x-api-key`, from the + * Overflow Dashboard's Developers › API Keys. 120 requests per minute per + * client; a 429 is answered when the window is spent. + * + * Reads used here are documented: GET /api/v3/tap/destinations, + * GET /api/v3/tap/groups and GET /api/v3/tap/groups/{id} (a group carries the + * `destinationId` it currently points at). + * + * THE SWITCH IS NOT (as of 2026-10-07). The public reference lists reads only + * for Tap; Overflow's own ProPresenter helper changes a group's destination + * through the Dashboard's private API after signing in with a Dashboard + * login. `setGroupDestination` below sends the natural REST shape for this + * API — PATCH the group with the new `destinationId`, as PATCH /donors/{id} + * updates a donor — and the server confirms every switch by reading the group + * back, so an answer that changes nothing is reported, not believed. If + * Overflow publishes (or confirms) a different route, this one function is + * the only place to change. tools/overflow-tap-mock.js serves the same shape. + * + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at that mock. + */ + +const DEFAULT_API_BASE = 'https://server.overflow.co'; +const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, ''); +const TIMEOUT_MS = 10000; +const PAGE_SIZE = 100; +const MAX_PAGES = 20; +const ID_RE = /^[0-9a-f]{24}$/i; + +class OverflowError extends Error { + constructor(message, { status = 0, retryable = false } = {}) { + super(message); + this.name = 'OverflowError'; + this.status = status; + this.retryable = retryable; + } +} + +function looksLikeId(s) { + return ID_RE.test(String(s || '').trim()); +} + +function createOverflowClient({ clientId, apiKey, fetchImpl = globalThis.fetch }) { + const id = String(clientId || '').trim(); + const key = String(apiKey || '').trim(); + + async function request(method, path, { query, body } = {}) { + if (!id || !key) throw new OverflowError('Overflow client ID and API key are not set', { status: 0 }); + const url = new URL(API_BASE + path); + for (const [k, v] of Object.entries(query || {})) { + if (v === undefined || v === null || v === '') continue; + url.searchParams.set(k, String(v)); + } + const headers = { Accept: 'application/json', 'x-client-id': id, 'x-api-key': key }; + if (body !== undefined) headers['Content-Type'] = 'application/json'; + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), TIMEOUT_MS); + let res; + try { + res = await fetchImpl(url, { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: ctl.signal, + }); + } catch (err) { + const aborted = err && err.name === 'AbortError'; + const cause = err && err.cause && (err.cause.code || err.cause.message); + throw new OverflowError(aborted ? `Overflow did not answer within ${TIMEOUT_MS / 1000} s` : `Overflow unreachable: ${err.message}${cause ? ` (${cause})` : ''}`, { retryable: true }); + } finally { + clearTimeout(timer); + } + const text = await res.text(); + let data = null; + if (text) { + try { data = JSON.parse(text); } catch { data = null; } + } + if (!res.ok) { + const detail = data && (data.message || data.error || data.detail); + const what = Array.isArray(detail) ? detail.join('; ') : (detail ? String(detail) : res.statusText); + let message; + if (res.status === 401 || res.status === 403) message = `Overflow refused the credentials (${res.status}${what ? `: ${what}` : ''})`; + else if (res.status === 404 || res.status === 405) message = `Overflow has no ${method} ${path} (${res.status})`; + else if (res.status === 429) message = 'Overflow is rate-limiting (120 requests a minute)'; + else message = `Overflow answered ${res.status}${what ? `: ${what}` : ''}`; + throw new OverflowError(message, { status: res.status, retryable: res.status === 429 || res.status >= 500 }); + } + return data; + } + + /** Every page of a collection, newest-first as the API sorts it. */ + async function listAll(path, query = {}) { + const all = []; + for (let page = 1; page <= MAX_PAGES; page += 1) { + const res = await request('GET', path, { query: { ...query, limit: PAGE_SIZE, page } }); + const data = Array.isArray(res && res.data) ? res.data : []; + all.push(...data); + const total = Number(res && res.totalCount); + if (data.length < PAGE_SIZE || (Number.isFinite(total) && all.length >= total)) break; + } + return all; + } + + return { + base: API_BASE, + configured: Boolean(id && key), + listDestinations: () => listAll('/api/v3/tap/destinations'), + listGroups: () => listAll('/api/v3/tap/groups'), + async getGroup(groupId) { + const res = await request('GET', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`); + return res && res.data ? res.data : res; + }, + /** See the note at the top of this file: the one unverified call. */ + setGroupDestination(groupId, destinationId) { + return request('PATCH', `/api/v3/tap/groups/${encodeURIComponent(groupId)}`, { body: { destinationId } }); + }, + }; +} + +module.exports = { createOverflowClient, OverflowError, looksLikeId, API_BASE, DEFAULT_API_BASE }; diff --git a/modules/overflow-tap/server.js b/modules/overflow-tap/server.js new file mode 100644 index 0000000..149f3ff --- /dev/null +++ b/modules/overflow-tap/server.js @@ -0,0 +1,552 @@ +'use strict'; + +/** + * Overflow Tap — server entry. + * + * Does what Overflow's "ProPresenter Integration" helper app does, inside + * ProdDash: watches the live slide in ProPresenter, and when its notes carry + * one of the admin page's keywords, points the chosen Overflow Tap groups at + * that keyword's destination — so what people reach when they tap a disc + * follows what is on screen. A slide with no keyword changes nothing; the + * last destination stays until the next keyworded slide. + * + * GET /state → the view-model every tile draws + * GET /stream → SSE, `state` events on every change (heartbeated) + * POST /refresh → re-read Overflow's groups and destinations now + * GET /groups → optionsRoute for the admin page's Tap groups picker + * GET /destinations → the destinations, for a quick look while setting up + * + * ProPresenter is polled (GET /v1/status/slide, twice a second — one small + * request, nothing heavier) because its chunked update stream is the thing + * that goes quiet without saying so. Overflow is read on start, every few + * minutes after that, and on /refresh; every switch is confirmed by reading + * the group back. Which Overflow call does the switching, and why that call + * is the one unverified piece, is explained at the top of overflow.js. + * + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the module at + * tools/overflow-tap-mock.js, which also plays a ProPresenter on :1602. + */ + +const { createOverflowClient, OverflowError, looksLikeId } = require('./overflow'); +const { parseKeywordLines, matchKeyword, cleanNotes, norm } = require('./keywords'); + +const POLL_MS = 500; // ProPresenter, while it answers +const RETRY_MS = 3000; // ProPresenter, while it does not +const PP_TIMEOUT_MS = 4000; +const HEARTBEAT_MS = 15000; +const DESTINATIONS_REFRESH_MS = 5 * 60 * 1000; +const GROUPS_REFRESH_MS = 60 * 1000; // also how often drift (a change made in Overflow by hand) is noticed +const OVERFLOW_RETRY_MS = 30 * 1000; +const SWITCH_RETRY_MS = 10 * 1000; +const SWITCH_RETRIES = 3; +const HISTORY_MAX = 8; // the tile shows four; a few more for the hover +const ALERT_SECONDS = 30; +const MATCH_MODES = new Set(['word', 'anywhere', 'line']); + +/** Set by init(), read by routes() — both are rebuilt together on a config save. */ +let current = null; + +function json(res, code, body) { + res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' }); + res.end(JSON.stringify(body)); +} + +/** One line for a log or a tile; a fetch failure names its cause (ECONNREFUSED, ENOTFOUND…). */ +function describe(err) { + if (err instanceof OverflowError) return err.message; + if (err && err.name === 'AbortError') return 'timed out'; + const msg = err && err.message ? err.message : String(err); + const cause = err && err.cause && (err.cause.code || err.cause.message); + return cause && !msg.includes(String(cause)) ? `${msg} (${cause})` : msg; +} + +module.exports = { + init({ config, log, shell }) { + /* ── config ────────────────────────────────────────────────────── */ + const ep = config.propresenter && typeof config.propresenter === 'object' ? config.propresenter : {}; + const host = String(ep.host || '').trim(); + const port = Number(ep.port) || 1025; + const password = String(config.password || ''); + const chosenGroups = (Array.isArray(config.groups) ? config.groups : []).map((g) => String(g || '').trim()).filter(Boolean); + const mappings = parseKeywordLines(config.keywords); + const mode = MATCH_MODES.has(config.matching) ? config.matching : 'word'; + const alertEnabled = Boolean(config.alertEnabled); + const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1))); + + const overflow = createOverflowClient({ clientId: config.clientId, apiKey: config.apiKey }); + + /* ── state ─────────────────────────────────────────────────────── */ + const streams = new Set(); + const timers = new Set(); + let stopped = false; + let lastFrame = ''; + + let destinations = []; // Overflow's, as listed + let groups = []; // Overflow's, as listed (each with its current destinationId) + let resolved = []; // the mappings, resolved against those two lists + let lastSlideKey = null; // change detection on the live slide + let wanted = null; // the resolved keyword Tap should be on + let inFlight = false; + let retriesLeft = 0; + let retryTimer = null; + let alertRaised = false; + + const state = { + status: 'connecting', + message: '', + propresenter: { configured: Boolean(host), reachable: false, error: '', host, port }, + overflow: { configured: overflow.configured, reachable: false, error: '', groups: 0, destinations: 0, loadedAt: 0 }, + keywords: [], + slide: { live: false, keyword: '', unmapped: '', hasNotes: false }, + current: null, + history: [], + updatedAt: 0, + }; + + /* ── timers that die with the handle ───────────────────────────── */ + + function later(fn, ms) { + const t = setTimeout(() => { + timers.delete(t); + if (!stopped) fn(); + }, ms); + t.unref?.(); + timers.add(t); + return t; + } + + /* ── health, shared by the admin line and the tiles ────────────── */ + + function assess() { + const pp = state.propresenter; + const of = state.overflow; + if (!pp.configured) return ['error', 'No ProPresenter host configured — set one in /admin']; + if (!of.configured) return ['error', 'Overflow client ID and API key not set — add them in /admin']; + if (!mappings.length) return ['error', 'No keywords configured — add some in /admin']; + if (!of.reachable) return ['error', of.error || 'Reaching Overflow…']; + if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…']; + const bad = state.keywords.filter((k) => k.problem); + if (bad.length && bad.length === state.keywords.length) { + return ['error', `No keyword maps to a destination — ${bad[0].keyword}: ${bad[0].problem}`]; + } + if (state.current && !state.current.pending && !state.current.ok) { + return ['error', `Tap not switched to ${state.current.keyword} — ${state.current.error}`]; + } + if (state.current && state.current.pending) return ['connecting', `Switching Tap to ${state.current.keyword}…`]; + const extra = bad.length ? ` · ${bad.length} keyword${bad.length === 1 ? '' : 's'} without a destination: ${bad.map((k) => k.keyword).join(', ')}` : ''; + const drift = state.current && state.current.drift ? ` · ${state.current.drift}` : ''; + return ['ok', `Following ${host}:${port} · Overflow: ${of.groups} group${of.groups === 1 ? '' : 's'}, ${of.destinations} destination${of.destinations === 1 ? '' : 's'}, ${state.keywords.length} keyword${state.keywords.length === 1 ? '' : 's'}${extra}${drift}`]; + } + + function publish() { + if (stopped) return; + [state.status, state.message] = assess(); + const { updatedAt, ...rest } = state; + let frame; + try { + frame = JSON.stringify(rest); + } catch { + return; + } + if (frame === lastFrame) return; + lastFrame = frame; + state.updatedAt = Date.now(); + const payload = `event: state\ndata: ${JSON.stringify(state)}\n\n`; + for (const res of streams) { + try { + res.write(payload); + } catch { + streams.delete(res); + } + } + } + + /* ── Overflow: destinations, groups, and the keywords resolved ─── */ + + function resolveKeywords() { + const dById = new Map(destinations.map((d) => [String(d.id), d])); + const dByName = new Map(destinations.map((d) => [norm(d.name), d])); + const gById = new Map(groups.map((g) => [String(g.id), g])); + const gByName = new Map(groups.map((g) => [norm(g.name), g])); + const defaults = chosenGroups.length + ? chosenGroups.map((id) => gById.get(id)).filter(Boolean) + : groups.slice(); + resolved = mappings.map((m) => { + const out = { keyword: m.keyword, destination: m.destination, destinationId: '', groups: [], problem: m.problem }; + if (out.problem) return out; + const d = (looksLikeId(m.destination) && dById.get(m.destination.trim())) || dByName.get(norm(m.destination)); + if (!d) { + out.problem = destinations.length + ? `No Overflow destination named "${m.destination}"` + : 'Overflow destinations not read yet'; + return out; + } + out.destination = String(d.name || m.destination); + out.destinationId = String(d.id); + let gs; + if (m.groups.length) { + gs = []; + const missing = []; + for (const name of m.groups) { + const g = (looksLikeId(name) && gById.get(name)) || gByName.get(norm(name)); + if (g) gs.push(g); + else missing.push(name); + } + if (missing.length) { + out.problem = `No Tap group named ${missing.map((x) => `"${x}"`).join(', ')}`; + return out; + } + } else { + gs = defaults; + } + if (!gs.length) { + out.problem = groups.length + ? 'None of the chosen Tap groups exist any more' + : 'No Tap groups on this Overflow account'; + return out; + } + out.groups = gs.map((g) => ({ id: String(g.id), name: String(g.name || g.id) })); + return out; + }); + state.keywords = resolved.map((r) => ({ + keyword: r.keyword, + destination: r.destination, + groups: r.groups.map((g) => g.name), + problem: r.problem, + })); + } + + /** Has anyone moved a group we switched? Noted on the live entry, never fought. */ + function noteDrift() { + const cur = state.current; + if (!cur || !cur.ok || cur.pending) return; + const byId = new Map(groups.map((g) => [String(g.id), g])); + const moved = []; + for (const g of cur.groupIds || []) { + const now = byId.get(g); + if (!now) continue; + if (String(now.destinationId || '') !== cur.destination.id) { + moved.push(`${now.name} now points at ${now.destination && now.destination.name ? now.destination.name : 'another destination'}`); + } + } + cur.drift = moved.length ? `changed in Overflow: ${moved.join('; ')}` : ''; + } + + let destinationsTimer = null; + let groupsTimer = null; + let loading = null; + + async function loadOverflow(what = 'all') { + if (stopped || !overflow.configured) return; + if (loading) return loading; + loading = (async () => { + try { + if (what === 'all' || what === 'destinations') destinations = await overflow.listDestinations(); + groups = await overflow.listGroups(); + state.overflow.reachable = true; + state.overflow.error = ''; + state.overflow.groups = groups.length; + state.overflow.destinations = destinations.length; + state.overflow.loadedAt = Date.now(); + resolveKeywords(); + noteDrift(); + // A slide that was already up when the lists arrived (or when a + // destination got its name fixed in Overflow) should take effect now. + if (lastSlideKey !== null) reconsiderSlide(); + } catch (err) { + state.overflow.reachable = false; + state.overflow.error = describe(err); + log(`Overflow: ${state.overflow.error}`); + if (!resolved.length) resolveKeywords(); // so the keyword list at least shows the lines + } + publish(); + })(); + try { + await loading; + } finally { + loading = null; + } + scheduleOverflow(); + } + + function scheduleOverflow() { + if (stopped) return; + if (destinationsTimer) clearTimeout(destinationsTimer); + if (groupsTimer) clearTimeout(groupsTimer); + timers.delete(destinationsTimer); + timers.delete(groupsTimer); + const ok = state.overflow.reachable; + destinationsTimer = later(() => loadOverflow('all'), ok ? DESTINATIONS_REFRESH_MS : OVERFLOW_RETRY_MS); + groupsTimer = ok ? later(() => loadOverflow('groups'), GROUPS_REFRESH_MS) : null; + } + + /* ── the switch ────────────────────────────────────────────────── */ + + function pushHistory(entry) { + const { pending, drift, ...rest } = entry; + state.history.unshift(rest); + if (state.history.length > HISTORY_MAX) state.history.length = HISTORY_MAX; + } + + function raiseAlert(keyword) { + if (!alertEnabled || !shell || typeof shell.alert !== 'function') return; + alertRaised = true; + Promise.resolve(shell.alert({ channel: alertChannel, level: 'medium', text: `Tap not switched: ${keyword}`, duration: ALERT_SECONDS })) + .catch((err) => log(`alert refused: ${describe(err)}`)); + } + + function clearAlert() { + if (!alertRaised || !shell || typeof shell.clearAlert !== 'function') return; + alertRaised = false; + Promise.resolve(shell.clearAlert(alertChannel)).catch(() => {}); + } + + function requestSwitch(target) { + wanted = target; + retriesLeft = SWITCH_RETRIES; + if (retryTimer) { + clearTimeout(retryTimer); + timers.delete(retryTimer); + retryTimer = null; + } + runSwitch(); + } + + async function runSwitch() { + if (stopped || inFlight || !wanted) return; + const target = wanted; + const cur = state.current; + const sameTarget = cur && cur.keyword === target.keyword && cur.destination.id === target.destinationId; + if (sameTarget && cur.ok) return; // already there + inFlight = true; + let entry; + if (sameTarget) { + entry = cur; // another go at the same switch + } else { + if (cur) pushHistory(cur); + entry = { + keyword: target.keyword, + destination: { id: target.destinationId, name: target.destination }, + groups: target.groups.map((g) => g.name), + groupIds: target.groups.map((g) => g.id), + at: Date.now(), + ok: false, + pending: true, + error: '', + attempts: 0, + drift: '', + }; + state.current = entry; + } + entry.pending = true; + entry.attempts += 1; + publish(); + try { + for (const g of target.groups) { + if (stopped) return; + await overflow.setGroupDestination(g.id, target.destinationId); + const check = await overflow.getGroup(g.id); + const nowAt = check ? String(check.destinationId || '') : ''; + if (check && nowAt !== target.destinationId) { + const name = check.destination && check.destination.name ? `"${check.destination.name}"` : 'another destination'; + throw new OverflowError(`Overflow accepted the request but "${g.name}" still points at ${name}`, { status: 200 }); + } + const cached = groups.find((x) => String(x.id) === g.id); + if (cached) { + cached.destinationId = target.destinationId; + cached.destination = { id: target.destinationId, name: target.destination }; + } + } + entry.ok = true; + entry.error = ''; + entry.at = Date.now(); + entry.drift = ''; + clearAlert(); + log(`Tap → ${target.destination} (${target.keyword}) on ${target.groups.map((g) => g.name).join(', ')}`); + } catch (err) { + if (stopped) return; + entry.ok = false; + entry.error = describe(err); + log(`Tap not switched to ${target.destination} (${target.keyword}): ${entry.error}`); + if (retriesLeft > 0) { + retriesLeft -= 1; + retryTimer = later(() => { + retryTimer = null; + runSwitch(); + }, SWITCH_RETRY_MS); + } + raiseAlert(target.keyword); + } finally { + entry.pending = false; + inFlight = false; + publish(); + if (!stopped && wanted !== target) runSwitch(); // a newer keyword arrived meanwhile + } + } + + /* ── ProPresenter: the live slide ──────────────────────────────── */ + + function ppUrl(target) { + const u = new URL(`http://${host}:${port}${target}`); + if (password) u.searchParams.set('password', password); + return u; + } + + async function ppJson(target) { + const ctl = new AbortController(); + const timer = setTimeout(() => ctl.abort(), PP_TIMEOUT_MS); + try { + const res = await fetch(ppUrl(target), { headers: { Accept: 'application/json' }, signal: ctl.signal }); + if (!res.ok) throw new Error(`ProPresenter ${res.status} ${res.statusText}`); + const text = await res.text(); + return text ? JSON.parse(text) : null; + } finally { + clearTimeout(timer); + } + } + + let lastSlide = null; + + function reconsiderSlide() { + const cur = lastSlide; + if (!cur) { + state.slide = { live: false, keyword: '', unmapped: '', hasNotes: false }; + return; + } + const notes = cleanNotes(cur.notes); + const hit = matchKeyword(notes, resolved, mode); + state.slide = { + live: true, + keyword: hit ? hit.keyword : '', + unmapped: hit && hit.problem ? hit.problem : '', + hasNotes: Boolean(notes.trim()), + }; + if (hit && !hit.problem) requestSwitch(hit); + } + + async function pollSlide() { + if (stopped) return; + let ok = false; + try { + const data = await ppJson('/v1/status/slide'); + const cur = data && data.current && typeof data.current === 'object' ? data.current : null; + state.propresenter.reachable = true; + state.propresenter.error = ''; + ok = true; + const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : ''; + if (key !== lastSlideKey) { + lastSlideKey = key; + lastSlide = cur; + reconsiderSlide(); + } + } catch (err) { + state.propresenter.reachable = false; + state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err); + } + publish(); + later(pollSlide, ok ? POLL_MS : RETRY_MS); + } + + /* ── go ────────────────────────────────────────────────────────── */ + + resolveKeywords(); + if (host) { + pollSlide(); + log(`following ProPresenter at ${host}:${port}`); + } else { + log('no ProPresenter host configured — set one in /admin'); + } + if (overflow.configured) { + loadOverflow('all'); + log(`Overflow at ${overflow.base}`); + } else { + log('Overflow client ID / API key not set — add them in /admin'); + } + publish(); + + const heartbeat = setInterval(() => { + for (const res of streams) { + try { + res.write(': ping\n\n'); + } catch { + streams.delete(res); + } + } + }, HEARTBEAT_MS); + heartbeat.unref?.(); + + const handle = { + streams, + getState: () => state, + refresh: () => loadOverflow('all'), + groupOptions: () => groups.map((g) => ({ value: String(g.id), label: String(g.name || g.id) })), + destinationOptions: () => destinations.map((d) => ({ value: String(d.id), label: String(d.name || d.id) })), + configured: overflow.configured, + }; + current = handle; + + return { + stop() { + stopped = true; + clearInterval(heartbeat); + for (const t of timers) clearTimeout(t); + timers.clear(); + clearAlert(); + for (const res of streams) { + try { res.end(); } catch { /* already gone */ } + } + streams.clear(); + if (current === handle) current = null; + }, + health() { + const [status, message] = assess(); + return { status, message }; + }, + }; + }, + + routes() { + return { + 'GET /state': (req, res) => { + if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); + json(res, 200, { state: current.getState() }); + }, + + 'GET /stream': (req, res) => { + res.writeHead(200, { + 'Content-Type': 'text/event-stream; charset=utf-8', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }); + res.write('retry: 3000\n\n'); + if (!current) return void res.end(); + res.write(`event: state\ndata: ${JSON.stringify(current.getState())}\n\n`); + try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ } + current.streams.add(res); + req.on('close', () => current && current.streams.delete(res)); + }, + + 'POST /refresh': async (req, res) => { + if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' }); + if (!current.configured) return json(res, 400, { error: 'Overflow client ID and API key are not set.' }); + await current.refresh(); + const st = current.getState(); + if (!st.overflow.reachable) return json(res, 502, { error: st.overflow.error || 'Overflow unreachable.' }); + json(res, 200, { ok: true, groups: st.overflow.groups, destinations: st.overflow.destinations }); + }, + + 'GET /groups': async (req, res) => { + if (!current) return json(res, 200, { options: [] }); + if (current.configured && !current.groupOptions().length) await current.refresh(); + json(res, 200, { options: current.groupOptions() }); + }, + + 'GET /destinations': async (req, res) => { + if (!current) return json(res, 200, { options: [] }); + if (current.configured && !current.destinationOptions().length) await current.refresh(); + json(res, 200, { options: current.destinationOptions() }); + }, + }; + }, +}; diff --git a/modules/overflow-tap/style.css b/modules/overflow-tap/style.css new file mode 100644 index 0000000..9a43d86 --- /dev/null +++ b/modules/overflow-tap/style.css @@ -0,0 +1,213 @@ +/* Overflow Tap — scoped under its tile via [data-module="overflow-tap"] and + styled with the shell's variables only. Sizes hang off the tile (container + units) so the keyword reads from across a booth on a big tile and still + fits a 2×2. */ + +[data-module="overflow-tap"] { + container-type: size; +} + +[data-module="overflow-tap"] .ot-root { + position: absolute; + inset: 0; + display: flex; + flex-direction: column; + align-items: stretch; + padding: clamp(4px, 4cqmin, 18px); + gap: clamp(2px, 1.8cqmin, 10px); + overflow: hidden; + color: var(--text); + user-select: none; + -webkit-user-select: none; +} + +[data-module="overflow-tap"] .ot-offline { + flex-shrink: 0; + font-size: clamp(0.6rem, 4.6cqmin, 0.9rem); + font-weight: 700; + color: var(--danger); + text-transform: uppercase; + letter-spacing: 0.04em; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +[data-module="overflow-tap"] .ot-root.is-offline .ot-main, +[data-module="overflow-tap"] .ot-root.is-offline .ot-slide, +[data-module="overflow-tap"] .ot-root.is-offline .ot-history { + opacity: 0.45; + filter: grayscale(0.6); +} + +/* ── the live keyword ───────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-main { + --tone: var(--accent); + flex: 1 1 auto; + min-height: 0; + container-type: size; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0; + text-align: center; + overflow: hidden; +} +[data-module="overflow-tap"] .ot-main[data-tone="ok"] { --tone: var(--accent); } +[data-module="overflow-tap"] .ot-main[data-tone="warn"] { --tone: var(--warn); } +[data-module="overflow-tap"] .ot-main[data-tone="alert"] { --tone: var(--danger); } +[data-module="overflow-tap"] .ot-main[data-tone="muted"] { --tone: var(--muted); } + +[data-module="overflow-tap"] .ot-label { + color: var(--tone); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + font-weight: 700; + letter-spacing: 0.14em; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +[data-module="overflow-tap"] .ot-keyword { + color: var(--tone); + font-size: min(clamp(1rem, 38cqh, 7rem), 18cqw); + font-weight: 750; + line-height: 1.15; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="overflow-tap"] .ot-keyword.is-none { color: var(--muted); } +[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-keyword { + animation: ot-pulse 1.2s ease-in-out infinite; +} +@keyframes ot-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.45; } +} + +[data-module="overflow-tap"] .ot-dest { + color: var(--text); + font-size: min(clamp(0.7rem, 16cqh, 1.9rem), 8cqw); + font-weight: 600; + line-height: 1.2; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +[data-module="overflow-tap"] .ot-main[data-tone="muted"] .ot-dest { color: var(--muted); font-weight: 500; } + +[data-module="overflow-tap"] .ot-groups { + color: var(--muted); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + line-height: 1.3; + flex-shrink: 0; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +[data-module="overflow-tap"] .ot-error { + color: var(--danger); + font-size: clamp(0.5rem, 9cqh, 0.95rem); + line-height: 1.3; + max-width: 100%; + overflow: hidden; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} +[data-module="overflow-tap"] .ot-error.is-warn { color: var(--warn); } + +/* ── this slide ─────────────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-slide { + flex-shrink: 0; + text-align: center; + font-size: clamp(0.55rem, 4.8cqmin, 0.95rem); + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + padding: 0.25em 0.6em; + border-radius: 999px; + background: color-mix(in srgb, var(--text) 7%, transparent); + align-self: center; + max-width: 100%; +} +[data-module="overflow-tap"] .ot-slide.is-muted { color: var(--muted); } +[data-module="overflow-tap"] .ot-slide.is-hit { + color: var(--accent-text); + background: color-mix(in srgb, var(--accent) 80%, transparent); + font-weight: 650; +} +[data-module="overflow-tap"] .ot-slide.is-warn { + color: var(--warn); + background: color-mix(in srgb, var(--warn) 16%, transparent); +} + +/* ── the trail ──────────────────────────────────────────────────── */ + +[data-module="overflow-tap"] .ot-history { + flex-shrink: 0; + list-style: none; + margin: 0; + padding: 0.3em 0 0; + border-top: 1px solid var(--border); + font-size: clamp(0.55rem, 4.6cqmin, 0.95rem); + display: flex; + flex-direction: column; + gap: 0.1em; +} + +[data-module="overflow-tap"] .ot-h-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr) minmax(0, 1.4fr) auto; + align-items: baseline; + gap: 0 0.7em; + color: var(--text); +} +[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-kw, +[data-module="overflow-tap"] .ot-h-row.is-failed .ot-h-mark { color: var(--danger); } +[data-module="overflow-tap"] .ot-h-time { + color: var(--muted); + font-variant-numeric: tabular-nums; +} +[data-module="overflow-tap"] .ot-h-kw { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +[data-module="overflow-tap"] .ot-h-dest { color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +[data-module="overflow-tap"] .ot-h-mark { color: var(--accent); font-weight: 700; } +[data-module="overflow-tap"] .ot-h-empty { color: var(--muted); text-align: center; } + +/* On a short tile the trail would crowd the keyword out: keep the + keyword, drop the trail. Inside the keyword block the same rule: when + it is short, the small lines go before the keyword does. */ +@container (max-height: 120px) { + [data-module="overflow-tap"] .ot-history { display: none; } +} +@container (max-height: 70px) { + [data-module="overflow-tap"] .ot-groups, + [data-module="overflow-tap"] .ot-error { display: none; } +} +@container (max-height: 46px) { + [data-module="overflow-tap"] .ot-dest, + [data-module="overflow-tap"] .ot-label { display: none; } +} + +[data-module="overflow-tap"] .ot-empty { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 6%; + color: var(--muted); + text-align: center; + font-size: clamp(0.65rem, 5.5cqmin, 1rem); +} diff --git a/tools/overflow-tap-mock.js b/tools/overflow-tap-mock.js new file mode 100644 index 0000000..024c4f4 --- /dev/null +++ b/tools/overflow-tap-mock.js @@ -0,0 +1,183 @@ +/** + * Mock ProPresenter slide status + mock Overflow Tap API, for developing the + * overflow-tap module without a live ProPresenter or an Overflow account. + * + * node tools/overflow-tap-mock.js [--pp=1602] [--overflow=24720] [--step=4000] + * [--key=] [--read-only] [--slow] + * + * ProPresenter side (127.0.0.1:1602 by default) — the one route the module + * uses, with a service that advances every --step ms: + * GET /version reachability probe + * GET /v1/status/slide { current: { uuid, text, notes }, next }, cycling + * through slides whose notes carry keywords + * ("give", "Next steps: connect", "prayer"), slides + * with other notes ("forgiveness" — must NOT fire + * "give" in whole-word mode), a slide whose keyword + * maps to nothing on the account, and a stretch with + * nothing live. + * + * Overflow side (127.0.0.1:24720 by default; run ProdDash with + * OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the documented reads with + * paging, and the switch the module sends: + * GET /api/v3/tap/destinations four destinations + * GET /api/v3/tap/groups three groups, each pointing somewhere + * GET /api/v3/tap/groups/{id} + * PATCH /api/v3/tap/groups/{id} { destinationId } — moves the group. + * --read-only answers 404 instead, which + * is what Overflow's public API does + * today, to exercise the failure path. + * Every request must carry x-client-id and x-api-key (any non-empty values; + * --key= demands that exact key, answering 401 otherwise). --slow adds + * a two-second delay to every Overflow answer. + * + * Suggested admin Keywords for this mock: + * give = Give Online + * connect = Connect Card @ Lobby + * prayer = Prayer Request + * baptism = Baptism Signup + * + * Every incoming request is logged so we can confirm what the module does. + */ +'use strict'; + +const http = require('http'); + +const args = process.argv.slice(2); +const opt = (name, dflt) => { + const a = args.find((x) => x.startsWith(`--${name}=`)); + return a ? a.slice(name.length + 3) : dflt; +}; +const PP_PORT = Number.parseInt(opt('pp', '1602'), 10); +const OF_PORT = Number.parseInt(opt('overflow', '24720'), 10); +const STEP_MS = Number.parseInt(opt('step', '4000'), 10); +const KEY = opt('key', ''); +const READ_ONLY = args.includes('--read-only'); +const SLOW = args.includes('--slow'); + +/* ── ProPresenter ────────────────────────────────────────────────── */ + +const SLIDES = [ + { uuid: 'S1', text: 'Welcome to Waters Church', notes: '' }, + { uuid: 'S2', text: 'Give online', notes: 'give' }, + { uuid: 'S3', text: 'Amazing grace, how sweet the sound', notes: 'Chorus x2' }, + { uuid: 'S4', text: 'Fill out a Connect Card', notes: 'Next steps: connect' }, + { uuid: 'S5', text: 'A sermon on forgiveness', notes: 'forgiveness — do not switch on this' }, + { uuid: 'S6', text: 'Pray with us', notes: 'prayer' }, + { uuid: 'S7', text: 'Baptism Sunday', notes: 'baptism' }, + null, // nothing live + { uuid: 'S8', text: 'Give online (again)', notes: 'give' }, + { uuid: 'S9', text: 'See you next week', notes: '' }, +]; +let slideIdx = 0; +setInterval(() => { slideIdx = (slideIdx + 1) % SLIDES.length; }, STEP_MS).unref(); + +function slidePayload() { + const cur = SLIDES[slideIdx]; + const nxt = SLIDES[(slideIdx + 1) % SLIDES.length]; + return { + current: cur ? { uuid: cur.uuid, text: cur.text, notes: cur.notes } : null, + next: nxt ? { uuid: nxt.uuid, text: nxt.text, notes: nxt.notes } : null, + }; +} + +function sendJson(res, code, body, extra = {}) { + res.writeHead(code, { 'Content-Type': 'application/json', ...extra }); + res.end(JSON.stringify(body)); +} + +http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const p = url.pathname; + console.log(`[pp] ${req.method} ${p}`); + if (p === '/version') return sendJson(res, 200, { name: 'Overflow mock', platform: 'mac', host_description: 'ProPresenter 21 (mock)', api_version: 'v1' }); + if (p === '/v1/status/slide') return sendJson(res, 200, slidePayload()); + if (p === '/v1/status/layers') return sendJson(res, 200, { slide: Boolean(SLIDES[slideIdx]), media: false, announcements: false }); + sendJson(res, 404, { error: 'not found' }); +}).listen(PP_PORT, '127.0.0.1', () => { + console.log(`[pp] mock ProPresenter on http://127.0.0.1:${PP_PORT} (slide every ${STEP_MS} ms)`); +}); + +/* ── Overflow ────────────────────────────────────────────────────── */ + +const stamp = (d) => new Date(d).toISOString(); +const DESTINATIONS = [ + { id: '6710f34fd5061afeec3eab01', name: 'Give Online', type: 'web', url: 'https://example.org/give', textNumber: null, textContent: null }, + { id: '6710f34fd5061afeec3eab02', name: 'Connect Card', type: 'web', url: 'https://example.org/connect', textNumber: null, textContent: null }, + { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request', type: 'web', url: 'https://example.org/pray', textNumber: null, textContent: null }, + { id: '6710f34fd5061afeec3eab04', name: 'Events', type: 'sms', url: null, textNumber: '+15550100', textContent: 'EVENTS' }, +].map((d) => ({ ...d, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null })); + +const GROUPS = [ + { id: '6710f34fd5061afeec3eab11', name: 'Auditorium', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab21', '6710f34fd5061afeec3eab22'] }, + { id: '6710f34fd5061afeec3eab12', name: 'Lobby', destinationId: DESTINATIONS[3].id, deviceIds: ['6710f34fd5061afeec3eab23'] }, + { id: '6710f34fd5061afeec3eab13', name: 'Kids Wing', destinationId: DESTINATIONS[1].id, deviceIds: [] }, +].map((g) => ({ ...g, createdAt: stamp('2026-05-01T17:22:11Z'), updatedAt: stamp('2026-05-04T14:09:32Z'), archivedAt: null })); + +function destView(id) { + const d = DESTINATIONS.find((x) => x.id === id); + return d ? { id: d.id, name: d.name, type: d.type, url: d.url, textNumber: d.textNumber, textContent: d.textContent } : null; +} +function groupView(g) { + return { ...g, destination: destView(g.destinationId) }; +} +function page(list, url) { + const limit = Math.min(100, Math.max(1, Number(url.searchParams.get('limit')) || 25)); + const pg = Math.max(1, Number(url.searchParams.get('page')) || 1); + const start = (pg - 1) * limit; + return { data: list.slice(start, start + limit), totalCount: list.length }; +} + +let served = 0; +http.createServer((req, res) => { + const url = new URL(req.url, `http://${req.headers.host}`); + const p = url.pathname; + served += 1; + const rl = { 'x-ratelimit-limit': '120', 'x-ratelimit-remaining': String(Math.max(0, 120 - (served % 120))), 'x-ratelimit-reset': '59' }; + const answer = (code, body) => { + const go = () => sendJson(res, code, body, rl); + if (SLOW) setTimeout(go, 2000); + else go(); + }; + const cid = String(req.headers['x-client-id'] || ''); + const key = String(req.headers['x-api-key'] || ''); + console.log(`[overflow] ${req.method} ${p}${url.search}${cid ? '' : ' (no client id)'}${key ? '' : ' (no api key)'}`); + if (!cid || !key || (KEY && key !== KEY)) return answer(401, { statusCode: 401, message: 'Unauthorized' }); + + if (req.method === 'GET' && p === '/api/v3/tap/destinations') return answer(200, page(DESTINATIONS, url)); + const dOne = /^\/api\/v3\/tap\/destinations\/([^/]+)$/.exec(p); + if (req.method === 'GET' && dOne) { + const d = DESTINATIONS.find((x) => x.id === dOne[1]); + return d ? answer(200, { data: d }) : answer(404, { statusCode: 404, message: 'Destination not found' }); + } + if (req.method === 'GET' && p === '/api/v3/tap/groups') return answer(200, page(GROUPS.map(groupView), url)); + const gOne = /^\/api\/v3\/tap\/groups\/([^/]+)$/.exec(p); + if (gOne) { + const g = GROUPS.find((x) => x.id === gOne[1]); + if (!g) return answer(404, { statusCode: 404, message: 'Group not found' }); + if (req.method === 'GET') return answer(200, { data: groupView(g) }); + if (req.method === 'PATCH' || req.method === 'PUT') { + if (READ_ONLY) return answer(404, { statusCode: 404, message: `Cannot ${req.method} /api/v3/tap/groups/${g.id}` }); + let body = ''; + req.on('data', (c) => (body += c)); + req.on('end', () => { + let parsed = {}; + try { parsed = JSON.parse(body || '{}'); } catch { return answer(400, { statusCode: 400, message: 'Invalid JSON' }); } + const dest = DESTINATIONS.find((x) => x.id === parsed.destinationId); + if (!dest) return answer(400, { statusCode: 400, message: 'destinationId must be an existing destination' }); + g.destinationId = dest.id; + g.updatedAt = stamp(Date.now()); + console.log(`[overflow] ${g.name} → ${dest.name}`); + answer(200, { data: groupView(g) }); + }); + return; + } + } + if (req.method === 'GET' && p === '/api/v3/tap/devices') { + const devices = GROUPS.flatMap((g) => g.deviceIds.map((id) => ({ id, serialNumber: `TAP-${id.slice(-4)}`, groupId: g.id }))); + return answer(200, page(devices, url)); + } + if (req.method === 'GET' && p === '/api/v3/tap/events') return answer(200, { data: [], totalCount: 0 }); + answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` }); +}).listen(OF_PORT, '127.0.0.1', () => { + console.log(`[overflow] mock Overflow API on http://127.0.0.1:${OF_PORT}${READ_ONLY ? ' (read-only: PATCH answers 404)' : ''}${KEY ? ' (api key required)' : ''}`); +});