Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7481d1d9e2 |
@@ -52,7 +52,6 @@ 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 Dashboard API on `127.0.0.1:24720`; run ProdDash with `OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720`, any email/password signs in; `--fail-switch`, `--expire=20`, `--login=<email>:<password>`).
|
||||
|
||||
## Reference code (read-only forks — don't develop these here)
|
||||
|
||||
|
||||
@@ -316,10 +316,15 @@ below. Nothing is installed without someone clicking.
|
||||
name, load a named layout (it becomes a local copy — browsers are never
|
||||
linked), or reset this browser's dashboard. Loading says what it did —
|
||||
*Loaded "FOH": 9 tiles, 2 not shown — Service, #ops aren't offered right
|
||||
now* — with **Undo** in the message to get back the arrangement you had.
|
||||
A tile whose module or picker entry isn't offered at the moment keeps its
|
||||
place and shows nothing, then appears when it is; it is never replaced by
|
||||
the module's default tile. Saving over an existing name asks first, and
|
||||
now; the Layouts menu can remove them* — with **Undo** in the message to
|
||||
get back the arrangement you had. A tile whose module or picker entry
|
||||
isn't offered at the moment keeps its place and shows nothing, then
|
||||
appears when it is; it is never replaced by the module's default tile.
|
||||
While any tile is in that state the same menu has a **Not shown** section
|
||||
listing each one with why — *not offered right now*, *module disabled*,
|
||||
*module not installed* — and a ✕ that removes it and frees its cells, for
|
||||
the tile that is not coming back (a module that stopped offering that
|
||||
kind of tile). Saving over an existing name asks first, and
|
||||
the server keeps the copy it replaced: **Restore previous** in Admin →
|
||||
Saved layouts swaps it back (and swapping again undoes that). The menu's
|
||||
**Grid Size** stepper sets how many columns this browser cuts the board into —
|
||||
@@ -510,27 +515,6 @@ 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 Integration" helper
|
||||
app does, inside ProdDash, speaking the same Dashboard API it does: the
|
||||
live slide's notes are read from ProPresenter (the presentation layer and
|
||||
the announcement layer), and when they carry one of the keywords mapped in
|
||||
the Overflow Dashboard, Overflow is told — and points that keyword's Tap
|
||||
groups at its destination, so a tap on a disc follows what is on screen.
|
||||
A slide with no keyword changes nothing. Keywords match as the helper
|
||||
matches them: as a whole word of the notes, any case, punctuation around
|
||||
it ignored (`give` fires on "Give!" and not on "forgiveness"). 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, is retried, and — if switched on in admin —
|
||||
flashes an alert channel. Admin config: the ProPresenter endpoint and an
|
||||
**Overflow Dashboard login** (email and password; Google sign-in has no
|
||||
password, so set one with "Forgot Your Password?"). The keywords, their
|
||||
destinations and their Tap groups stay where Overflow keeps them —
|
||||
Integrations › ProPresenter › Keyword Mappings in the Dashboard — and are
|
||||
read from there on start, every few minutes, and on the tile's refresh
|
||||
button. Tile settings are four switches: destination, groups, slide
|
||||
match, history.
|
||||
- **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.
|
||||
@@ -581,15 +565,8 @@ 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`) and
|
||||
`node tools/overflow-tap-mock.js` (a ProPresenter whose slide notes carry
|
||||
keywords on `127.0.0.1:1602`, announcement layer included, plus the Overflow
|
||||
Dashboard API on `127.0.0.1:24720` — run ProdDash with
|
||||
`OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720` and sign the module in with
|
||||
any email and password; `--fail-switch` refuses every switch,
|
||||
`--expire=20` makes sessions expire, `--login=<email>:<password>` accepts
|
||||
one login only). See the testing section of the
|
||||
[module guide](docs/MODULE-GUIDE.md).
|
||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
|
||||
@@ -688,8 +688,11 @@ module.exports = {
|
||||
tile added from an entry only when that entry (or one of its `aliases`) is
|
||||
in your list right now; otherwise the tile keeps its place in the layout,
|
||||
draws nothing, and appears the moment your list offers it again — a
|
||||
Service timer waits for PCO Plan, a transcript for its channel. So your
|
||||
client is never asked to render an entry you did not offer at mount time.
|
||||
Service timer waits for PCO Plan, a transcript for its channel. The
|
||||
operator can remove such a tile from the Layouts menu's **Not shown**
|
||||
section, so an entry you retire for good (a send box that moved to another
|
||||
module) leaves no dead cells behind. So your client is never asked to
|
||||
render an entry you did not offer at mount time.
|
||||
One case is still yours: an entry that vanishes *after* the tile mounted
|
||||
(a timer deleted in ProPresenter mid-service). Render nothing for it — no
|
||||
placeholder, no error — and the shell folds the tile away at its next
|
||||
@@ -1044,7 +1047,8 @@ client-side failures (a tile that fails to start says so in the tile).
|
||||
|
||||
**Poke the pieces directly:**
|
||||
|
||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret config).
|
||||
- `GET /api/modules` — what the shell sees (your manifest + non-secret
|
||||
config; `inactive` names the installed modules that are not running).
|
||||
- `GET /api/modules/<id>/...` — your routes, `curl`-able. For SSE:
|
||||
`curl -N http://localhost:24500/api/modules/<id>/stream`.
|
||||
- `GET /api/admin/state` — enabled flags, health, mount errors.
|
||||
|
||||
@@ -1,233 +0,0 @@
|
||||
/* 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 = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 8a5.5 5.5 0 1 1-1.6-3.9"/><path d="M13.5 2.5v3h-3"/></svg>';
|
||||
|
||||
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 login — add it in /admin';
|
||||
if (of.reachable && !(state.keywords || []).length) return 'No keyword mappings in the Overflow Dashboard yet';
|
||||
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, 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 = '';
|
||||
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 || '';
|
||||
}
|
||||
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 || ''));
|
||||
if (note) main.append(el('div', 'ot-error', 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 || {};
|
||||
const ann = state.announcement || {};
|
||||
const slideKeys = Array.isArray(slide.keywords) ? slide.keywords : [];
|
||||
const annKeys = Array.isArray(ann.keywords) ? ann.keywords : [];
|
||||
let text;
|
||||
let cls = 'ot-slide';
|
||||
if (!pp.reachable) { text = 'ProPresenter: not answering'; cls += ' is-muted'; }
|
||||
else if (!slide.live && !ann.live) { text = 'Nothing live in ProPresenter'; cls += ' is-muted'; }
|
||||
else if (slideKeys.length || annKeys.length) {
|
||||
const parts = [];
|
||||
if (slideKeys.length) parts.push(`This slide: ${slideKeys.join(' + ')}`);
|
||||
if (annKeys.length) parts.push(`Announcement: ${annKeys.join(' + ')}`);
|
||||
text = parts.join(' · ');
|
||||
cls += ' is-hit';
|
||||
} else if (slide.live) { text = slide.hasNotes ? 'This slide: no keyword' : 'This slide: no notes'; cls += ' is-muted'; }
|
||||
else { text = 'Announcement: no keyword'; 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.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 || ''));
|
||||
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 = `Keyword refresh failed (${res.status})`;
|
||||
try { why = (await res.json()).error || why; } catch { /* keep the status */ }
|
||||
moduleApi.setStatus('error', why);
|
||||
}
|
||||
} catch (err) {
|
||||
moduleApi.setStatus('error', `Keyword 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: 'Refresh keywords',
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Keyword matching — which of Overflow's keywords a slide's notes carry.
|
||||
*
|
||||
* Pure functions, no I/O, so keywords.selftest.js can pin every rule down:
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
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, '\\$&');
|
||||
}
|
||||
|
||||
function keywordMatches(notes, keyword) {
|
||||
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);
|
||||
}
|
||||
|
||||
/** 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());
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
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 };
|
||||
@@ -1,76 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Self-test for keywords.js — the matching rules, pinned.
|
||||
*
|
||||
* node modules/overflow-tap/keywords.selftest.js
|
||||
*/
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const { keywordMatches, matchKeywords, sameSet, 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('a keyword fires as a word of the notes, whatever the case', () => {
|
||||
assert.equal(keywordMatches('give', 'give'), true);
|
||||
assert.equal(keywordMatches('Give', 'give'), true);
|
||||
assert.equal(keywordMatches('please GIVE today', 'give'), true);
|
||||
});
|
||||
|
||||
test('surrounding punctuation is ignored, as the helper strips it', () => {
|
||||
assert.equal(keywordMatches('give!', 'give'), true);
|
||||
assert.equal(keywordMatches('#give now', 'give'), true);
|
||||
assert.equal(keywordMatches('go to (give) now', 'give'), true);
|
||||
assert.equal(keywordMatches('"give"', 'give'), true);
|
||||
assert.equal(keywordMatches('give.', 'give'), true);
|
||||
});
|
||||
|
||||
test('a keyword inside another word does not fire', () => {
|
||||
assert.equal(keywordMatches('a sermon on forgiveness', 'give'), false);
|
||||
assert.equal(keywordMatches('giveaway', 'give'), false);
|
||||
assert.equal(keywordMatches('give_now', 'give'), false);
|
||||
});
|
||||
|
||||
test('a phrase keyword is bounded the same way', () => {
|
||||
assert.equal(keywordMatches('Please fill out your Next Steps card', 'next steps'), true);
|
||||
assert.equal(keywordMatches('nextsteps', 'next steps'), false);
|
||||
});
|
||||
|
||||
test('regex characters in a keyword are literal', () => {
|
||||
assert.equal(keywordMatches('give.now', 'give.now'), true);
|
||||
assert.equal(keywordMatches('giveXnow', 'give.now'), false);
|
||||
});
|
||||
|
||||
test('empty notes never match; zero-width padding is ignored', () => {
|
||||
assert.equal(keywordMatches('', 'give'), false);
|
||||
assert.equal(keywordMatches('', 'give'), false);
|
||||
assert.equal(cleanNotes('give'), 'give');
|
||||
assert.equal(keywordMatches('give', 'give'), true);
|
||||
});
|
||||
|
||||
test('matchKeywords returns every hit, in list order, once each', () => {
|
||||
const list = ['connect', 'give', 'prayer', { keyword: 'Events' }];
|
||||
assert.deepEqual(matchKeywords('give and connect, then events', list), ['connect', 'give', 'Events']);
|
||||
assert.deepEqual(matchKeywords('just worship', list), []);
|
||||
assert.deepEqual(matchKeywords('give', []), []);
|
||||
assert.deepEqual(matchKeywords('give give', ['give', 'GIVE']), ['give']);
|
||||
});
|
||||
|
||||
test('sameSet compares keyword lists regardless of order and case', () => {
|
||||
assert.equal(sameSet(['give', 'connect'], ['Connect', 'give']), true);
|
||||
assert.equal(sameSet(['give'], ['give', 'connect']), false);
|
||||
assert.equal(sameSet([], []), true);
|
||||
assert.equal(sameSet(['give'], []), false);
|
||||
});
|
||||
|
||||
console.log(`${n} tests, exit ${process.exitCode || 0}`);
|
||||
@@ -1,112 +0,0 @@
|
||||
{
|
||||
"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 (and the announcement layer's) are read — nothing on the stage screens changes."
|
||||
},
|
||||
"Overflow": {
|
||||
"collapsed": "whenSet",
|
||||
"help": "An Overflow Dashboard login with Tap access — what Overflow's own ProPresenter helper app signs in with. Google sign-in does not work here: the account needs a password (Forgot Your Password? on the Dashboard sets one). The keywords, their destinations and their Tap groups are managed in the Dashboard under Integrations › ProPresenter › Keyword Mappings and read from there; after changing them, press the tile's refresh button or wait a few minutes."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"overflowEmail": {
|
||||
"type": "string",
|
||||
"label": "Email",
|
||||
"group": "Overflow",
|
||||
"default": ""
|
||||
},
|
||||
"overflowPassword": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"group": "Overflow",
|
||||
"default": ""
|
||||
},
|
||||
"nonprofitId": {
|
||||
"type": "string",
|
||||
"label": "Nonprofit ID",
|
||||
"group": "Overflow",
|
||||
"default": "",
|
||||
"help": "Leave blank: a church login carries its own. Only an @overflow.co staff login needs one."
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Overflow Dashboard API — the calls Overflow's own "ProPresenter Integration"
|
||||
* helper app (1.2.3) makes, read from its bundle on 2026-10-11. None of this is
|
||||
* in Overflow's public API reference (which documents only reads for Tap): it
|
||||
* is the Dashboard's own backend, used with a Dashboard login — the same
|
||||
* email and password the helper asks for. Google sign-in does not work here;
|
||||
* the account needs a password (Overflow's article says the same).
|
||||
*
|
||||
* POST /nonprofit-dashboard/authentication/login/jwt
|
||||
* { email, password } → { token } a JWT; its payload carries
|
||||
* email, nonprofitId, permissions [{ action, subject }] (subject TAP
|
||||
* or all is required), nonprofits [...], exp
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords
|
||||
* → [ { keyword, destinationName, tapGroups: [{ id, tapGroupName }], … } ]
|
||||
* the Dashboard's Integrations › ProPresenter › Keyword Mappings
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/keywords?syncNonprofit=true
|
||||
* { keywords: ["give"] } → the same list, updated THE SWITCH:
|
||||
* Overflow points the keyword's groups at its destination
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits?search=&limit=
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{nonprofitId}/select
|
||||
* only for an @overflow.co staff login, which has no nonprofit of its
|
||||
* own and must pick one first
|
||||
*
|
||||
* Bearer auth on everything after login. A 401 means the session is gone —
|
||||
* sign in again. The helper's own wording for a refused login is kept here
|
||||
* (401 invalid email or password, 429 too many attempts, 403 locked).
|
||||
*
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720 points the client at
|
||||
* tools/overflow-tap-mock.js.
|
||||
*/
|
||||
|
||||
const DEFAULT_API_BASE = 'https://server.overflow.co';
|
||||
const API_BASE = String(process.env.OVERFLOW_TAP_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
const DASH = '/nonprofit-dashboard';
|
||||
const TIMEOUT_MS = 10000;
|
||||
const TRIGGER_TIMEOUT_MS = 15000;
|
||||
|
||||
class OverflowError extends Error {
|
||||
constructor(message, { status = 0, retryable = false, auth = false } = {}) {
|
||||
super(message);
|
||||
this.name = 'OverflowError';
|
||||
this.status = status;
|
||||
this.retryable = retryable;
|
||||
this.auth = auth; // the session was refused: sign in again before retrying
|
||||
}
|
||||
}
|
||||
|
||||
/** The JWT's payload, or null when the token is not one. No signature check — we only read our own session. */
|
||||
function decodeJwt(token) {
|
||||
const parts = String(token || '').split('.');
|
||||
if (parts.length !== 3) return null;
|
||||
try {
|
||||
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||
return JSON.parse(Buffer.from(b64, 'base64').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** What the module needs to know about a session, from its token. */
|
||||
function describeSession(token) {
|
||||
const p = decodeJwt(token) || {};
|
||||
const email = String(p.email || '');
|
||||
const perms = Array.isArray(p.permissions) ? p.permissions : [];
|
||||
const nonprofits = Array.isArray(p.nonprofits)
|
||||
? p.nonprofits.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || '') })).filter((n) => n.id)
|
||||
: [];
|
||||
return {
|
||||
email,
|
||||
staff: email.toLowerCase().endsWith('@overflow.co'),
|
||||
nonprofitId: p.nonprofitId ? String(p.nonprofitId) : '',
|
||||
nonprofits,
|
||||
hasTap: perms.some((x) => x && (x.subject === 'TAP' || x.subject === 'all')),
|
||||
expiresAt: Number.isFinite(Number(p.exp)) ? Number(p.exp) * 1000 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function createOverflowClient({ fetchImpl = globalThis.fetch } = {}) {
|
||||
async function request(method, path, { query, body, token, timeout = TIMEOUT_MS } = {}) {
|
||||
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' };
|
||||
if (token) headers.Authorization = `Bearer ${token}`;
|
||||
if (body !== undefined) headers['Content-Type'] = 'application/json';
|
||||
const ctl = new AbortController();
|
||||
const timer = setTimeout(() => ctl.abort(), timeout);
|
||||
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 / 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) : '');
|
||||
throw new OverflowError(what ? `Overflow answered ${res.status}: ${what}` : `Overflow answered ${res.status} ${res.statusText}`.trim(), {
|
||||
status: res.status,
|
||||
auth: res.status === 401,
|
||||
retryable: res.status === 429 || res.status >= 500,
|
||||
});
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
base: API_BASE,
|
||||
|
||||
/** Sign in with a Dashboard login; resolves to the JWT. */
|
||||
async login(email, password) {
|
||||
try {
|
||||
const data = await request('POST', `${DASH}/authentication/login/jwt`, { body: { email, password } });
|
||||
const token = data && data.token;
|
||||
if (!token || typeof token !== 'string') throw new OverflowError('Overflow sign-in answered without a token');
|
||||
return token;
|
||||
} catch (err) {
|
||||
if (!(err instanceof OverflowError) || !err.status) throw err;
|
||||
if (err.status === 401) throw new OverflowError('Overflow refused the sign-in: invalid email or password', { status: 401 });
|
||||
if (err.status === 429) throw new OverflowError('Overflow refused the sign-in: too many attempts — wait a while', { status: 429, retryable: true });
|
||||
if (err.status === 403) throw new OverflowError('Overflow refused the sign-in: account locked', { status: 403 });
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
|
||||
/** The Keyword Mappings for a nonprofit, as the Dashboard holds them. */
|
||||
async listKeywords(token, nonprofitId) {
|
||||
const data = await request('GET', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, { token });
|
||||
return Array.isArray(data) ? data : [];
|
||||
},
|
||||
|
||||
/** Tell Overflow these keywords are on screen; it moves their groups. Resolves to the updated list when one comes back. */
|
||||
async trigger(token, nonprofitId, keywords) {
|
||||
const data = await request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/keywords`, {
|
||||
query: { syncNonprofit: 'true' },
|
||||
body: { keywords },
|
||||
token,
|
||||
timeout: TRIGGER_TIMEOUT_MS,
|
||||
});
|
||||
return Array.isArray(data) ? data : null;
|
||||
},
|
||||
|
||||
/** Staff logins only: the nonprofits this login may act for. */
|
||||
async searchNonprofits(token, search = '', limit = 10) {
|
||||
const data = await request('GET', `${DASH}/propresenter/nonprofits`, { token, query: { search, limit } });
|
||||
const list = (data && data.data && data.data.nonprofits) || (data && data.nonprofits) || (data && data.data) || data;
|
||||
return Array.isArray(list)
|
||||
? list.map((n) => ({ id: String((n && n.id) || ''), name: String((n && n.name) || ''), slug: String((n && n.slug) || '') }))
|
||||
: [];
|
||||
},
|
||||
|
||||
/** Staff logins only: bind the session to one nonprofit before reading its keywords. */
|
||||
selectNonprofit(token, nonprofitId) {
|
||||
return request('POST', `${DASH}/propresenter/nonprofits/${encodeURIComponent(nonprofitId)}/select`, { token, body: {} });
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createOverflowClient, OverflowError, decodeJwt, describeSession, API_BASE, DEFAULT_API_BASE };
|
||||
@@ -1,648 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Overflow Tap — server entry.
|
||||
*
|
||||
* Does what Overflow's "ProPresenter Integration" helper app does, inside
|
||||
* ProdDash: watches the live slide in ProPresenter (the presentation layer
|
||||
* and the announcement layer), and when its notes carry one of the keywords
|
||||
* mapped in the Overflow Dashboard, tells Overflow — which points that
|
||||
* keyword's Tap groups at its 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 the keyword mappings from the Dashboard now
|
||||
* GET /keywords → the mappings as read, for a look while setting up
|
||||
*
|
||||
* ProPresenter is polled (GET /v1/status/slide for the presentation layer,
|
||||
* GET /v1/announcement/slide_index for announcements, once a second each —
|
||||
* small requests, nothing heavier) because its chunked update stream is the
|
||||
* thing that goes quiet without saying so. Overflow is the Dashboard's own
|
||||
* API, signed in with a Dashboard login — the calls, and where they come
|
||||
* from, are at the top of overflow.js. Keyword mappings are read on start,
|
||||
* every few minutes after that, on /refresh, and from every trigger's answer.
|
||||
*
|
||||
* 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 crypto = require('crypto');
|
||||
const { createOverflowClient, OverflowError, describeSession } = require('./overflow');
|
||||
const { matchKeywords, sameSet, cleanNotes, norm } = require('./keywords');
|
||||
|
||||
const POLL_MS = 1000; // ProPresenter, while it answers
|
||||
const RETRY_MS = 3000; // ProPresenter, while it does not
|
||||
const PP_TIMEOUT_MS = 4000;
|
||||
const HEARTBEAT_MS = 15000;
|
||||
const KEYWORDS_REFRESH_MS = 5 * 60 * 1000;
|
||||
const OVERFLOW_RETRY_MS = 30 * 1000;
|
||||
const LOGIN_MIN_GAP_MS = 20 * 1000; // a refused sign-in is not retried sooner (429 = "too many attempts")
|
||||
const SESSION_MARGIN_MS = 60 * 1000; // sign in again this long before the token expires
|
||||
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;
|
||||
|
||||
/** Set by init(), read by routes() — both are rebuilt together on a config save. */
|
||||
let current = null;
|
||||
|
||||
/**
|
||||
* The signed-in session, kept across re-inits for the same login: an admin
|
||||
* saving config five times in a minute must not become five sign-ins
|
||||
* (Overflow refuses that with 429). Keyed on a hash of the login, so a
|
||||
* changed email or password starts fresh; the token itself never leaves the
|
||||
* server process.
|
||||
*/
|
||||
let sessionCache = null; // { key, token, expiresAt, email, nonprofitId }
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
function unique(list) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const v of list) {
|
||||
const k = norm(v);
|
||||
if (!k || seen.has(k)) continue;
|
||||
seen.add(k);
|
||||
out.push(v);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The destination and group names Overflow attached to a keyword mapping, whatever the field names turn out to be. */
|
||||
function destinationOf(entry) {
|
||||
return String((entry && (entry.destinationName || (entry.destination && entry.destination.name))) || '');
|
||||
}
|
||||
function groupsOf(entry) {
|
||||
const list = entry && Array.isArray(entry.tapGroups) ? entry.tapGroups : [];
|
||||
return list.map((g) => String((g && (g.tapGroupName || g.name || g.id)) || '')).filter(Boolean);
|
||||
}
|
||||
|
||||
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 ppPassword = String(config.password || '');
|
||||
const email = String(config.overflowEmail || '').trim();
|
||||
const ofPassword = String(config.overflowPassword || '');
|
||||
const staffNonprofit = String(config.nonprofitId || '').trim();
|
||||
const configured = Boolean(email && ofPassword);
|
||||
const alertEnabled = Boolean(config.alertEnabled);
|
||||
const alertChannel = Math.min(6, Math.max(1, Math.round(Number(config.alertChannel) || 1)));
|
||||
const sessionKey = crypto.createHash('sha256').update(`${email}\n${ofPassword}`).digest('hex');
|
||||
|
||||
const overflow = createOverflowClient();
|
||||
|
||||
/* ── state ─────────────────────────────────────────────────────── */
|
||||
const streams = new Set();
|
||||
const timers = new Set();
|
||||
let stopped = false;
|
||||
let lastFrame = '';
|
||||
|
||||
let keywordCache = new Map(); // norm(keyword) → the Dashboard's entry
|
||||
let session = null; // { token, expiresAt, email, nonprofitId }
|
||||
let lastLoginAt = 0;
|
||||
let lastLoginError = null;
|
||||
let wanted = null; // the keywords Tap should be on: { keywords, keyword, destination, groups }
|
||||
let inFlight = false;
|
||||
let retriesLeft = 0;
|
||||
let retryTimer = null;
|
||||
let alertRaised = false;
|
||||
|
||||
// Presentation layer: the slide /v1/status/slide reports.
|
||||
let lastSlideKey = null;
|
||||
let lastSlide = null;
|
||||
let lastPresKeywords = [];
|
||||
// Announcement layer: the slide /v1/announcement/slide_index points at, read from /v1/announcement/active.
|
||||
let annSupported = true;
|
||||
let lastAnnKey = null;
|
||||
let annNotes = '';
|
||||
let annLive = false;
|
||||
let lastAnnKeywords = [];
|
||||
|
||||
const state = {
|
||||
status: 'connecting',
|
||||
message: '',
|
||||
propresenter: { configured: Boolean(host), reachable: false, error: '', host, port },
|
||||
overflow: { configured, reachable: false, error: '', keywords: 0, loadedAt: 0 },
|
||||
keywords: [],
|
||||
slide: { live: false, keywords: [], hasNotes: false },
|
||||
announcement: { live: false, keywords: [] },
|
||||
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(forAdmin = false) {
|
||||
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 login not set — add the email and password in /admin'];
|
||||
if (!of.reachable) return ['error', of.error || 'Signing in to Overflow…'];
|
||||
if (!pp.reachable) return ['error', pp.error ? `ProPresenter unreachable: ${pp.error}` : 'Reaching ProPresenter…'];
|
||||
if (!state.keywords.length) return ['error', 'No keyword mappings in the Overflow Dashboard (Integrations › ProPresenter)'];
|
||||
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 n = state.keywords.length;
|
||||
const who = forAdmin && session && session.email ? `${session.email}, ` : '';
|
||||
return ['ok', `Following ${host}:${port} · Overflow: ${who}${n} keyword${n === 1 ? '' : 's'}`];
|
||||
}
|
||||
|
||||
function publish() {
|
||||
if (stopped) return;
|
||||
[state.status, state.message] = assess(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the Overflow session ──────────────────────────────────────── */
|
||||
|
||||
async function ensureSession() {
|
||||
const now = Date.now();
|
||||
if (session && now < session.expiresAt - SESSION_MARGIN_MS) return session;
|
||||
if (sessionCache && sessionCache.key === sessionKey && now < sessionCache.expiresAt - SESSION_MARGIN_MS) {
|
||||
session = sessionCache;
|
||||
return session;
|
||||
}
|
||||
if (lastLoginError && now - lastLoginAt < LOGIN_MIN_GAP_MS) throw lastLoginError;
|
||||
lastLoginAt = now;
|
||||
let token;
|
||||
try {
|
||||
token = await overflow.login(email, ofPassword);
|
||||
} catch (err) {
|
||||
lastLoginError = err;
|
||||
throw err;
|
||||
}
|
||||
lastLoginError = null;
|
||||
const info = describeSession(token);
|
||||
if (!info.hasTap) throw new OverflowError('This Overflow login has no Tap permission — use one with the Organization Admin or Tap Only role');
|
||||
let nonprofitId = info.nonprofitId || (info.nonprofits.length === 1 ? info.nonprofits[0].id : '');
|
||||
if (info.staff) {
|
||||
if (!staffNonprofit) throw new OverflowError('An @overflow.co login has no nonprofit of its own — enter the Nonprofit ID in /admin');
|
||||
await overflow.selectNonprofit(token, staffNonprofit);
|
||||
nonprofitId = staffNonprofit;
|
||||
} else if (!nonprofitId) {
|
||||
nonprofitId = staffNonprofit;
|
||||
}
|
||||
if (!nonprofitId) throw new OverflowError('This Overflow login carries no nonprofit — enter the Nonprofit ID in /admin');
|
||||
session = { token, expiresAt: info.expiresAt || now + 12 * 3600 * 1000, email: info.email || email, nonprofitId };
|
||||
sessionCache = { key: sessionKey, ...session };
|
||||
log(`signed in to Overflow as ${session.email} (nonprofit ${nonprofitId})`);
|
||||
return session;
|
||||
}
|
||||
|
||||
function dropSession() {
|
||||
session = null;
|
||||
if (sessionCache && sessionCache.key === sessionKey) sessionCache = null;
|
||||
}
|
||||
|
||||
/** Run a signed-in call; when the session is refused, sign in again once and retry. */
|
||||
async function withSession(fn) {
|
||||
const s = await ensureSession();
|
||||
try {
|
||||
return await fn(s);
|
||||
} catch (err) {
|
||||
if (!(err instanceof OverflowError) || !err.auth) throw err;
|
||||
dropSession();
|
||||
const again = await ensureSession();
|
||||
return fn(again);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── the keyword mappings ──────────────────────────────────────── */
|
||||
|
||||
function rebuildKeywordView() {
|
||||
state.keywords = [...keywordCache.values()].map((e) => ({
|
||||
keyword: String(e.keyword).trim(),
|
||||
destination: destinationOf(e),
|
||||
groups: groupsOf(e),
|
||||
}));
|
||||
state.overflow.keywords = state.keywords.length;
|
||||
}
|
||||
|
||||
/** Replace the cache with the Dashboard's list (`merge` folds a partial answer into it instead). */
|
||||
function applyKeywordList(list, merge = false) {
|
||||
const next = merge ? new Map(keywordCache) : new Map();
|
||||
for (const entry of list || []) {
|
||||
if (!entry || typeof entry !== 'object') continue;
|
||||
const k = norm(entry.keyword);
|
||||
if (!k) continue;
|
||||
if (entry.active === false || entry.enabled === false || entry.isActive === false) {
|
||||
next.delete(k);
|
||||
continue;
|
||||
}
|
||||
next.set(k, entry);
|
||||
}
|
||||
keywordCache = next;
|
||||
rebuildKeywordView();
|
||||
}
|
||||
|
||||
let loading = null;
|
||||
let refreshTimer = null;
|
||||
|
||||
async function loadKeywords() {
|
||||
if (stopped || !configured) return;
|
||||
if (loading) return loading;
|
||||
loading = (async () => {
|
||||
try {
|
||||
const list = await withSession((s) => overflow.listKeywords(s.token, s.nonprofitId));
|
||||
applyKeywordList(list);
|
||||
state.overflow.reachable = true;
|
||||
state.overflow.error = '';
|
||||
state.overflow.loadedAt = Date.now();
|
||||
reconsider(); // a slide already up when the mappings arrive takes effect now
|
||||
} catch (err) {
|
||||
state.overflow.reachable = false;
|
||||
state.overflow.error = describe(err);
|
||||
log(`Overflow: ${state.overflow.error}`);
|
||||
}
|
||||
publish();
|
||||
})();
|
||||
try {
|
||||
await loading;
|
||||
} finally {
|
||||
loading = null;
|
||||
}
|
||||
if (stopped) return;
|
||||
if (refreshTimer) {
|
||||
clearTimeout(refreshTimer);
|
||||
timers.delete(refreshTimer);
|
||||
}
|
||||
refreshTimer = later(loadKeywords, state.overflow.reachable ? KEYWORDS_REFRESH_MS : OVERFLOW_RETRY_MS);
|
||||
}
|
||||
|
||||
/* ── the switch ────────────────────────────────────────────────── */
|
||||
|
||||
function describeTarget(keys) {
|
||||
const entries = keys.map((k) => keywordCache.get(norm(k))).filter(Boolean);
|
||||
return {
|
||||
keywords: keys,
|
||||
keyword: keys.join(' + '),
|
||||
destination: unique(entries.map(destinationOf).filter(Boolean)).join(' + '),
|
||||
groups: unique(entries.flatMap(groupsOf)),
|
||||
};
|
||||
}
|
||||
|
||||
function pushHistory(entry) {
|
||||
const { pending, ...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 && sameSet(cur.keywords, target.keywords);
|
||||
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,
|
||||
keywords: target.keywords,
|
||||
destination: target.destination,
|
||||
groups: target.groups,
|
||||
at: Date.now(),
|
||||
ok: false,
|
||||
pending: true,
|
||||
error: '',
|
||||
attempts: 0,
|
||||
};
|
||||
state.current = entry;
|
||||
}
|
||||
entry.pending = true;
|
||||
entry.attempts += 1;
|
||||
publish();
|
||||
try {
|
||||
const answer = await withSession((s) => overflow.trigger(s.token, s.nonprofitId, target.keywords));
|
||||
if (answer) {
|
||||
applyKeywordList(answer, true);
|
||||
const fresh = describeTarget(target.keywords);
|
||||
entry.destination = fresh.destination || entry.destination;
|
||||
entry.groups = fresh.groups.length ? fresh.groups : entry.groups;
|
||||
}
|
||||
entry.ok = true;
|
||||
entry.error = '';
|
||||
entry.at = Date.now();
|
||||
clearAlert();
|
||||
log(`Tap → ${entry.destination || '?'} (${target.keyword})${entry.groups.length ? ` on ${entry.groups.join(', ')}` : ''}`);
|
||||
} catch (err) {
|
||||
if (stopped) return;
|
||||
entry.ok = false;
|
||||
entry.error = describe(err);
|
||||
log(`Tap not switched to ${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
|
||||
}
|
||||
}
|
||||
|
||||
/* ── what is on screen, and what to do about it ────────────────── */
|
||||
|
||||
/**
|
||||
* The helper's rule, per layer: when the set of keywords the layer's
|
||||
* slide carries changes to a non-empty set, send it. Returning to a
|
||||
* keyword after a slide without one sends it again; staying on the same
|
||||
* keyword across slides sends nothing.
|
||||
*/
|
||||
function reconsider() {
|
||||
const list = [...keywordCache.values()];
|
||||
const presNotes = lastSlide ? cleanNotes(lastSlide.notes) : '';
|
||||
const presKeys = lastSlide ? matchKeywords(presNotes, list) : [];
|
||||
state.slide = { live: Boolean(lastSlide), keywords: presKeys, hasNotes: Boolean(presNotes.trim()) };
|
||||
const presChanged = !sameSet(presKeys, lastPresKeywords);
|
||||
if (presChanged) lastPresKeywords = presKeys;
|
||||
|
||||
const annKeys = annLive ? matchKeywords(annNotes, list) : [];
|
||||
state.announcement = { live: annLive, keywords: annKeys };
|
||||
const annChanged = !sameSet(annKeys, lastAnnKeywords);
|
||||
if (annChanged) lastAnnKeywords = annKeys;
|
||||
|
||||
const batch = [];
|
||||
if (presChanged && presKeys.length) batch.push(...presKeys);
|
||||
if (annChanged && annKeys.length) batch.push(...annKeys);
|
||||
if (batch.length) requestSwitch(describeTarget(unique(batch)));
|
||||
}
|
||||
|
||||
/* ── ProPresenter ──────────────────────────────────────────────── */
|
||||
|
||||
function ppUrl(target) {
|
||||
const u = new URL(`http://${host}:${port}${target}`);
|
||||
if (ppPassword) u.searchParams.set('password', ppPassword);
|
||||
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) {
|
||||
const err = new Error(`ProPresenter ${res.status} ${res.statusText}`);
|
||||
err.status = res.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await res.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
/** The presentation layer: /v1/status/slide names the live slide and carries its notes. */
|
||||
async function pollSlide() {
|
||||
const data = await ppJson('/v1/status/slide');
|
||||
const cur = data && data.current && typeof data.current === 'object' ? data.current : null;
|
||||
const key = cur ? `${cur.uuid || ''}\u0000${cur.text || ''}\u0000${cur.notes || ''}` : '';
|
||||
if (key === lastSlideKey) return false;
|
||||
lastSlideKey = key;
|
||||
lastSlide = cur;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The announcement layer: its slide index names the document and the
|
||||
* slide; the document's slides (every group's, in order, disabled ones
|
||||
* included — that is the index's space) carry the notes. An older
|
||||
* ProPresenter without this API answers 404 once and is left alone.
|
||||
*/
|
||||
async function pollAnnouncement() {
|
||||
if (!annSupported) return false;
|
||||
let idx;
|
||||
try {
|
||||
idx = await ppJson('/v1/announcement/slide_index');
|
||||
} catch (err) {
|
||||
if (err && err.status === 404) {
|
||||
annSupported = false;
|
||||
log('this ProPresenter has no announcement layer API — following the presentation layer only');
|
||||
return false;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
const node = idx && idx.announcement_index;
|
||||
if (!node || !Number.isInteger(node.index)) {
|
||||
if (lastAnnKey === '') return false;
|
||||
lastAnnKey = '';
|
||||
annLive = false;
|
||||
annNotes = '';
|
||||
return true;
|
||||
}
|
||||
const id = node.presentation_id || {};
|
||||
const key = `${id.uuid || ''}\u0000${id.name || ''}\u0000${node.index}`;
|
||||
if (key === lastAnnKey) return false;
|
||||
const doc = await ppJson('/v1/announcement/active');
|
||||
const ann = doc && doc.announcement;
|
||||
const slides = [];
|
||||
for (const g of (ann && Array.isArray(ann.groups) ? ann.groups : [])) {
|
||||
for (const s of (g && Array.isArray(g.slides) ? g.slides : [])) slides.push(s);
|
||||
}
|
||||
const slide = slides[node.index];
|
||||
annNotes = slide ? cleanNotes(slide.notes) : '';
|
||||
annLive = true;
|
||||
lastAnnKey = key;
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pollTick() {
|
||||
if (stopped) return;
|
||||
let ok = false;
|
||||
let changed = false;
|
||||
try {
|
||||
changed = await pollSlide();
|
||||
state.propresenter.reachable = true;
|
||||
state.propresenter.error = '';
|
||||
ok = true;
|
||||
try {
|
||||
if (await pollAnnouncement()) changed = true;
|
||||
} catch {
|
||||
/* the presentation poll already said ProPresenter answers; a hiccup here waits for the next tick */
|
||||
}
|
||||
} catch (err) {
|
||||
state.propresenter.reachable = false;
|
||||
state.propresenter.error = err && err.name === 'AbortError' ? 'timed out' : describe(err);
|
||||
}
|
||||
if (changed) reconsider();
|
||||
publish();
|
||||
later(pollTick, ok ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
/* ── go ────────────────────────────────────────────────────────── */
|
||||
|
||||
if (host) {
|
||||
pollTick();
|
||||
log(`following ProPresenter at ${host}:${port}`);
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
if (configured) {
|
||||
loadKeywords();
|
||||
log(`Overflow at ${overflow.base}`);
|
||||
} else {
|
||||
log('Overflow login not set — add the email and password 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,
|
||||
configured,
|
||||
getState: () => state,
|
||||
refresh: () => loadKeywords(),
|
||||
};
|
||||
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(true);
|
||||
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 login is 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, keywords: st.overflow.keywords });
|
||||
},
|
||||
|
||||
'GET /keywords': (req, res) => {
|
||||
if (!current) return json(res, 503, { error: 'Overflow Tap is not running.' });
|
||||
const st = current.getState();
|
||||
json(res, 200, { keywords: st.keywords, loadedAt: st.overflow.loadedAt, error: st.overflow.error });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -1,213 +0,0 @@
|
||||
/* 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);
|
||||
}
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "proddash",
|
||||
"version": "1.12.0",
|
||||
"version": "1.13.0",
|
||||
"private": true,
|
||||
"description": "Modular production dashboard for Waters Church \u2014 zero-dependency Node server + vanilla client",
|
||||
"license": "UNLICENSED",
|
||||
|
||||
+93
-4
@@ -188,6 +188,10 @@ function menuItem(label, onClick, { sub = '', danger = false, disabled = false,
|
||||
/* ── module registry ────────────────────────────────────────────────── */
|
||||
|
||||
let registry = new Map(); // module id -> manifest from GET /api/modules
|
||||
/* The installed modules that are not running — disabled, or needing a newer
|
||||
shell — id -> { id, name, reason }, from the same GET. Read for one thing:
|
||||
to say why a tile one of them left in the layout is not drawn. */
|
||||
let inactiveModules = new Map();
|
||||
|
||||
/** fetch that gives up after `ms`. A request the browser has queued behind
|
||||
held connections would otherwise pend forever, and so would anything
|
||||
@@ -208,6 +212,7 @@ async function loadRegistry() {
|
||||
if (!res.ok) throw new Error('modules ' + res.status);
|
||||
const body = await res.json();
|
||||
registry = new Map((body.modules || []).map((m) => [m.id, m]));
|
||||
inactiveModules = new Map((body.inactive || []).map((m) => [m.id, m]));
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
@@ -487,7 +492,9 @@ const GEAR_SVG = '<svg viewBox="0 0 90 90" fill="currentColor" aria-hidden="true
|
||||
registry offers them again: a Service timer waits for PCO Plan, a
|
||||
transcript for its channel. Never the module's default in their place —
|
||||
a wall of "All timers" where five different timers were is worse than
|
||||
five gaps that fill themselves in. */
|
||||
five gaps that fill themselves in. For the one that is not coming back
|
||||
(a module stopped offering that kind of tile) the Layouts menu lists them
|
||||
under Not shown, each with a ✕ — the only way to let its cells go. */
|
||||
|
||||
function tileIsOffered(tile) {
|
||||
const man = registry.get(tile.module);
|
||||
@@ -521,6 +528,7 @@ function reconcileOfferedTiles() {
|
||||
if (changed) rebuildDividers();
|
||||
scheduleOfferedRecheck();
|
||||
refreshEmptyState(); // the registry may have gone from nothing to something
|
||||
repaintUnshown(); // a Not shown row goes the moment its tile is drawn again
|
||||
}
|
||||
|
||||
/* A module's entry list can change without anyone saving admin config: PCO
|
||||
@@ -540,10 +548,90 @@ function scheduleOfferedRecheck() {
|
||||
}, OFFERED_RECHECK_MS);
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, each with what
|
||||
to call it and, in a few words, why it is not drawn. */
|
||||
function unshownTiles() {
|
||||
return tiles.filter((t) => !tileEls.has(t.id)).map((tile) => {
|
||||
const man = registry.get(tile.module);
|
||||
if (man) {
|
||||
const title = tile.title || (tile.variant ? `${man.name} · ${tile.variant}` : man.name);
|
||||
return { tile, title, reason: 'not offered right now' };
|
||||
}
|
||||
const off = inactiveModules.get(tile.module);
|
||||
if (off) {
|
||||
const reason = off.reason === 'incompatible' ? 'module needs a newer ProdDash' : 'module disabled';
|
||||
return { tile, title: tile.title || off.name || tile.module, reason };
|
||||
}
|
||||
return { tile, title: tile.title || tile.module, reason: 'module not installed' };
|
||||
});
|
||||
}
|
||||
|
||||
/** The tiles a layout holds that the board is not showing, by name. */
|
||||
function unshownTileNames() {
|
||||
return tiles.filter((t) => !tileEls.has(t.id))
|
||||
.map((t) => t.title || registry.get(t.module)?.name || t.module);
|
||||
return unshownTiles().map((u) => u.title);
|
||||
}
|
||||
|
||||
/* Repaints the Layouts menu's Not shown section while the menu is open —
|
||||
set by unshownSection(), called whenever tiles become offered or stop
|
||||
being, so the list never lags the board behind it. */
|
||||
let repaintUnshown = () => {};
|
||||
|
||||
/** The Layouts menu's Not shown section: one row per tile the layout holds
|
||||
that the board is not drawing — its name, why in a word or two, and a ✕
|
||||
that removes it — present only while there is one. A tile that waits for
|
||||
its module (a Service timer before PCO Plan is up) is listed but can stay;
|
||||
this exists for the one that will never be back, which would otherwise
|
||||
hold its cells with no control anywhere to let them go. */
|
||||
function unshownSection() {
|
||||
const wrap = document.createElement('div');
|
||||
wrap.className = 'menu-unshown';
|
||||
const signature = (list) => list.map((u) => `${u.tile.id}|${u.reason}`).join('\n');
|
||||
let painted = '';
|
||||
const paint = () => {
|
||||
wrap.innerHTML = '';
|
||||
const list = unshownTiles();
|
||||
painted = signature(list);
|
||||
wrap.hidden = !list.length;
|
||||
if (!list.length) return;
|
||||
const divider = document.createElement('div');
|
||||
divider.className = 'menu-divider';
|
||||
const title = document.createElement('div');
|
||||
title.className = 'menu-title';
|
||||
title.textContent = 'Not shown';
|
||||
wrap.append(divider, title);
|
||||
for (const { tile, title: name, reason } of list) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'menu-item menu-unshown-row';
|
||||
const label = document.createElement('span');
|
||||
label.textContent = name;
|
||||
const sub = document.createElement('span');
|
||||
sub.className = 'sub';
|
||||
sub.textContent = reason;
|
||||
label.appendChild(sub);
|
||||
const remove = document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.className = 'menu-x';
|
||||
remove.setAttribute('data-tip', 'Remove');
|
||||
remove.setAttribute('aria-label', `Remove ${name}`);
|
||||
remove.innerHTML = CLOSE_SVG;
|
||||
remove.addEventListener('click', (e) => {
|
||||
// The row goes with the tile, so the click must not reach the
|
||||
// document as one from outside the menu — the menu stays open
|
||||
// to show the next row (or none), as the stepper and Alerts do.
|
||||
e.stopPropagation();
|
||||
removeTile(tile.id); // saves the layout and frees the cells, as a drawn tile's ✕ does
|
||||
paint();
|
||||
});
|
||||
row.append(label, remove);
|
||||
wrap.appendChild(row);
|
||||
}
|
||||
};
|
||||
paint();
|
||||
// Only when something changed: the registry is re-read every few seconds
|
||||
// while a tile is folded away, and rows rebuilt under the pointer for
|
||||
// nothing would lose the tooltip (and the click) the operator was on.
|
||||
repaintUnshown = () => { if (wrap.isConnected && signature(unshownTiles()) !== painted) paint(); };
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function buildTile(tile) {
|
||||
@@ -2253,6 +2341,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
// how one is arranged.
|
||||
menu.appendChild(gridStepper());
|
||||
menu.appendChild(alertsFold());
|
||||
menu.appendChild(unshownSection()); // only while a tile the layout holds is not drawn
|
||||
const stepperDivider = document.createElement('div');
|
||||
stepperDivider.className = 'menu-divider';
|
||||
menu.appendChild(stepperDivider);
|
||||
@@ -2344,7 +2433,7 @@ wireDropdown('layout-btn', 'layout-menu', async (menu, stillOpen) => {
|
||||
const count = `${tiles.length} tile${tiles.length === 1 ? '' : 's'}`;
|
||||
const gridNote = COLS !== gridBefore ? ` on a ${COLS}-column grid` : '';
|
||||
const note = unshown.length
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now`
|
||||
? `, ${unshown.length} not shown — ${unshown.join(', ')} ${unshown.length === 1 ? 'isn’t' : 'aren’t'} offered right now; the Layouts menu can remove ${unshown.length === 1 ? 'it' : 'them'}`
|
||||
: '';
|
||||
toast(`Loaded “${l.name}”: ${count}${gridNote}${note}`, false, {
|
||||
ms: unshown.length ? 10000 : 7000,
|
||||
|
||||
@@ -368,6 +368,33 @@ body[data-alert="critical"] .tile.is-tinted {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* the Layouts menu's Not shown rows: a tile the layout holds but the board
|
||||
is not drawing, why in a word or two, and the ✕ that removes it */
|
||||
.menu-unshown[hidden] { display: none; }
|
||||
.menu-unshown-row { cursor: default; }
|
||||
.menu-unshown-row:hover { background: none; }
|
||||
.menu-unshown-row > span:first-child {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.menu-x {
|
||||
flex: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
background: none;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
}
|
||||
.menu-x:hover { background: var(--border); color: var(--danger); }
|
||||
.menu-x svg { width: 12px; height: 12px; display: block; }
|
||||
|
||||
/* ── grid ──────────────────────────────────────────────────────────── */
|
||||
|
||||
#gridwrap {
|
||||
|
||||
@@ -2336,8 +2336,17 @@ function handleRequest(req, res) {
|
||||
|
||||
/* — shell API — */
|
||||
if (urlPath === '/api/modules' && req.method === 'GET') {
|
||||
// Beside what dashboards may load: the installed modules that are not
|
||||
// running, by id and name only, so a tile one of them left in a layout
|
||||
// can say why it is not drawn ("module disabled") instead of guessing at
|
||||
// "not installed". Nothing of their config travels with it.
|
||||
const inactive = [...manifests.keys()].filter((id) => !isActive(id)).map((id) => ({
|
||||
id,
|
||||
name: String(manifests.get(id).name || id),
|
||||
reason: isEnabled(id) ? 'incompatible' : 'disabled',
|
||||
}));
|
||||
Promise.all([...manifests.keys()].filter(isActive).map(clientManifest))
|
||||
.then((list) => sendJson(res, 200, { modules: list }))
|
||||
.then((list) => sendJson(res, 200, { modules: list, inactive }))
|
||||
.catch((err) => {
|
||||
console.error('[proddash] /api/modules failed:', err);
|
||||
if (!res.headersSent) sendJson(res, 500, { error: 'Server error.' });
|
||||
|
||||
@@ -1,255 +0,0 @@
|
||||
/**
|
||||
* Mock ProPresenter slide status + mock Overflow Dashboard 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]
|
||||
* [--login=<email>:<password>] [--expire=<seconds>]
|
||||
* [--fail-switch] [--slow] [--no-announcements]
|
||||
*
|
||||
* ProPresenter side (127.0.0.1:1602 by default) — the routes 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"), a slide whose
|
||||
* keyword is not mapped on the account, and
|
||||
* a stretch with nothing live.
|
||||
* GET /v1/announcement/slide_index the announcement layer: a three-slide
|
||||
* document whose index advances every third
|
||||
* step; its middle slide carries "events".
|
||||
* GET /v1/announcement/active that document, groups → slides → notes.
|
||||
* --no-announcements answers 404 to both,
|
||||
* like an older ProPresenter.
|
||||
*
|
||||
* Overflow side (127.0.0.1:24720 by default; run ProdDash with
|
||||
* OVERFLOW_TAP_API_BASE=http://127.0.0.1:24720) — the Dashboard API the
|
||||
* module speaks (see modules/overflow-tap/overflow.js):
|
||||
* POST /nonprofit-dashboard/authentication/login/jwt any non-empty email +
|
||||
* password signs in (--login=<email>:<password> demands that pair,
|
||||
* answering 401 otherwise); the token expires after --expire seconds
|
||||
* (3600 by default — set 20 to exercise the sign-in-again path). An
|
||||
* @overflow.co email gets a staff token with no nonprofit of its own.
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits staff: the nonprofits
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/select
|
||||
* GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords four mappings:
|
||||
* give → Give Online (every group), connect → Connect Card (Lobby),
|
||||
* prayer → Prayer Request (every group), events → Events (Lobby, Kids
|
||||
* Wing). "baptism" is deliberately unmapped.
|
||||
* POST /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords?syncNonprofit=true
|
||||
* { keywords } — moves the groups and answers the updated list.
|
||||
* --fail-switch answers 500 instead, to exercise the failure path.
|
||||
* Every signed-in call needs a Bearer token this mock issued and has not
|
||||
* expired, or it answers 401. --slow adds a two-second delay to every Overflow
|
||||
* answer.
|
||||
*
|
||||
* Every incoming request is logged so we can confirm what the module does.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
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 LOGIN = opt('login', '');
|
||||
const EXPIRE_S = Number.parseInt(opt('expire', '3600'), 10);
|
||||
const FAIL_SWITCH = args.includes('--fail-switch');
|
||||
const SLOW = args.includes('--slow');
|
||||
const NO_ANN = args.includes('--no-announcements');
|
||||
|
||||
function sendJson(res, code, body, extra = {}) {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json', ...extra });
|
||||
res.end(body === undefined ? '' : JSON.stringify(body));
|
||||
}
|
||||
|
||||
/* ── 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: '' },
|
||||
];
|
||||
const ANNOUNCEMENT = {
|
||||
id: { uuid: 'ANN-1', name: 'Announcements', index: 0 },
|
||||
presentation_path: 'Announcements',
|
||||
groups: [
|
||||
{ name: 'Loop', slides: [
|
||||
{ enabled: true, notes: '', text: 'Welcome', label: '' },
|
||||
{ enabled: true, notes: 'events', text: 'Upcoming events', label: '' },
|
||||
{ enabled: true, notes: '', text: 'Thanks for coming', label: '' },
|
||||
] },
|
||||
],
|
||||
};
|
||||
let step = 0;
|
||||
setInterval(() => { step += 1; }, STEP_MS).unref();
|
||||
const slideIdx = () => step % SLIDES.length;
|
||||
const annIdx = () => Math.floor(step / 3) % ANNOUNCEMENT.groups[0].slides.length;
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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: !NO_ANN });
|
||||
if (p === '/v1/announcement/slide_index') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement_index: { index: annIdx(), presentation_id: ANNOUNCEMENT.id } });
|
||||
}
|
||||
if (p === '/v1/announcement/active') {
|
||||
if (NO_ANN) return sendJson(res, 404, { error: 'not found' });
|
||||
return sendJson(res, 200, { announcement: ANNOUNCEMENT });
|
||||
}
|
||||
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${NO_ANN ? ', no announcement layer' : ''})`);
|
||||
});
|
||||
|
||||
/* ── Overflow Dashboard ──────────────────────────────────────────── */
|
||||
|
||||
const NONPROFIT = { id: '6710f34fd5061afeec3eab00', name: 'Waters Church (mock)', slug: 'waters-church' };
|
||||
const DESTINATIONS = {
|
||||
give: { id: '6710f34fd5061afeec3eab01', name: 'Give Online' },
|
||||
connect: { id: '6710f34fd5061afeec3eab02', name: 'Connect Card' },
|
||||
prayer: { id: '6710f34fd5061afeec3eab03', name: 'Prayer Request' },
|
||||
events: { id: '6710f34fd5061afeec3eab04', name: 'Events' },
|
||||
};
|
||||
const GROUPS = [
|
||||
{ id: '6710f34fd5061afeec3eab11', tapGroupName: 'Auditorium', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab12', tapGroupName: 'Lobby', destinationId: DESTINATIONS.events.id },
|
||||
{ id: '6710f34fd5061afeec3eab13', tapGroupName: 'Kids Wing', destinationId: DESTINATIONS.connect.id },
|
||||
];
|
||||
const groupRef = (g) => ({ id: g.id, tapGroupName: g.tapGroupName });
|
||||
const KEYWORDS = [
|
||||
{ id: 'kw1', keyword: 'give', destinationId: DESTINATIONS.give.id, destinationName: DESTINATIONS.give.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw2', keyword: 'connect', destinationId: DESTINATIONS.connect.id, destinationName: DESTINATIONS.connect.name, tapGroups: [groupRef(GROUPS[1])], active: true },
|
||||
{ id: 'kw3', keyword: 'prayer', destinationId: DESTINATIONS.prayer.id, destinationName: DESTINATIONS.prayer.name, tapGroups: GROUPS.map(groupRef), active: true },
|
||||
{ id: 'kw4', keyword: 'events', destinationId: DESTINATIONS.events.id, destinationName: DESTINATIONS.events.name, tapGroups: [groupRef(GROUPS[1]), groupRef(GROUPS[2])], active: true },
|
||||
];
|
||||
const stampedKeywords = () => KEYWORDS.map((k) => ({ ...k, nonprofitId: NONPROFIT.id, updatedAt: new Date().toISOString() }));
|
||||
|
||||
const tokens = new Map(); // token → { email, exp, nonprofitId }
|
||||
|
||||
function issueToken(email) {
|
||||
const staff = email.toLowerCase().endsWith('@overflow.co');
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const payload = {
|
||||
id: 'user-1',
|
||||
email,
|
||||
fullName: 'Mock User',
|
||||
role: staff ? 'overflowSupport' : 'orgAdmin',
|
||||
permissions: [{ action: 'manage', subject: 'TAP' }],
|
||||
nonprofits: staff ? [] : [{ id: NONPROFIT.id, name: NONPROFIT.name, slug: NONPROFIT.slug }],
|
||||
iat: now,
|
||||
exp: now + EXPIRE_S,
|
||||
};
|
||||
if (!staff) payload.nonprofitId = NONPROFIT.id;
|
||||
const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
|
||||
const token = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64(payload)}.${crypto.randomBytes(16).toString('base64url')}`;
|
||||
tokens.set(token, { email, exp: payload.exp, nonprofitId: staff ? '' : NONPROFIT.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
function bearer(req) {
|
||||
const m = /^Bearer\s+(.+)$/.exec(String(req.headers.authorization || ''));
|
||||
if (!m) return null;
|
||||
const t = tokens.get(m[1]);
|
||||
if (!t) return null;
|
||||
if (Date.now() / 1000 >= t.exp) return null;
|
||||
return t;
|
||||
}
|
||||
|
||||
function readBody(req) {
|
||||
return new Promise((resolve) => {
|
||||
let body = '';
|
||||
req.on('data', (c) => (body += c));
|
||||
req.on('end', () => {
|
||||
try { resolve(JSON.parse(body || '{}')); } catch { resolve(null); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const p = url.pathname;
|
||||
const answer = (code, body) => {
|
||||
const go = () => sendJson(res, code, body);
|
||||
if (SLOW) setTimeout(go, 2000);
|
||||
else go();
|
||||
};
|
||||
console.log(`[overflow] ${req.method} ${p}${url.search}`);
|
||||
|
||||
if (req.method === 'POST' && p === '/nonprofit-dashboard/authentication/login/jwt') {
|
||||
const body = await readBody(req);
|
||||
const email = String((body && body.email) || '').trim();
|
||||
const password = String((body && body.password) || '');
|
||||
if (!email || !password) return answer(400, { statusCode: 400, message: 'email and password are required' });
|
||||
if (LOGIN && `${email}:${password}` !== LOGIN) return answer(401, { statusCode: 401, message: 'Invalid email or password' });
|
||||
console.log(`[overflow] signed in ${email} (token expires in ${EXPIRE_S} s)`);
|
||||
return answer(200, { token: issueToken(email) });
|
||||
}
|
||||
|
||||
const session = bearer(req);
|
||||
if (!session) return answer(401, { statusCode: 401, message: 'Unauthorized' });
|
||||
|
||||
if (req.method === 'GET' && p === '/nonprofit-dashboard/propresenter/nonprofits') {
|
||||
return answer(200, { data: { nonprofits: [NONPROFIT] } });
|
||||
}
|
||||
const sel = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/select$/.exec(p);
|
||||
if (req.method === 'POST' && sel) {
|
||||
if (sel[1] !== NONPROFIT.id) return answer(404, { statusCode: 404, message: 'Nonprofit not found' });
|
||||
session.nonprofitId = NONPROFIT.id;
|
||||
return answer(204);
|
||||
}
|
||||
const kw = /^\/nonprofit-dashboard\/propresenter\/nonprofits\/([^/]+)\/keywords$/.exec(p);
|
||||
if (kw) {
|
||||
if (kw[1] !== NONPROFIT.id || session.nonprofitId !== NONPROFIT.id) return answer(403, { statusCode: 403, message: 'Forbidden' });
|
||||
if (req.method === 'GET') return answer(200, stampedKeywords());
|
||||
if (req.method === 'POST') {
|
||||
const body = await readBody(req);
|
||||
const keys = body && Array.isArray(body.keywords) ? body.keywords.map((k) => String(k).toLowerCase().trim()) : [];
|
||||
if (!keys.length) return answer(400, { statusCode: 400, message: 'keywords is required' });
|
||||
if (FAIL_SWITCH) return answer(500, { statusCode: 500, message: 'Internal server error' });
|
||||
for (const k of keys) {
|
||||
const entry = KEYWORDS.find((x) => x.keyword === k);
|
||||
if (!entry) {
|
||||
console.log(`[overflow] "${k}" is not a mapped keyword — ignored`);
|
||||
continue;
|
||||
}
|
||||
for (const g of entry.tapGroups) {
|
||||
const group = GROUPS.find((x) => x.id === g.id);
|
||||
if (group) group.destinationId = entry.destinationId;
|
||||
}
|
||||
console.log(`[overflow] ${entry.tapGroups.map((g) => g.tapGroupName).join(', ')} → ${entry.destinationName} (${k})`);
|
||||
}
|
||||
return answer(200, stampedKeywords());
|
||||
}
|
||||
}
|
||||
answer(404, { statusCode: 404, message: `Cannot ${req.method} ${p}` });
|
||||
}).listen(OF_PORT, '127.0.0.1', () => {
|
||||
console.log(`[overflow] mock Overflow Dashboard API on http://127.0.0.1:${OF_PORT}${FAIL_SWITCH ? ' (every switch answers 500)' : ''}${LOGIN ? ' (one login accepted)' : ''}${EXPIRE_S < 3600 ? ` (tokens expire after ${EXPIRE_S} s)` : ''}`);
|
||||
});
|
||||
Reference in New Issue
Block a user