Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e47c2fdd | ||
|
|
485482531d | ||
|
|
7bb852b1c8 | ||
|
|
f800c1867e | ||
|
|
7c4217a23a | ||
|
|
5ed2137dd5 | ||
|
|
49f8a82fbd | ||
|
|
70f56c7909 | ||
|
|
cd13ecaf7c | ||
|
|
09f59c1bef | ||
|
|
3bfea6010a | ||
|
|
7c0579cbf9 | ||
|
|
6fffefc6c0 | ||
|
|
227e3bb846 | ||
|
|
c043834d1e | ||
|
|
1fd7dcdf1e | ||
|
|
e59629694e | ||
|
|
6da6f4c31e | ||
|
|
8e4a0e849e | ||
|
|
df2f0494f8 | ||
|
|
0548cb54fa | ||
|
|
c1152b4ab3 | ||
|
|
a692b6a9e2 | ||
|
|
e670e1ee72 | ||
|
|
4b0f161c93 | ||
|
|
dafb48d9ed | ||
|
|
4c1c7f565f | ||
|
|
e8ad99a4ce | ||
|
|
459d8b9c38 | ||
|
|
6b51044be6 |
@@ -53,6 +53,7 @@ title-bar controls). The rules that matter most:
|
||||
`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/vista-midi-mock.js 5104` (a network MIDI / RTP-MIDI session for Vista Cues; point its first sender at `127.0.0.1:5104` and paste the cue sheet it prints; `--invite 127.0.0.1:5040` joins from its side, `--name`, `--channel`, `--every`, `--notes 2,23,8`).
|
||||
`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)
|
||||
|
||||
|
||||
@@ -358,7 +358,13 @@ below. Nothing is installed without someone clicking.
|
||||
at ProdCom (the tile's bottom edge lights up in the accent color), and the
|
||||
channel the text goes out *as* is picked from their Send as menu — only
|
||||
channels ProdCom lets text be typed onto are offered.
|
||||
Admin config: ProdCom server (host : port), API key.
|
||||
Admin config: ProdCom server (host : port), API key, and two switches for
|
||||
a ProdCom on this Mac (macOS only): **Start ProdCom when it is offline**
|
||||
opens it when it isn't running (up to three tries, five seconds apart), and
|
||||
**Restart ProdCom to turn its API on** turns the Application API on and
|
||||
restarts ProdCom when it was running with the API off — ProdCom reads that
|
||||
switch only at launch, so the restart costs about ten seconds without
|
||||
intercom. Neither does anything for a ProdCom on another machine.
|
||||
Tile settings: channel icons, direction and timestamps.
|
||||
- **ProPresenter** — what's live and what's next, with slide progress,
|
||||
driven by the shared `propresenter-core` client (ProPresenter 7/20).
|
||||
@@ -366,8 +372,8 @@ below. Nothing is installed without someone clicking.
|
||||
the live item alone with the gradient filling the tile edge to edge — a
|
||||
wall panel that says one thing.
|
||||
Admin config: ProPresenter endpoint (host : port), password.
|
||||
Tile settings: a **Show** group of switches for the playlist name, live
|
||||
status, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||
Tile settings: a **Show** group of switches for the playlist name, audience
|
||||
look, NOW and NEXT labels, slide count, progress bar, the next list and
|
||||
its item tags, plus tidy titles.
|
||||
- **Timers** — live ProPresenter timers (countdown, countdown-to-time,
|
||||
elapsed), the **video countdown** (time left on what ProPresenter is
|
||||
@@ -515,17 +521,57 @@ 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, 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. How a switch went is the corner dot's
|
||||
job: a switch Overflow refuses is retried three times with the dot left
|
||||
alone, and only when those are spent does the dot go red (hover it for
|
||||
the reason) and, if switched on in admin, an alert channel flash. 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.
|
||||
A photo library fills it: point the module at a Google Drive folder shared
|
||||
"anyone with the link" (file name → person's name) or at a folder on the
|
||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||
title bar opens that library — drag a photo onto a slot, drag between slots
|
||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
||||
drops each into the slot matching their position, filling only slots nobody
|
||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
||||
title bar opens that library — the spots pinned on top, the photos (with a
|
||||
name search) scrolling below — drag a photo onto a slot (mouse, or press and
|
||||
hold on a touchscreen), drag between slots to swap, rename or clear.
|
||||
Dropping a photo onto the person already in a spot makes it *their* photo
|
||||
for that instrument (a nickname like "Rose" for Rosaliz counts): they keep
|
||||
their Planning Center name and spot, and every later Sync uses it whenever
|
||||
they are on that instrument.
|
||||
Planning Center is optional auto-fill on top, not the source of truth:
|
||||
**Sync** reads the upcoming plan's team members and places each one. A
|
||||
person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to
|
||||
that slot — this is how the leader and vocalists, whose position is just
|
||||
"Vocalist", land on the right mic — and everyone else goes to the slot
|
||||
matching their position, one slot per person. Photos come from the library
|
||||
whenever it has one of them, and Planning Center's headshot only when it
|
||||
has none. Someone who plays more than one thing can have a photo per
|
||||
instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`;
|
||||
Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and
|
||||
a plain `DJ.jpg` for everything else: Sync uses the one for the spot they
|
||||
are in that week. A photo dropped onto someone is remembered for that
|
||||
instrument only. Anything placed or moved by
|
||||
hand for that plan stays put through later syncs; last week's people are
|
||||
replaced.
|
||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||
raining confetti, and a Choir button adds the extra spot for the weeks there
|
||||
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||
@@ -598,8 +644,15 @@ 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/vista-midi-mock.js 5104` (a network MIDI session playing a cue
|
||||
sheet's notes, as ProPresenter does for Vista — point Vista Cues' first
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints). See the testing section of
|
||||
the [module guide](docs/MODULE-GUIDE.md).
|
||||
sender at `127.0.0.1:5104` and paste the sheet it prints) 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).
|
||||
|
||||
## Install as a desktop web app
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
verbatim, so they say what ProdDash does with each permission. -->
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>ProdDash reads an audio input on this Mac to decode SMPTE LTC timecode for the Timers module.</string>
|
||||
<key>NSAppleEventsUsageDescription</key>
|
||||
<string>ProdDash asks ProdCom to quit and relaunches it when its Application API has been switched off.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>ProdDash connects to production gear on this network — ProdCom, ProPresenter and anything else its modules are pointed at.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
|
||||
@@ -6,5 +6,10 @@
|
||||
the hardened runtime that needs saying out loud. -->
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<!-- The ProdCom module asks ProdCom to quit (an Apple Event) before it
|
||||
relaunches it with its API on; without this the hardened runtime
|
||||
refuses the event outright. -->
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
+144
-56
@@ -1,5 +1,5 @@
|
||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or click)
|
||||
// The Edit button opens an editor: an uploaded-photo library you drag (or tap)
|
||||
// into slots, plus rename / clear / upload / delete. Edits go to the server and
|
||||
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||
// (Wed→Tue) rains confetti.
|
||||
@@ -14,26 +14,108 @@ export default function create({ root, moduleApi }) {
|
||||
let selectedLibId = ''; // click-to-place fallback for touch
|
||||
const canvases = new Set();
|
||||
let raf = null;
|
||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
||||
let dragScrolling = false;
|
||||
let dragY = 0;
|
||||
let dragBody = null;
|
||||
let dragRaf = null;
|
||||
function startDragAutoScroll() {
|
||||
if (dragRaf) return;
|
||||
const tick = () => {
|
||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
||||
const r = dragBody.getBoundingClientRect();
|
||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
||||
let dy = 0;
|
||||
if (dragY < r.top + zone) dy = -Math.ceil((r.top + zone - dragY) / 4) - 3;
|
||||
else if (dragY > r.bottom - zone) dy = Math.ceil((dragY - (r.bottom - zone)) / 4) + 3;
|
||||
if (dy) dragBody.scrollTop += dy;
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
};
|
||||
dragRaf = requestAnimationFrame(tick);
|
||||
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
|
||||
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
|
||||
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
|
||||
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
|
||||
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
|
||||
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
|
||||
* edges. Board updates that arrive during a drag wait until it ends. */
|
||||
const DRAG_MOVE_PX = 6;
|
||||
const TOUCH_HOLD_MS = 220;
|
||||
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
|
||||
let pending = null; // a pointerdown that may become a drag
|
||||
let renderHeld = false; // a state update arrived mid-drag; render when it ends
|
||||
|
||||
function slotUnder(x, y) {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
|
||||
return cell && root.contains(cell) ? cell : null;
|
||||
}
|
||||
function stopDragAutoScroll() { dragScrolling = false; if (dragRaf) { cancelAnimationFrame(dragRaf); dragRaf = null; } }
|
||||
function beginDrag(p) {
|
||||
const ghost = el('div', 'bl-drag-ghost');
|
||||
ghost.dataset.module = 'band-lineup'; // lives on <body> (above every tile), so it carries its own scope
|
||||
const src = p.source.querySelector('img');
|
||||
if (src) { const img = document.createElement('img'); img.src = src.src; img.alt = ''; ghost.appendChild(img); }
|
||||
else ghost.textContent = p.label || '';
|
||||
document.body.appendChild(ghost);
|
||||
drag = { kind: p.kind, id: p.id, body: p.body, ghost, over: null, x: p.x, y: p.y, raf: null, source: p.source };
|
||||
p.source.classList.add('bl-dragging');
|
||||
root.classList.add('bl-is-dragging');
|
||||
moveDrag(p.x, p.y);
|
||||
const tick = () => {
|
||||
if (!drag) return;
|
||||
// Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
|
||||
const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
|
||||
if (b && b.scrollHeight > b.clientHeight) {
|
||||
const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
|
||||
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
|
||||
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
|
||||
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
|
||||
}
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
};
|
||||
drag.raf = requestAnimationFrame(tick);
|
||||
}
|
||||
function moveDrag(x, y) {
|
||||
drag.x = x; drag.y = y;
|
||||
drag.ghost.style.transform = `translate(${x}px, ${y}px)`;
|
||||
const cell = slotUnder(x, y);
|
||||
if (cell !== drag.over) {
|
||||
if (drag.over) drag.over.classList.remove('bl-drop-over');
|
||||
drag.over = cell;
|
||||
if (cell) cell.classList.add('bl-drop-over');
|
||||
}
|
||||
}
|
||||
function endDrag(drop) {
|
||||
const d = drag; drag = null;
|
||||
if (!d) return;
|
||||
if (d.raf) cancelAnimationFrame(d.raf);
|
||||
d.ghost.remove();
|
||||
d.source.classList.remove('bl-dragging');
|
||||
root.classList.remove('bl-is-dragging');
|
||||
if (d.over) d.over.classList.remove('bl-drop-over');
|
||||
if (drop && d.over) {
|
||||
const to = d.over.dataset.index;
|
||||
if (d.kind === 'lib') assign(to, d.id);
|
||||
else if (d.id !== to) api(`/move?from=${d.id}&to=${to}`, { method: 'POST' });
|
||||
}
|
||||
if (renderHeld) { renderHeld = false; render(); }
|
||||
}
|
||||
function cancelPending() { if (pending && pending.timer) clearTimeout(pending.timer); pending = null; }
|
||||
// Make `node` draggable. `kind`/`id` say what is being dragged.
|
||||
function makeDraggable(node, kind, id, body, label) {
|
||||
node.addEventListener('pointerdown', (e) => {
|
||||
if (e.button !== 0 || drag) return;
|
||||
if (e.target.closest('button')) return; // name / clear / delete buttons keep working
|
||||
cancelPending();
|
||||
pending = { kind, id, body, label, source: node, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY, pointerId: e.pointerId, touch: e.pointerType !== 'mouse', armed: e.pointerType === 'mouse', timer: null };
|
||||
if (pending.touch) {
|
||||
const p = pending;
|
||||
p.timer = setTimeout(() => { if (pending === p) { p.armed = true; beginDrag(p); try { node.setPointerCapture(p.pointerId); } catch { /* gone */ } } }, TOUCH_HOLD_MS);
|
||||
}
|
||||
});
|
||||
}
|
||||
// Document-level move/up so the drag survives leaving the card it started on.
|
||||
function onPointerMove(e) {
|
||||
if (drag) { e.preventDefault(); moveDrag(e.clientX, e.clientY); return; }
|
||||
if (!pending || e.pointerId !== pending.pointerId) return;
|
||||
const dist = Math.hypot(e.clientX - pending.sx, e.clientY - pending.sy);
|
||||
if (pending.touch) { if (dist > 10) cancelPending(); return; } // moved before the hold: it's a scroll
|
||||
if (dist >= DRAG_MOVE_PX) { const p = pending; pending = null; p.x = e.clientX; p.y = e.clientY; beginDrag(p); }
|
||||
}
|
||||
function onPointerUp(e) {
|
||||
if (drag) { e.preventDefault(); suppressClick = true; setTimeout(() => { suppressClick = false; }, 0); endDrag(e.type === 'pointerup'); }
|
||||
cancelPending();
|
||||
}
|
||||
let suppressClick = false; // the click that follows a drop must not also select / place
|
||||
// While a finger drag is live, stop the page from scrolling under it.
|
||||
function onTouchMove(e) { if (drag) e.preventDefault(); }
|
||||
document.addEventListener('pointermove', onPointerMove, { passive: false });
|
||||
document.addEventListener('pointerup', onPointerUp);
|
||||
document.addEventListener('pointercancel', onPointerUp);
|
||||
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
||||
function stopDragAutoScroll() { cancelPending(); endDrag(false); }
|
||||
|
||||
/* ---------- confetti (always animates) ---------- */
|
||||
function makeParticle(w, h, intensity, seedY) {
|
||||
@@ -79,11 +161,12 @@ export default function create({ root, moduleApi }) {
|
||||
function stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||
|
||||
function render() {
|
||||
if (drag) { renderHeld = true; return; }
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
root.innerHTML = '';
|
||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||
if (editing) { renderEditor(); return; }
|
||||
if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
|
||||
root.innerHTML = '';
|
||||
|
||||
const board = el('div', 'bl-board');
|
||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||
@@ -158,13 +241,16 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
/* ---------- editor ---------- */
|
||||
let libFilter = ''; // the photo search box, kept across re-renders
|
||||
function renderEditor() {
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
||||
// board on top and the picture picker (library) below it.
|
||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
|
||||
// that scroll on their own: the spots pinned on top, the photos below — so a photo found far
|
||||
// down the list never has to be carried back up to its spot. Both panes keep their scroll
|
||||
// position (and the search box its focus) when a board update redraws the editor.
|
||||
const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
|
||||
const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
|
||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||
const body = el('div', 'bl-editor-body');
|
||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
||||
const body = el('div', 'bl-editor-body bl-editor-split');
|
||||
|
||||
// Slots (drop targets)
|
||||
const boardEl = el('div', 'bl-edit-board');
|
||||
@@ -176,14 +262,7 @@ export default function create({ root, moduleApi }) {
|
||||
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||
cell.appendChild(photo);
|
||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||
if (slot.person) {
|
||||
cell.draggable = true;
|
||||
cell.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'slot:' + slot.index); e.dataTransfer.effectAllowed = 'move';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
cell.addEventListener('dragend', stopDragAutoScroll);
|
||||
}
|
||||
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
|
||||
const nameRow = el('div', 'bl-edit-namerow');
|
||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.name : ''));
|
||||
@@ -191,26 +270,23 @@ export default function create({ root, moduleApi }) {
|
||||
if (slot.person) { const clr = el('button', 'bl-edit-clear', '×'); clr.title = 'Clear'; clr.addEventListener('click', () => api(`/slot/${slot.index}/clear`, { method: 'POST' })); nameRow.appendChild(clr); }
|
||||
cell.appendChild(nameRow);
|
||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||
// drop
|
||||
cell.addEventListener('dragover', (e) => { e.preventDefault(); cell.classList.add('bl-drop-over'); });
|
||||
cell.addEventListener('dragleave', () => cell.classList.remove('bl-drop-over'));
|
||||
cell.addEventListener('drop', (e) => {
|
||||
e.preventDefault(); stopDragAutoScroll(); cell.classList.remove('bl-drop-over');
|
||||
const data = e.dataTransfer.getData('text/plain') || '';
|
||||
if (data.startsWith('lib:')) assign(slot.index, data.slice(4));
|
||||
else if (data.startsWith('slot:')) { const from = data.slice(5); if (from !== String(slot.index)) api(`/move?from=${from}&to=${slot.index}`, { method: 'POST' }); }
|
||||
});
|
||||
cell.addEventListener('click', (e) => { if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||
boardEl.appendChild(cell);
|
||||
}
|
||||
body.appendChild(boardEl);
|
||||
const boardPane = el('div', 'bl-pane bl-board-pane');
|
||||
boardPane.appendChild(boardEl);
|
||||
body.appendChild(boardPane);
|
||||
|
||||
// Library
|
||||
// Library, with a name search
|
||||
const libPane = el('div', 'bl-pane bl-lib-pane');
|
||||
const search = el('input', 'bl-lib-search');
|
||||
search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
|
||||
libPane.appendChild(search);
|
||||
const lib = el('div', 'bl-library');
|
||||
const list = state?.library || [];
|
||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||
for (const person of list) {
|
||||
const item = el('div', 'bl-lib-item'); item.draggable = true; item.dataset.id = person.id;
|
||||
const item = el('div', 'bl-lib-item'); item.dataset.id = person.id;
|
||||
if (person.id === selectedLibId) item.classList.add('bl-lib-selected');
|
||||
const ph = el('div', 'bl-lib-photo'); ph.appendChild(photoNode(person, 'bl-lib-img')); item.appendChild(ph);
|
||||
const nm = el('button', 'bl-lib-name', person.name || 'Name…');
|
||||
@@ -219,19 +295,27 @@ export default function create({ root, moduleApi }) {
|
||||
const del = el('button', 'bl-lib-del', '×'); del.title = 'Delete photo';
|
||||
del.addEventListener('click', (e) => { e.stopPropagation(); if (confirm(`Delete ${person.name || 'this photo'} from the library?`)) api(`/library/${person.id}/delete`, { method: 'POST' }); });
|
||||
item.appendChild(del);
|
||||
item.addEventListener('dragstart', (e) => {
|
||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
||||
});
|
||||
item.addEventListener('dragend', stopDragAutoScroll);
|
||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
makeDraggable(item, 'lib', person.id, body, person.name);
|
||||
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||
lib.appendChild(item);
|
||||
}
|
||||
body.appendChild(lib);
|
||||
const applyFilter = () => {
|
||||
const q = libFilter.trim().toLowerCase();
|
||||
for (const item of lib.querySelectorAll('.bl-lib-item')) {
|
||||
const nm = item.querySelector('.bl-lib-name');
|
||||
item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
|
||||
}
|
||||
};
|
||||
search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
|
||||
applyFilter();
|
||||
libPane.appendChild(lib);
|
||||
body.appendChild(libPane);
|
||||
wrap.appendChild(body);
|
||||
|
||||
root.innerHTML = '';
|
||||
root.appendChild(wrap);
|
||||
boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
|
||||
if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
|
||||
}
|
||||
|
||||
function assign(slotIndex, personId) {
|
||||
@@ -260,7 +344,7 @@ export default function create({ root, moduleApi }) {
|
||||
title: 'Sync',
|
||||
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
||||
});
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
|
||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
|
||||
// Columns −/+ (touch-friendly; replaces the gear number field).
|
||||
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
||||
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
||||
@@ -293,6 +377,10 @@ export default function create({ root, moduleApi }) {
|
||||
stopConfetti();
|
||||
stopParallax();
|
||||
stopDragAutoScroll();
|
||||
document.removeEventListener('pointermove', onPointerMove);
|
||||
document.removeEventListener('pointerup', onPointerUp);
|
||||
document.removeEventListener('pointercancel', onPointerUp);
|
||||
document.removeEventListener('touchmove', onTouchMove);
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
|
||||
+133
-11
@@ -117,9 +117,61 @@ function scoreMatch(slotLabel, positionLabel) {
|
||||
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||
*/
|
||||
const MIN_SCORE = 60;
|
||||
|
||||
/** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */
|
||||
function mixNumbers(value) {
|
||||
const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m;
|
||||
while ((m = re.exec(String(value || '')))) out.add(m[1]);
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* How well a person's Planning Center note names a spot. The booth writes the mic/role into each
|
||||
* vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those
|
||||
* change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a
|
||||
* different mix than the spot is never a match; the role part ("VOX 5") is scored like a position.
|
||||
* 0 means the note says nothing usable about this spot ("NA", blank, or no overlap).
|
||||
*/
|
||||
function noteScore(slotLabel, note) {
|
||||
const text = String(note || '').trim();
|
||||
if (!text || /^n\/?a$/i.test(text)) return 0;
|
||||
const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text);
|
||||
let mixBonus = 0;
|
||||
if (noteMix.size && slotMix.size) {
|
||||
if (![...noteMix].some((n) => slotMix.has(n))) return 0;
|
||||
mixBonus = 100;
|
||||
}
|
||||
const role = text.replace(/\bmix\s*\d+\b/gi, ' ');
|
||||
const roleScore = scoreMatch(slotRoleText(slotLabel), role);
|
||||
if (!roleScore && !mixBonus) return 0;
|
||||
if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) {
|
||||
// The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither.
|
||||
const roleTokens = labelTokens(role);
|
||||
if (roleTokens.length) return 0;
|
||||
}
|
||||
return mixBonus + roleScore;
|
||||
}
|
||||
|
||||
function assignMembersToSlots(slots, members) {
|
||||
const pool = members.slice();
|
||||
return slots.map((slotLabel) => {
|
||||
const result = slots.map(() => null);
|
||||
const take = (chosen) => {
|
||||
// One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot.
|
||||
const key = chosen.personId || '';
|
||||
for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1);
|
||||
};
|
||||
// Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first.
|
||||
const pairs = [];
|
||||
slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); }));
|
||||
pairs.sort((a, b) => b.sc - a.sc);
|
||||
for (const { s, m } of pairs) {
|
||||
if (result[s] || !pool.includes(m)) continue;
|
||||
result[s] = m; take(m);
|
||||
}
|
||||
// Pass 2 — everyone else by Planning Center position, into the spots still open.
|
||||
return result.map((placed, s) => {
|
||||
if (placed) return placed;
|
||||
const slotLabel = slots[s];
|
||||
const role = slotRoleText(slotLabel);
|
||||
let bestIndex = -1;
|
||||
let bestScore = MIN_SCORE - 1;
|
||||
@@ -128,18 +180,66 @@ function assignMembersToSlots(slots, members) {
|
||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||
}
|
||||
if (bestIndex < 0) return null;
|
||||
return pool.splice(bestIndex, 1)[0];
|
||||
const chosen = pool[bestIndex];
|
||||
take(chosen);
|
||||
return chosen;
|
||||
});
|
||||
}
|
||||
|
||||
/* ── photos by instrument ──────────────────────────────────────────────
|
||||
* A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg",
|
||||
* "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo
|
||||
* whose instrument matches the spot the person is in that week. Instruments group into families so
|
||||
* the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass,
|
||||
* Guitar/Electric/Acoustic/Rhythm, Leader. */
|
||||
const ROLE_FAMILY = {
|
||||
vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys',
|
||||
drums: 'drums', percussion: 'drums', bass: 'bass',
|
||||
guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader',
|
||||
};
|
||||
/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */
|
||||
function roleFamilies(text) {
|
||||
const out = new Set();
|
||||
for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); }
|
||||
return out;
|
||||
}
|
||||
/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */
|
||||
function spotFamilies(slotLabel, positionLabel) {
|
||||
const fromSlot = roleFamilies(slotRoleText(slotLabel));
|
||||
return fromSlot.size ? fromSlot : roleFamilies(positionLabel || '');
|
||||
}
|
||||
/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}.
|
||||
* The first word is always the person; instrument words after it are read off, other words
|
||||
* ("action", "2") are just part of the photo's description. */
|
||||
function photoParts(libName) {
|
||||
const words = normalizeLabel(libName).split(' ').filter(Boolean);
|
||||
const roles = new Set(); const person = [];
|
||||
words.forEach((w, i) => {
|
||||
const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null;
|
||||
if (f) roles.add(f); else if (!roles.size) person.push(w);
|
||||
});
|
||||
return { person: person.join(' '), roles };
|
||||
}
|
||||
/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick
|
||||
* never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under
|
||||
* its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */
|
||||
const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar'];
|
||||
function photoKey(personId, families) {
|
||||
const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || '';
|
||||
return f ? `${personId}|${f}` : String(personId || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||||
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||||
* preferring the most specific. Returns the entry, or null when there's no confident, UNAMBIGUOUS
|
||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
||||
* `entries` is [{ ...whatever, name }].
|
||||
* preferring the most specific. Returns the entry, or null when no library photo is a confident
|
||||
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
|
||||
* not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
|
||||
* `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the
|
||||
* person is on this week: a photo for that instrument beats their everyday photo, which beats a
|
||||
* photo of them on something else — but any photo of theirs still beats PCO's headshot.
|
||||
*/
|
||||
function bestLibraryMatch(pcoName, entries) {
|
||||
function bestLibraryMatch(pcoName, entries, want) {
|
||||
const target = normalizeLabel(pcoName);
|
||||
if (!target) return null;
|
||||
const T = target.split(' ').filter(Boolean);
|
||||
@@ -171,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
|
||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||
return 0;
|
||||
};
|
||||
const wanted = want && want.size ? want : null;
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
let tie = false;
|
||||
for (const e of entries) {
|
||||
const s = scoreOf(e.name);
|
||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
||||
else if (s === bestScore && s > 0) tie = true;
|
||||
const parts = photoParts(e.name);
|
||||
let s = scoreOf(parts.roles.size ? parts.person : e.name);
|
||||
if (s < 60) continue; // not them
|
||||
if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20;
|
||||
if (s > bestScore) { bestScore = s; best = e; }
|
||||
}
|
||||
if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo
|
||||
if (!best) return null; // no confident match → PCO photo
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Could this library photo be of this Planning Center person? Used when someone drops a photo onto
|
||||
* a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here".
|
||||
* A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their
|
||||
* first three letters (a nickname: "Rose" → "Rosaliz").
|
||||
*/
|
||||
function samePerson(pcoName, libName) {
|
||||
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
|
||||
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || '';
|
||||
if (a && a === b) return true;
|
||||
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
samePerson,
|
||||
roleFamilies,
|
||||
spotFamilies,
|
||||
photoParts,
|
||||
photoKey,
|
||||
normalizeTeamName,
|
||||
isAllowedTeamName,
|
||||
bestLibraryMatch,
|
||||
@@ -194,5 +314,7 @@ module.exports = {
|
||||
numbersIn,
|
||||
scoreMatch,
|
||||
assignMembersToSlots,
|
||||
noteScore,
|
||||
mixNumbers,
|
||||
MIN_SCORE,
|
||||
};
|
||||
|
||||
@@ -61,7 +61,83 @@ console.log('assignment:', JSON.stringify(out));
|
||||
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||
// exact full name beats a first-name photo
|
||||
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
||||
// tie → no match (two identical-scoring photos)
|
||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
||||
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
|
||||
assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
|
||||
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
|
||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- one spot per person: someone scheduled twice (Leader + Acoustic) fills one spot only ---
|
||||
{
|
||||
const P = (label, name, personId) => ({ label, name, personId });
|
||||
const slots = ['[LEADER, VOX 1, ACOUSTIC] MIX 1', '[GUITAR, 2] MIX 7'];
|
||||
const got = assignMembersToSlots(slots, [P('[Leader]', 'Mike', 'mp'), P('Guitar [Acoustic]', 'Mike', 'mp'), P('Guitar [Rhythm]', 'Craig', 'cs')]).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Craig'], 'Mike once, Craig gets the guitar spot');
|
||||
console.log('one spot per person: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- Planning Center notes place the leader and vocalists (real notes from Oct 25 / Nov 1, 2026) ---
|
||||
{
|
||||
const { noteScore } = require('./matching');
|
||||
const NA_SLOTS = [
|
||||
'[LEADER, VOX 1, ACOUSTIC] MIX 1', '[VOX 2] MIX 2', '[VOX 3] MIX 3', '[VOX 4] MIX 4', '[GUITAR, LEAD] MIX 5',
|
||||
'[BASS] MIX 6', '[GUITAR, 2] MIX 7', '[VOX 5] MIX 8', '[PIANO]', '[DRUMS]', '[KEYS 2]',
|
||||
];
|
||||
const P = (label, name, personId, note) => ({ label, name, personId, note });
|
||||
// Listed in the order Planning Center returned them — vocalists alphabetical, not by mic.
|
||||
const oct25 = [
|
||||
P('Vocalist', 'DJ', 'dj', 'Mix 3, VOX 3'), P('[Leader]', 'Mike', 'mp', 'Mix 1, VOX 1'), P('Guitar [Lead]', 'Yeremis', 'yc'),
|
||||
P('Piano/Pad', 'Kevin', 'km'), P('Vocalist', 'Genesis', 'gg', 'Mix 4, VOX 4'), P('Guitar [Rhythm]', 'Craig', 'cs'),
|
||||
P('Guitar [Acoustic]', 'Mike', 'mp'), P('Vocalist', 'Rosaliz', 'rg', 'Mix 2, VOX 2'), P('Drums', 'Luis', 'ld'),
|
||||
P('Vocalist', 'Bianca', 'bb', 'Mix 8, VOX 5'), P('Guitar [Bass]', 'Joram', 'jl'),
|
||||
];
|
||||
const got = assignMembersToSlots(NA_SLOTS, oct25).map((m) => (m ? m.name : null));
|
||||
assert.deepStrictEqual(got, ['Mike', 'Rosaliz', 'DJ', 'Genesis', 'Yeremis', 'Joram', 'Craig', 'Bianca', 'Kevin', 'Luis', null], 'Oct 25 by notes');
|
||||
// Nov 1: leader note in different case, a drum note with no mix, an "NA" note.
|
||||
const nov1 = [P('Guitar [Acoustic]', 'Mike', 'mp', 'NA'), P('Drums', 'Alex', 'ag', 'DP48, Drums'), P('[Leader]', 'Mike', 'mp', 'Mix 1, Vox 1')];
|
||||
assert.deepStrictEqual(assignMembersToSlots(NA_SLOTS, nov1).map((m) => (m ? m.name : null)).filter(Boolean), ['Mike', 'Alex'], 'Nov 1: Mike once, Alex on drums');
|
||||
assert.strictEqual(assignMembersToSlots(NA_SLOTS, nov1)[9].name, 'Alex', 'DP48, Drums → [DRUMS]');
|
||||
// A note never pulls someone onto a different mix.
|
||||
assert.strictEqual(noteScore('[VOX 2] MIX 2', 'Mix 8, VOX 5'), 0, 'Mix 8 note must not match MIX 2');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'Mix 8, VOX 5') > 0, true, 'Mix 8, VOX 5 matches [VOX 5] MIX 8');
|
||||
assert.strictEqual(noteScore('[VOX 5] MIX 8', 'NA'), 0, 'NA says nothing');
|
||||
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
|
||||
console.log('notes: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- samePerson: is a dropped photo of the person already in the spot? ---
|
||||
{
|
||||
const { samePerson } = require('./matching');
|
||||
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
|
||||
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
|
||||
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
|
||||
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
|
||||
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
|
||||
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
|
||||
console.log('samePerson: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
|
||||
{
|
||||
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
|
||||
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
|
||||
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
|
||||
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
|
||||
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
|
||||
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
|
||||
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
|
||||
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
|
||||
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
|
||||
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
|
||||
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
|
||||
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
|
||||
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
|
||||
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
|
||||
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
|
||||
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
|
||||
assert.strictEqual(photoKey('dj', new Set()), 'dj');
|
||||
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
|
||||
console.log('photos by instrument: all assertions passed ✓');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "band-lineup",
|
||||
"name": "Band Lineup",
|
||||
"version": "1.11.0",
|
||||
"version": "1.13.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||
"client": "client.js",
|
||||
|
||||
@@ -32,7 +32,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
|
||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
|
||||
|
||||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
||||
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||
@@ -219,6 +219,27 @@ module.exports = {
|
||||
const settings = readJsonFile(settingsPath, {}) || {};
|
||||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
||||
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||||
// The photo the booth picked for a Planning Center person on an instrument ("<PCO person id>|drums"
|
||||
// → library id; a bare person id is their everyday pick), learned when a library photo is dropped
|
||||
// onto that person. Sync uses it before any name matching.
|
||||
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
|
||||
function rememberPhoto(key, libId) {
|
||||
if (!key || !libId || settings.photoFor[key] === libId) return;
|
||||
settings.photoFor[key] = libId; saveSettings();
|
||||
}
|
||||
// Library photo for a scheduled person in a spot whose instruments are `fams`:
|
||||
// 1. the photo picked for them on this instrument;
|
||||
// 2. a photo named for this instrument ("DJ - Drums");
|
||||
// 3. their everyday pick, else their best-named photo (an other-instrument photo of them
|
||||
// still beats PCO's headshot).
|
||||
function libraryPhotoFor(personId, name, fams) {
|
||||
const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null);
|
||||
const forRole = live(settings.photoFor[photoKey(personId, fams)]);
|
||||
if (forRole) return forRole;
|
||||
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams);
|
||||
if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m;
|
||||
return live(settings.photoFor[personId]) || (m && m.file ? m : null);
|
||||
}
|
||||
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||
|
||||
const CHOIR_LABEL = 'CHOIR';
|
||||
@@ -247,7 +268,7 @@ module.exports = {
|
||||
return {
|
||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
||||
plan: null,
|
||||
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
|
||||
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||
library: [], choir: choirOn,
|
||||
};
|
||||
@@ -313,7 +334,7 @@ module.exports = {
|
||||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
||||
*/
|
||||
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').trim();
|
||||
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums"
|
||||
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||
function scanFolder() {
|
||||
if (!photoFolder) return false;
|
||||
@@ -449,11 +470,13 @@ module.exports = {
|
||||
if (!e) return null;
|
||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||
if (e.pco) {
|
||||
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
|
||||
// A synced person. Their library photo (if any) is read live, so replacing that photo
|
||||
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
|
||||
manual = false; personId = e.pco.personId || '';
|
||||
name = String(e.pco.name || '').trim();
|
||||
file = e.pco.file || '';
|
||||
birthday = e.pco.birthday || '';
|
||||
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
||||
file = (lib && lib.file) || e.pco.file || '';
|
||||
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
||||
} else if (e.personId) {
|
||||
const lib = library[e.personId];
|
||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||
@@ -493,7 +516,8 @@ module.exports = {
|
||||
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||
if (pco.configured && serviceTypeId) {
|
||||
const plan = pickPlan(await listPlans(serviceTypeId));
|
||||
latest.plan = plan ? { title: plan.title, dates: plan.dates } : null;
|
||||
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
|
||||
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
|
||||
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||
else {
|
||||
|
||||
@@ -511,6 +535,7 @@ module.exports = {
|
||||
personId,
|
||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||
label: String(m.attributes?.team_position_name || '').trim(),
|
||||
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
||||
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||
});
|
||||
if (personId) seen.add(personId);
|
||||
@@ -519,15 +544,29 @@ module.exports = {
|
||||
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||||
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||||
|
||||
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
||||
// A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
|
||||
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
|
||||
// Planning Center name, kept in that spot.
|
||||
for (const [i, e] of Object.entries(board)) {
|
||||
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
|
||||
const libName = library[e.personId].name;
|
||||
const who = [...new Map(members.map((m) => [m.personId, m])).values()].filter((m) => m.personId && samePerson(m.name, libName));
|
||||
if (who.length !== 1) continue; // nobody, or can't tell which — leave it a plain hand placement
|
||||
const m = who[0];
|
||||
rememberPhoto(photoKey(m.personId, spotFamilies(effectiveSlots()[Number(i)] || '', m.label)), e.personId);
|
||||
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId, position: m.label }, plan: e.plan };
|
||||
}
|
||||
|
||||
// Slots a person is sitting in by hand — don't fill them, and don't place that same person twice.
|
||||
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
|
||||
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
|
||||
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
|
||||
const eff = effectiveSlots();
|
||||
const planId = String(plan.id);
|
||||
const openIdx = []; const manualNames = new Set();
|
||||
for (let i = 0; i < eff.length; i += 1) {
|
||||
const e = board[String(i)];
|
||||
if (e && (e.personId || e.name)) {
|
||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||||
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||
if (nm) manualNames.add(normName(nm));
|
||||
} else openIdx.push(i);
|
||||
}
|
||||
@@ -537,11 +576,12 @@ module.exports = {
|
||||
for (let k = 0; k < openIdx.length; k += 1) {
|
||||
const i = openIdx[k]; const m = assigned[k];
|
||||
if (m) {
|
||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
||||
} else if (board[String(i)] && board[String(i)].pco) {
|
||||
delete board[String(i)]; // was synced, no longer on the plan
|
||||
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
|
||||
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
|
||||
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
|
||||
} else if (board[String(i)]) {
|
||||
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||||
}
|
||||
}
|
||||
saveBoard();
|
||||
@@ -580,6 +620,9 @@ module.exports = {
|
||||
current = {
|
||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||
planId: () => (latest.plan && latest.plan.id) || '',
|
||||
rememberPhoto,
|
||||
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
|
||||
photoUrlFor, libraryList,
|
||||
setChoir, choirOn: () => choirOn,
|
||||
};
|
||||
@@ -670,6 +713,10 @@ module.exports = {
|
||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
||||
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
|
||||
const a = current.board[from]; const b = current.board[to];
|
||||
// Moving a spot is a hand placement for this plan: Sync must not move it back.
|
||||
const plan = current.planId();
|
||||
if (a) a.plan = plan;
|
||||
if (b) b.plan = plan;
|
||||
if (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
@@ -684,7 +731,14 @@ module.exports = {
|
||||
const personId = String(q.get('personId') || '').trim();
|
||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||
if (personId) current.board[slot] = { personId, name: '' };
|
||||
const here = current.board[slot];
|
||||
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
|
||||
// A photo of the person already in this spot: it's their photo now — this week and every
|
||||
// Sync after. They keep their Planning Center name and their spot.
|
||||
// Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone.
|
||||
current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId);
|
||||
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
|
||||
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
|
||||
else delete current.board[slot];
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
json(res, 200, { ok: true });
|
||||
@@ -702,6 +756,7 @@ module.exports = {
|
||||
const name = String(q.get('name') || '').trim();
|
||||
const existing = current.board[index] || { personId: '', name: '' };
|
||||
existing.name = name;
|
||||
existing.plan = current.planId();
|
||||
if (!existing.personId && !name) delete current.board[index];
|
||||
else current.board[index] = existing;
|
||||
current.saveBoard(); current.rebuildLocal();
|
||||
|
||||
@@ -230,3 +230,34 @@
|
||||
background: color-mix(in srgb, var(--bg) 60%, transparent); color: var(--text); cursor: pointer; font-size: 0.85em; line-height: 1;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-del:hover { background: var(--danger); color: #fff; }
|
||||
|
||||
/* Drag: the floating photo that follows the pointer (appended to <body>, so it scopes itself). */
|
||||
[data-module="band-lineup"].bl-drag-ghost {
|
||||
position: fixed; left: 0; top: 0; z-index: 10000; pointer-events: none;
|
||||
width: 72px; aspect-ratio: 3 / 4; margin: -48px 0 0 -36px; border-radius: 8px; overflow: hidden;
|
||||
border: 2px solid var(--accent); background: var(--panel, #222); color: var(--text); font-size: 11px;
|
||||
display: flex; align-items: center; justify-content: center; text-align: center;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45); opacity: 0.92;
|
||||
}
|
||||
[data-module="band-lineup"].bl-drag-ghost img { width: 100%; height: 100%; object-fit: cover; object-position: center top; }
|
||||
[data-module="band-lineup"] .bl-lib-item,
|
||||
[data-module="band-lineup"] .bl-edit-slot { -webkit-user-select: none; user-select: none; -webkit-touch-callout: none; }
|
||||
[data-module="band-lineup"] .bl-edit-slot img,
|
||||
[data-module="band-lineup"] .bl-lib-item img { -webkit-user-drag: none; pointer-events: none; }
|
||||
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
|
||||
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
|
||||
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
|
||||
|
||||
/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
|
||||
[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
|
||||
[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
|
||||
[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
|
||||
[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
|
||||
[data-module="band-lineup"] .bl-lib-search {
|
||||
display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
|
||||
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
|
||||
}
|
||||
[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
|
||||
[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
|
||||
[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
|
||||
[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
/* Overflow Tap — the tile.
|
||||
*
|
||||
* One fact matters across a booth: which keyword Tap is on right now. 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. Whether the last switch landed is the
|
||||
* corner dot's to say — red when Overflow refused it and the module's own
|
||||
* retries did not fix that — never a line of text in the tile.
|
||||
*
|
||||
* 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));
|
||||
}
|
||||
|
||||
// The keyword Tap is on, its destination and the groups moved. No status
|
||||
// words here: how the switch went is the corner dot's job (its hover
|
||||
// carries the reason), and the trail's ✓ / ✕ keeps the record. The
|
||||
// keyword's colour follows along — dimmed while a switch is in flight,
|
||||
// red once Overflow has refused it for good.
|
||||
const main = el('div', 'ot-main');
|
||||
if (cur) {
|
||||
main.dataset.tone = cur.pending ? 'muted' : (cur.ok ? 'ok' : 'alert');
|
||||
main.append(el('div', 'ot-keyword', cur.keyword));
|
||||
if (on('showDestination')) main.append(el('div', 'ot-dest', cur.destination || ''));
|
||||
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-keyword is-none', '—'));
|
||||
main.append(el('div', 'ot-dest', 'No keyword sent yet'));
|
||||
}
|
||||
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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,76 @@
|
||||
'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}`);
|
||||
@@ -0,0 +1,112 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'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 };
|
||||
@@ -0,0 +1,678 @@
|
||||
'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.
|
||||
*
|
||||
* A switch Overflow refuses is retried three times, ten seconds apart, with
|
||||
* the tile's dot left alone meanwhile; only when those are spent does the
|
||||
* dot go red (and the alert fire, if switched on) — the booth hears about a
|
||||
* problem the module could not fix itself, not about every hiccup.
|
||||
*
|
||||
* 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;
|
||||
/**
|
||||
* Refusals in a row, across keywords. The retry budget is per keyword, but
|
||||
* a service keeps bringing new keywords: if Overflow refuses every one of
|
||||
* them, each would start a fresh budget and the dot would never go red.
|
||||
* So the budget is also spent by refusals as a whole, and a success
|
||||
* clears it.
|
||||
*/
|
||||
let consecutiveFailures = 0;
|
||||
|
||||
// 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)'];
|
||||
// A refused switch the module is still retrying on its own is amber —
|
||||
// being worked on; one it has given up on is red — the booth's problem.
|
||||
const cur = state.current;
|
||||
if (cur && !cur.ok && !cur.pending && !cur.retrying) {
|
||||
return ['error', `Tap not switched to ${cur.keyword} — ${cur.error}`];
|
||||
}
|
||||
if (cur && !cur.ok && (cur.retrying || consecutiveFailures > 0)) {
|
||||
return ['connecting', `Tap not switched to ${cur.keyword} yet — retrying (${consecutiveFailures} of ${SWITCH_RETRIES + 1} attempts failed): ${cur.error || 'in flight'}`];
|
||||
}
|
||||
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,
|
||||
retrying: false, // a retry is scheduled: the module is still trying to fix this itself
|
||||
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.retrying = false;
|
||||
entry.at = Date.now();
|
||||
consecutiveFailures = 0;
|
||||
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);
|
||||
consecutiveFailures += 1;
|
||||
if (retriesLeft > 0 && consecutiveFailures <= SWITCH_RETRIES) {
|
||||
// Still the module's problem: try again shortly; the dot goes amber.
|
||||
retriesLeft -= 1;
|
||||
entry.retrying = true;
|
||||
log(`Tap not switched to ${target.keyword}: ${entry.error} — retrying in ${SWITCH_RETRY_MS / 1000} s`);
|
||||
retryTimer = later(() => {
|
||||
retryTimer = null;
|
||||
runSwitch();
|
||||
}, SWITCH_RETRY_MS);
|
||||
} else {
|
||||
// Out of retries: now it is the booth's problem — red dot, and the
|
||||
// alert if the operator asked for one (raised once per outage, not
|
||||
// again for every further keyword Overflow refuses).
|
||||
entry.retrying = false;
|
||||
log(`Tap not switched to ${target.keyword}: ${entry.error} — giving up after ${consecutiveFailures} refusals`);
|
||||
if (!alertRaised) 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 });
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,186 @@
|
||||
/* 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-keyword {
|
||||
color: var(--tone);
|
||||
font-size: min(clamp(1rem, 44cqh, 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;
|
||||
}
|
||||
|
||||
/* ── 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 { display: none; }
|
||||
}
|
||||
@container (max-height: 46px) {
|
||||
[data-module="overflow-tap"] .ot-dest { 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,7 +1,7 @@
|
||||
{
|
||||
"id": "prodcom-transcript",
|
||||
"name": "ProdCom",
|
||||
"version": "1.4.0",
|
||||
"version": "1.5.0",
|
||||
"proddash": ">=1.4.0",
|
||||
"description": "Live ProdCom transcript; typed text to a ProdCom channel through the Send Message module",
|
||||
"permissions": [
|
||||
@@ -38,6 +38,19 @@
|
||||
"type": "password",
|
||||
"label": "API key (only if PSK auth is enabled in ProdCom)",
|
||||
"default": ""
|
||||
},
|
||||
"autoStart": {
|
||||
"type": "switch",
|
||||
"label": "Start ProdCom when it is offline",
|
||||
"default": true,
|
||||
"help": "Only when ProdCom runs on this machine (the server above is 127.0.0.1 or this Mac’s own address). If ProdCom is not running, ProdDash opens it — up to three tries, five seconds apart. macOS only."
|
||||
},
|
||||
"apiRestart": {
|
||||
"type": "switch",
|
||||
"label": "Restart ProdCom to turn its API on",
|
||||
"default": true,
|
||||
"showWhen": "autoStart",
|
||||
"help": "When ProdCom is running but its Application API is switched off, ProdDash turns the API on and restarts ProdCom to apply it (about ten seconds without intercom). Off: the API is turned on for ProdCom’s next start and the tile says to restart it."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
|
||||
@@ -32,9 +32,15 @@
|
||||
* extractEntries). POST /api/v1/channels/{id}/send with { text } puts typed
|
||||
* text on a channel (it comes back on the stream with source "typed"). A
|
||||
* bearer API key is sent when one is configured.
|
||||
*
|
||||
* When ProdCom runs on this very Mac and stops answering, supervisor.js opens
|
||||
* it again — or turns its Application API on and restarts it, the API being
|
||||
* read only at launch. Every failed connection is reported to it (fail()) and
|
||||
* every good one (connect()); it does nothing for a ProdCom on another machine.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const { createSupervisor } = require('./supervisor');
|
||||
|
||||
/** The module that owns the PIN and the unlock tokens — see verifyUnlock(). */
|
||||
const SENDER_ID = 'send-message';
|
||||
@@ -60,6 +66,19 @@ function resolveTarget(config) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Codes that mean ProdCom could not be reached at all — not that it answered badly. */
|
||||
const CONNECTION_CODES = new Set(['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EHOSTUNREACH', 'ENOTFOUND']);
|
||||
|
||||
/** The code of an error that is a failed (or timed-out) connection to ProdCom, else ''.
|
||||
An HTTP status from ProdCom is not one: its API is up, so there is nothing to start. */
|
||||
function connectionCode(err) {
|
||||
const code = err && (err.cause?.code || err.code);
|
||||
if (CONNECTION_CODES.has(code)) return code;
|
||||
if (code === 'UND_ERR_SOCKET') return 'ECONNRESET'; // fetch's spelling of a connection dropped under it
|
||||
if (err && (err.name === 'TimeoutError' || err.name === 'AbortError')) return 'ETIMEDOUT';
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Entries inside whatever ProdCom sent: a bare entry, or one wrapped in data / payload / entry / entries / transcript. */
|
||||
function extractEntries(obj) {
|
||||
if (!obj || typeof obj !== 'object') return [];
|
||||
@@ -181,6 +200,7 @@ module.exports = {
|
||||
let upstream = null;
|
||||
let generation = 0; // callbacks from a superseded connection are ignored
|
||||
let retryTimer = null;
|
||||
let retryPending = false; // waiting out the backoff, with no connect() under way
|
||||
let retryMs = RETRY_MIN_MS;
|
||||
|
||||
const authHeaders = () => (apiKey ? { authorization: `Bearer ${apiKey}` } : {});
|
||||
@@ -190,7 +210,11 @@ module.exports = {
|
||||
headers: { accept: 'application/json', ...authHeaders() },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} for ${pathname}`);
|
||||
if (!res.ok) {
|
||||
const err = new Error(`HTTP ${res.status} for ${pathname}`);
|
||||
err.httpStatus = res.status; // ProdCom answered, so it is up
|
||||
throw err;
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
@@ -220,6 +244,22 @@ module.exports = {
|
||||
broadcast('status', status);
|
||||
}
|
||||
|
||||
// Keeps ProdCom itself up when it is on this machine (supervisor.js); a
|
||||
// no-op everywhere else. Its words reach the tile through setStatus. This
|
||||
// module is re-init'ed whenever its config is saved, so `config` is current.
|
||||
const supervisor = createSupervisor({
|
||||
settings: () => ({ autoStart: config.autoStart !== false, apiRestart: config.apiRestart !== false }),
|
||||
target: () => ({ host: target ? target.hostname : '', port: target ? Number(target.port) || 80 : 0 }),
|
||||
log,
|
||||
status: setStatus,
|
||||
// ProdCom answers again: connect now rather than when the backoff runs out — unless a connect is already under way.
|
||||
onUp: () => {
|
||||
if (stopped || !retryPending) return;
|
||||
clearTimeout(retryTimer);
|
||||
connect();
|
||||
},
|
||||
});
|
||||
|
||||
function trim() {
|
||||
while (entries.size > ENTRIES_MAX) entries.delete(entries.keys().next().value);
|
||||
}
|
||||
@@ -281,22 +321,29 @@ module.exports = {
|
||||
function scheduleRetry() {
|
||||
if (stopped) return;
|
||||
clearTimeout(retryTimer);
|
||||
retryPending = true;
|
||||
retryTimer = setTimeout(connect, retryMs);
|
||||
retryMs = Math.min(RETRY_MAX_MS, retryMs * 2);
|
||||
}
|
||||
|
||||
function fail(gen, why) {
|
||||
/** `code` is set when the connection itself failed (see connectionCode), not when ProdCom answered badly. */
|
||||
function fail(gen, why, code = '') {
|
||||
if (stopped || gen !== generation) return;
|
||||
generation += 1; // whatever else this connection reports is stale now
|
||||
try { upstream?.destroy(); } catch { /* already gone */ }
|
||||
upstream = null;
|
||||
setStatus('error', `ProdCom unreachable (${why}) — retrying`);
|
||||
log(`ProdCom ${target.origin}: ${why} — retrying in ${retryMs / 1000} s`);
|
||||
if (code) supervisor.kick(code);
|
||||
// While the supervisor is starting ProdCom — or has given up and says why —
|
||||
// its words are the ones worth reading, not "unreachable" on every retry.
|
||||
const shown = code || why; // the normalised code where there is one (a timeout reads ETIMEDOUT, not 23)
|
||||
if (!(code && supervisor.message())) setStatus('error', `ProdCom unreachable (${shown}) — retrying`);
|
||||
log(`ProdCom ${target.origin}: ${shown} — retrying in ${retryMs / 1000} s`);
|
||||
scheduleRetry();
|
||||
}
|
||||
|
||||
/** Lists and history first (a fresh snapshot for the tiles), then the live stream. */
|
||||
async function connect() {
|
||||
retryPending = false;
|
||||
if (stopped || !target) return;
|
||||
const gen = ++generation;
|
||||
try {
|
||||
@@ -304,7 +351,8 @@ module.exports = {
|
||||
await loadHistory();
|
||||
} catch (err) {
|
||||
if (gen !== generation || stopped) return;
|
||||
return fail(gen, err.cause?.code || err.code || err.message);
|
||||
if (err.httpStatus) supervisor.noteReachable(); // 401, 5xx…: ProdCom is up, whatever it said
|
||||
return fail(gen, err.cause?.code || err.code || err.message, connectionCode(err));
|
||||
}
|
||||
if (gen !== generation || stopped) return;
|
||||
broadcast('state', snapshot());
|
||||
@@ -319,9 +367,11 @@ module.exports = {
|
||||
if (gen !== generation) return void res.destroy();
|
||||
if (res.statusCode !== 200) {
|
||||
res.resume();
|
||||
supervisor.noteReachable(); // ProdCom answered
|
||||
return fail(gen, `HTTP ${res.statusCode} from the transcript stream`);
|
||||
}
|
||||
retryMs = RETRY_MIN_MS;
|
||||
supervisor.noteReachable();
|
||||
setStatus('ok', 'Live');
|
||||
log(`following ${target.origin} — ${channels.length} channels, ${entries.size} entries`);
|
||||
let buf = '';
|
||||
@@ -337,9 +387,9 @@ module.exports = {
|
||||
if (buf.length > 1e6) buf = ''; // a frame that never ends is dropped
|
||||
});
|
||||
res.on('end', () => fail(gen, 'stream ended'));
|
||||
res.on('error', (err) => fail(gen, err.code || err.message));
|
||||
res.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
|
||||
});
|
||||
req.on('error', (err) => fail(gen, err.code || err.message));
|
||||
req.on('error', (err) => fail(gen, err.code || err.message, connectionCode(err)));
|
||||
req.end();
|
||||
upstream = req;
|
||||
}
|
||||
@@ -449,6 +499,7 @@ module.exports = {
|
||||
stop() {
|
||||
stopped = true;
|
||||
generation += 1;
|
||||
supervisor.stop();
|
||||
clearTimeout(retryTimer);
|
||||
clearInterval(listsTimer);
|
||||
clearInterval(heartbeat);
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ProdCom Transcript — keeping ProdCom itself running.
|
||||
*
|
||||
* When ProdCom runs on the same Mac as ProdDash (the booth Mac) and its API
|
||||
* stops answering, the usual reason is that nobody has opened ProdCom, or that
|
||||
* its Application API is switched off — ProdCom reads that switch only at
|
||||
* launch. This watches for that and puts it right: open ProdCom if it is not
|
||||
* running; if it is running with the API off, turn the API on and restart it.
|
||||
* Nothing here does anything on another OS, or when ProdCom is on another
|
||||
* machine — a remote ProdCom is somebody else's to look after.
|
||||
*
|
||||
* The module calls kick(code) every time a connection to ProdCom fails and
|
||||
* noteReachable() when one succeeds; the rest is decided here. A "run" is up to
|
||||
* three attempts, each: act, wait five seconds, ask the API whether it is up.
|
||||
* When a run cannot fix it, the supervisor gives up for that outage — kick()
|
||||
* then returns at once, so the module's reconnect loop can call it every few
|
||||
* seconds without anything happening — until a connection succeeds again.
|
||||
*
|
||||
* Every touch of the machine (is ProdCom running, open it, read/write its
|
||||
* preference, quit it) and of the network (the ping) is injected, so
|
||||
* supervisor.test.js drives the decisions with fakes; the real ones are
|
||||
* defaultOs() and httpProbe() below, all child_process.execFile with an
|
||||
* argument list — never a shell string — and a timeout.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const nodeOs = require('os');
|
||||
const { execFile } = require('child_process');
|
||||
|
||||
const BUNDLE_ID = 'com.prodcom.prodcom';
|
||||
const PROCESS_NAME = 'ProdCom';
|
||||
const API_PREF = 'applicationAPIEnabled'; // a UserDefaults bool in the app's own domain
|
||||
|
||||
const MAX_ATTEMPTS = 3;
|
||||
const ATTEMPT_WAIT_MS = 5000; // after acting, before asking whether the API answers
|
||||
const PROBE_TIMEOUT_MS = 3000;
|
||||
const QUICK_TOOL_MS = 5000; // pgrep, defaults, pkill
|
||||
const SLOW_TOOL_MS = 10000; // open
|
||||
const QUIT_ASK_MS = 5000; // osascript's patience: a ProdCom that will not answer an Apple Event is told with SIGTERM
|
||||
const QUIT_WAIT_MS = 10000; // for the process to be gone after asking it to quit
|
||||
const QUIT_POLL_MS = 500;
|
||||
|
||||
/* ── the real machine ───────────────────────────────────────────────── */
|
||||
|
||||
/** Run a program with an argument list (`execFileImpl` is child_process.execFile, or a fake in tests).
|
||||
Never rejects: { ok, exitCode, error, stdout, stderr } —
|
||||
exitCode is the program's own exit status, or null when it never got to exit
|
||||
normally (not found, timed out, killed). */
|
||||
function runProgram(execFileImpl, file, args, timeout) {
|
||||
return new Promise((resolve) => {
|
||||
execFileImpl(file, args, { timeout, windowsHide: true }, (error, stdout, stderr) => {
|
||||
resolve({
|
||||
ok: !error,
|
||||
exitCode: error ? (typeof error.code === 'number' ? error.code : null) : 0,
|
||||
error,
|
||||
stdout: String(stdout || ''),
|
||||
stderr: String(stderr || ''),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/** What went wrong with an exec() result, for a message: how it failed, then the first line the program said about it. */
|
||||
function why(r) {
|
||||
let how = 'unknown';
|
||||
if (r.exitCode !== null) how = `exit ${r.exitCode}`;
|
||||
else if (r.error) how = r.error.killed ? 'timed out' : (r.error.code || r.error.message);
|
||||
const said = r.stderr.split('\n').map((l) => l.trim()).find(Boolean);
|
||||
if (!said) return how;
|
||||
return `${how}: ${said.length > 120 ? `${said.slice(0, 120)}…` : said}`;
|
||||
}
|
||||
|
||||
/** A sleep the supervisor can cut short: resolves true after `ms`, false at once
|
||||
when `signal` aborts (its timer is cleared, so nothing is left behind). */
|
||||
function abortableSleep(ms, signal) {
|
||||
return new Promise((resolve) => {
|
||||
if (signal && signal.aborted) return resolve(false);
|
||||
let timer = null;
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve(false);
|
||||
};
|
||||
timer = setTimeout(() => {
|
||||
if (signal) signal.removeEventListener('abort', onAbort);
|
||||
resolve(true);
|
||||
}, ms);
|
||||
timer.unref?.();
|
||||
if (signal) signal.addEventListener('abort', onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/** `defaults read` prints a bool as 1 / 0; anything else is not an answer. */
|
||||
function parseApiPref(stdout) {
|
||||
const s = String(stdout || '').trim().toLowerCase();
|
||||
if (s === '1' || s === 'true' || s === 'yes') return true;
|
||||
if (s === '0' || s === 'false' || s === 'no') return false;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The address as one canonical spelling, so a configured "::1" matches the interface's. */
|
||||
function canonicalAddress(a) {
|
||||
let s = String(a ?? '').trim().toLowerCase();
|
||||
if (s.startsWith('[') && s.endsWith(']')) s = s.slice(1, -1);
|
||||
s = s.replace(/%.*$/, ''); // a scope id (fe80::1%en0)
|
||||
if (s.includes(':')) {
|
||||
try { s = new URL(`http://[${s}]`).hostname.slice(1, -1); } catch { /* not an address */ }
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Is `host` this machine? `hostname` and `addresses` are this machine's own
|
||||
(os.hostname(), every address of os.networkInterfaces()) — passed in so this
|
||||
is a pure function. */
|
||||
function isLocalHostName(host, hostname, addresses) {
|
||||
const h = canonicalAddress(host).replace(/\.$/, '');
|
||||
if (h === '' || h === 'localhost' || h === '127.0.0.1' || h === '::1' || h === '0.0.0.0') return true;
|
||||
const bare = (s) => String(s).toLowerCase().replace(/\.$/, '').replace(/\.local$/, '');
|
||||
if (hostname && bare(hostname) === bare(h)) return true;
|
||||
return (addresses || []).some((a) => canonicalAddress(a) === h);
|
||||
}
|
||||
|
||||
/** Is anything answering HTTP at host:port? Any response counts, whatever its
|
||||
status — the API's ping needs no key, and a 401 is still a live server. */
|
||||
function httpProbe(host, port) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (up) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
resolve(up);
|
||||
};
|
||||
let h = canonicalAddress(host);
|
||||
if (!h || h === '0.0.0.0') h = '127.0.0.1';
|
||||
try {
|
||||
const req = http.get({
|
||||
host: h,
|
||||
port,
|
||||
path: '/api/v1/ping',
|
||||
agent: false,
|
||||
timeout: PROBE_TIMEOUT_MS,
|
||||
headers: { accept: 'application/json' },
|
||||
}, (res) => {
|
||||
res.resume();
|
||||
finish(true);
|
||||
});
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
finish(false);
|
||||
});
|
||||
req.on('error', () => finish(false));
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The operating-system layer: macOS commands, each run with execFile.
|
||||
* `deps` is for tests only — { execFile, sleep, now } replace the real
|
||||
* child_process.execFile, abortableSleep and Date.now.
|
||||
*/
|
||||
function defaultOs(deps = {}) {
|
||||
const exec = (file, args, timeout) => runProgram(deps.execFile || execFile, file, args, timeout);
|
||||
const wait = deps.sleep || abortableSleep;
|
||||
const now = deps.now || Date.now;
|
||||
|
||||
async function isRunning() {
|
||||
const r = await exec('/usr/bin/pgrep', ['-x', PROCESS_NAME], QUICK_TOOL_MS);
|
||||
if (r.ok) return true;
|
||||
if (r.exitCode === 1) return false; // pgrep's "nothing matched"
|
||||
throw new Error(`Could not check whether ProdCom is running (${why(r)})`);
|
||||
}
|
||||
|
||||
/** Open ProdCom in the background (-g): a booth Mac must not lose the keyboard from ProPresenter or OBS mid-service. */
|
||||
async function launch() {
|
||||
const r = await exec('/usr/bin/open', ['-g', '-b', BUNDLE_ID], SLOW_TOOL_MS);
|
||||
if (r.ok) return;
|
||||
if (r.exitCode !== null) throw new Error('ProdCom is not installed on this machine');
|
||||
throw new Error(`Could not open ProdCom (${why(r)})`);
|
||||
}
|
||||
|
||||
async function readApiPref() {
|
||||
const r = await exec('/usr/bin/defaults', ['read', BUNDLE_ID, API_PREF], QUICK_TOOL_MS);
|
||||
if (r.ok) return parseApiPref(r.stdout);
|
||||
if (r.exitCode !== null) return null; // the key does not exist yet
|
||||
// Not knowing is not the same as "off": it would restart a ProdCom that is fine.
|
||||
throw new Error(`Could not read ProdCom's settings (${why(r)})`);
|
||||
}
|
||||
|
||||
async function writeApiPref() {
|
||||
const r = await exec('/usr/bin/defaults', ['write', BUNDLE_ID, API_PREF, '-bool', 'true'], QUICK_TOOL_MS);
|
||||
if (!r.ok) throw new Error(`Could not turn on ProdCom's API (${why(r)})`);
|
||||
}
|
||||
|
||||
/** Ask ProdCom to quit; if it will not be asked, terminate it. Resolves when it is gone.
|
||||
Once `signal` aborts it does nothing more — no SIGTERM, no further look at the process list. */
|
||||
async function quit(signal) {
|
||||
const cancelled = () => Boolean(signal && signal.aborted);
|
||||
if (cancelled()) throw new Error('cancelled');
|
||||
const asked = await exec('/usr/bin/osascript', ['-e', `tell application id "${BUNDLE_ID}" to quit`], QUIT_ASK_MS);
|
||||
if (cancelled()) throw new Error('cancelled');
|
||||
if (!asked.ok) await exec('/usr/bin/pkill', ['-x', '-TERM', PROCESS_NAME], QUICK_TOOL_MS);
|
||||
const until = now() + QUIT_WAIT_MS;
|
||||
for (;;) {
|
||||
if (cancelled()) throw new Error('cancelled');
|
||||
if (!(await isRunning())) return;
|
||||
if (now() >= until) throw new Error('ProdCom would not quit');
|
||||
await wait(QUIT_POLL_MS, signal);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
platform: process.platform,
|
||||
isLocalHost(host) {
|
||||
const addresses = [];
|
||||
for (const list of Object.values(nodeOs.networkInterfaces())) {
|
||||
for (const a of list || []) addresses.push(a.address);
|
||||
}
|
||||
return isLocalHostName(host, nodeOs.hostname(), addresses);
|
||||
},
|
||||
isRunning,
|
||||
launch,
|
||||
readApiPref,
|
||||
writeApiPref,
|
||||
quit,
|
||||
};
|
||||
}
|
||||
|
||||
/* ── the decisions ──────────────────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* @param opts.settings () => { autoStart, apiRestart } read live, every time
|
||||
* @param opts.target () => { host, port } where the module points ProdCom
|
||||
* @param opts.probe () => Promise<boolean> optional; the API answers (default: GET /api/v1/ping)
|
||||
* @param opts.os the machine layer (see defaultOs); optional, defaults to the real one
|
||||
* @param opts.sleep (ms, signal) => Promise optional; the default is cut short by `signal`
|
||||
* @param opts.log (line) => void
|
||||
* @param opts.status (state, message) => void the tile's status; state is 'connecting' | 'error'
|
||||
* @param opts.onUp () => void optional; ProdCom answers again — the module may reconnect now
|
||||
*/
|
||||
function createSupervisor(opts) {
|
||||
const { settings, target, log, status } = opts;
|
||||
const onUp = opts.onUp || (() => {});
|
||||
const os = opts.os || defaultOs();
|
||||
const probe = opts.probe || (() => { const t = target(); return httpProbe(t.host, t.port); });
|
||||
const sleep = opts.sleep || abortableSleep;
|
||||
|
||||
let stopped = false;
|
||||
let busy = false; // a run is in progress
|
||||
let gaveUp = false; // this outage is beyond the supervisor until a connection succeeds
|
||||
let msg = null; // the supervisor's words for the tile
|
||||
let epoch = 0; // a run belongs to the epoch it started in; a new one ends it
|
||||
let abort = null; // cuts the running run's sleep (and quit) short
|
||||
let current = Promise.resolve();
|
||||
const said = new Set(); // no-op reasons already logged
|
||||
|
||||
/** End the run in progress, if any: it unwinds without another word. */
|
||||
function cancelRun() {
|
||||
epoch += 1;
|
||||
if (abort) {
|
||||
abort.abort();
|
||||
abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Nothing to do, and why — logged once per distinct reason, not on every reconnect tick. */
|
||||
function skip(reason) {
|
||||
if (!said.has(reason)) {
|
||||
said.add(reason);
|
||||
log(reason);
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
/** Wait `ms`, but never past stop() or the end of the run, whatever `sleep` does with the signal. */
|
||||
function pause(ms, signal) {
|
||||
return new Promise((resolve) => {
|
||||
if (signal.aborted) return resolve();
|
||||
const done = () => {
|
||||
signal.removeEventListener('abort', done);
|
||||
resolve();
|
||||
};
|
||||
signal.addEventListener('abort', done, { once: true });
|
||||
Promise.resolve().then(() => sleep(ms, signal)).then(done, done);
|
||||
});
|
||||
}
|
||||
|
||||
async function run(myEpoch, signal) {
|
||||
const live = () => !stopped && epoch === myEpoch;
|
||||
const say = (state, text) => {
|
||||
msg = text;
|
||||
status(state, text);
|
||||
};
|
||||
const giveUp = (text) => {
|
||||
gaveUp = true;
|
||||
say('error', text);
|
||||
log(text);
|
||||
};
|
||||
|
||||
let restarted = false; // ProdCom was quit and opened again in this run — once is all it gets
|
||||
let opened = false; // this run opened ProdCom itself: if it is running now, it may simply still be starting up
|
||||
let apiOnSilent = false; // the latest look found ProdCom running, API on, port silent, and not of our opening
|
||||
try {
|
||||
for (let n = 1; n <= MAX_ATTEMPTS; n += 1) {
|
||||
const s = settings();
|
||||
apiOnSilent = false;
|
||||
// Look afresh every attempt: one sample proves little (a ProdCom that is quitting still shows as running).
|
||||
const running = await os.isRunning();
|
||||
if (!live()) return;
|
||||
if (!running) {
|
||||
// Not there — never opened, quit by hand, or gone since the restart. A launch, never a second quit.
|
||||
await os.launch();
|
||||
if (!live()) return;
|
||||
opened = true;
|
||||
say('connecting', `ProdCom is not running — starting it (${n}/${MAX_ATTEMPTS})`);
|
||||
} else if (!restarted) {
|
||||
const pref = await os.readApiPref();
|
||||
if (!live()) return;
|
||||
if (pref === true) {
|
||||
// Running with its API on and the port still silent. Slow to start, quitting, or the wrong port —
|
||||
// one look cannot tell, so wait and look again; only the last attempt blames the port.
|
||||
apiOnSilent = !opened;
|
||||
} else {
|
||||
// The API is off (or the key was never written): switch it on for ProdCom's next start…
|
||||
await os.writeApiPref();
|
||||
if (!live()) return;
|
||||
// …and restart ProdCom only if the setting really holds: that restart cuts its intercom.
|
||||
const now = await os.readApiPref();
|
||||
if (!live()) return;
|
||||
if (now !== true) {
|
||||
return giveUp(`Could not turn on ProdCom's API — it still reads ${now === false ? 'off' : 'unset'}`);
|
||||
}
|
||||
if (!s.apiRestart) {
|
||||
return giveUp("ProdCom's API is off — restart ProdCom to turn it on");
|
||||
}
|
||||
say('connecting', "ProdCom's API is off — turning it on and restarting ProdCom");
|
||||
await os.quit(signal);
|
||||
if (!live()) return;
|
||||
await os.launch();
|
||||
if (!live()) return;
|
||||
restarted = true;
|
||||
}
|
||||
}
|
||||
await pause(ATTEMPT_WAIT_MS, signal);
|
||||
if (!live()) return;
|
||||
const up = await Promise.resolve().then(probe).catch(() => false);
|
||||
if (!live()) return;
|
||||
if (up) {
|
||||
log(`ProdCom is up after ${n} attempt(s)`);
|
||||
say('connecting', 'ProdCom is back — reconnecting');
|
||||
try {
|
||||
onUp();
|
||||
} catch (err) {
|
||||
log(`ProdCom auto-start: reconnect nudge failed (${err && err.message ? err.message : err})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
giveUp(apiOnSilent
|
||||
? `ProdCom is running with its API on, but port ${target().port} does not answer — check its API port`
|
||||
: 'ProdCom would not start — open it by hand');
|
||||
} catch (err) {
|
||||
if (!live()) return;
|
||||
giveUp(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
/** Decide whether a run is called for, and start it: the reason it did not, or null. */
|
||||
function start(code) {
|
||||
if (stopped) return 'stopped';
|
||||
if (!settings().autoStart) return skip('ProdCom auto-start is off');
|
||||
if (os.platform !== 'darwin') return skip('ProdCom auto-start is macOS only');
|
||||
const { host } = target();
|
||||
if (!os.isLocalHost(host)) return skip(`ProdCom at ${host} is not on this machine`);
|
||||
if (busy) return skip('ProdCom auto-start is already working on it');
|
||||
if (gaveUp) return skip('ProdCom auto-start has given up until ProdCom answers');
|
||||
|
||||
busy = true;
|
||||
said.clear(); // a new outage says its reasons again
|
||||
const myEpoch = epoch;
|
||||
const controller = new AbortController();
|
||||
abort = controller;
|
||||
log(`ProdCom did not answer (${code}) — checking whether to start it`);
|
||||
current = run(myEpoch, controller.signal)
|
||||
.catch((err) => log(`ProdCom auto-start failed: ${err && err.message ? err.message : err}`))
|
||||
.then(() => {
|
||||
busy = false;
|
||||
if (abort === controller) abort = null;
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* A connection to ProdCom failed (`code` is the error code). Starts a run
|
||||
* unless there is nothing to do; answers the reason when it does nothing,
|
||||
* null when it started one.
|
||||
*/
|
||||
kick(code) {
|
||||
try {
|
||||
return start(code);
|
||||
} catch (err) {
|
||||
// Called from the module's reconnect path: whatever goes wrong here must not stop it reconnecting.
|
||||
return skip(`ProdCom auto-start could not decide (${err && err.message ? err.message : err})`);
|
||||
}
|
||||
},
|
||||
|
||||
/** A connection to ProdCom succeeded: this outage is over, the next gets a fresh run. */
|
||||
noteReachable() {
|
||||
if (busy) log('ProdCom answered on its own — ending the start-up run');
|
||||
gaveUp = false;
|
||||
msg = null;
|
||||
said.clear();
|
||||
cancelRun();
|
||||
},
|
||||
|
||||
/** The supervisor's words for the tile while it is working or has given up, else null. */
|
||||
message() {
|
||||
return busy || gaveUp ? msg : null;
|
||||
},
|
||||
|
||||
/** Resolves when no run is in progress. */
|
||||
idle() {
|
||||
return current;
|
||||
},
|
||||
|
||||
stop() {
|
||||
stopped = true;
|
||||
msg = null;
|
||||
cancelRun();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSupervisor, defaultOs, isLocalHostName, parseApiPref, abortableSleep };
|
||||
@@ -0,0 +1,832 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for the ProdCom supervisor (supervisor.js).
|
||||
*
|
||||
* node --test modules/prodcom-transcript/supervisor.test.js
|
||||
*
|
||||
* Fakes only. The decisions run on a fake machine layer (is ProdCom running,
|
||||
* open it, its preference, quit it), a fake ping, a fake sleep, log and tile
|
||||
* status; the real machine layer (defaultOs) runs only on a fake execFile, a
|
||||
* fake sleep and a fake clock. Nothing here starts, quits or configures a real
|
||||
* ProdCom, runs a command or waits on a timer. (One test aborts the real
|
||||
* abortableSleep with an hour on it: the timer is cleared at once.)
|
||||
*/
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
createSupervisor, defaultOs, isLocalHostName, parseApiPref, abortableSleep,
|
||||
} = require('./supervisor');
|
||||
|
||||
const START = (n) => ['connecting', `ProdCom is not running — starting it (${n}/3)`];
|
||||
const BACK = ['connecting', 'ProdCom is back — reconnecting'];
|
||||
const NO_START = ['error', 'ProdCom would not start — open it by hand'];
|
||||
const API_OFF_RESTART = ['connecting', "ProdCom's API is off — turning it on and restarting ProdCom"];
|
||||
const API_OFF_STAY = ['error', "ProdCom's API is off — restart ProdCom to turn it on"];
|
||||
const API_PORT = ['error', 'ProdCom is running with its API on, but port 24480 does not answer — check its API port'];
|
||||
const NOT_STUCK = (what) => ['error', `Could not turn on ProdCom's API — it still reads ${what}`];
|
||||
|
||||
/**
|
||||
* A supervisor on fakes. `calls` is every machine action in order, `sleeps` the
|
||||
* waits asked for, `statuses` what the tile was told, `logs` what the log got,
|
||||
* `ups` how often onUp fired. `hold` makes the Nth call of a fake hold until
|
||||
* the test lets it go — `hold: { 'launch#1': true }`, then `r.release('launch#1')`
|
||||
* — so a run can be looked at, or ended, in the middle of a machine call.
|
||||
*/
|
||||
function rig(o = {}) {
|
||||
const settings = { autoStart: true, apiRestart: true, ...(o.settings || {}) };
|
||||
const machine = { running: false, pref: null, local: true, ...(o.machine || {}) };
|
||||
const calls = [];
|
||||
const sleeps = [];
|
||||
const statuses = [];
|
||||
const logs = [];
|
||||
const order = []; // statuses and onUp, in the order they happened
|
||||
const probes = [...(o.probes || [])];
|
||||
const counts = {};
|
||||
const gates = {};
|
||||
let probed = 0;
|
||||
let ups = 0;
|
||||
|
||||
const held = (name) => {
|
||||
counts[name] = (counts[name] || 0) + 1;
|
||||
const key = `${name}#${counts[name]}`;
|
||||
if (!(o.hold && o.hold[key])) return null;
|
||||
return new Promise((resolve) => { gates[key] = resolve; });
|
||||
};
|
||||
|
||||
const os = {
|
||||
platform: o.platform || 'darwin',
|
||||
isLocalHost: (host) => {
|
||||
calls.push(`isLocalHost:${host}`);
|
||||
return machine.local;
|
||||
},
|
||||
isRunning: async () => {
|
||||
calls.push('isRunning');
|
||||
const g = held('isRunning');
|
||||
if (g) await g;
|
||||
if (o.runningError) throw new Error(o.runningError);
|
||||
// `runningSeq` scripts it per attempt (the last answer repeats); otherwise it is whatever `machine.running` says.
|
||||
if (o.runningSeq) return o.runningSeq.length > 1 ? o.runningSeq.shift() : o.runningSeq[0];
|
||||
return machine.running;
|
||||
},
|
||||
launch: async () => {
|
||||
calls.push('launch');
|
||||
const g = held('launch');
|
||||
if (g) await g;
|
||||
if (o.launchError) throw new Error(o.launchError);
|
||||
},
|
||||
readApiPref: async () => {
|
||||
calls.push('readApiPref');
|
||||
const g = held('readApiPref');
|
||||
if (g) await g;
|
||||
if (o.readError) throw new Error(o.readError);
|
||||
return machine.pref;
|
||||
},
|
||||
writeApiPref: async () => {
|
||||
calls.push('writeApiPref');
|
||||
const g = held('writeApiPref');
|
||||
if (g) await g;
|
||||
if (o.writeError) throw new Error(o.writeError);
|
||||
if (o.writeSticks !== false) machine.pref = true; // the preference now reads on, as `defaults write` makes it
|
||||
},
|
||||
quit: async () => {
|
||||
calls.push('quit');
|
||||
const g = held('quit');
|
||||
if (g) await g;
|
||||
if (o.quitError) throw new Error(o.quitError);
|
||||
},
|
||||
};
|
||||
|
||||
const sup = createSupervisor({
|
||||
settings: () => settings,
|
||||
target: () => ({ host: o.host ?? '127.0.0.1', port: 24480 }),
|
||||
probe: async () => {
|
||||
probed += 1;
|
||||
const g = held('probe');
|
||||
if (g) await g;
|
||||
return probes.length ? probes.shift() : false;
|
||||
},
|
||||
os,
|
||||
sleep: o.sleep || ((ms) => {
|
||||
sleeps.push(ms);
|
||||
return Promise.resolve();
|
||||
}),
|
||||
log: (line) => logs.push(line),
|
||||
status: (state, message) => {
|
||||
statuses.push([state, message]);
|
||||
order.push(`status:${message}`);
|
||||
},
|
||||
onUp: () => {
|
||||
ups += 1;
|
||||
order.push('onUp');
|
||||
if (o.upError) throw new Error(o.upError);
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
sup, settings, machine, calls, sleeps, statuses, logs, order,
|
||||
probed: () => probed,
|
||||
ups: () => ups,
|
||||
isHeld: (key) => typeof gates[key] === 'function',
|
||||
release: (key) => gates[key](),
|
||||
/** The machine actions, without the host check every kick starts with. */
|
||||
actions: () => calls.filter((c) => !c.startsWith('isLocalHost')),
|
||||
};
|
||||
}
|
||||
|
||||
/** A sleep that waits until the test lets it go — to look at a run mid-flight. */
|
||||
function heldSleep() {
|
||||
const held = [];
|
||||
const sleep = (ms, signal) => new Promise((resolve) => held.push({ ms, signal, release: resolve }));
|
||||
return { sleep, held, releaseAll: () => held.splice(0).forEach((h) => h.release()) };
|
||||
}
|
||||
|
||||
const tick = () => new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
/* ── a run, start to finish ─────────────────────────────────────────── */
|
||||
|
||||
test('not running: opens ProdCom, and the first probe answers', async () => {
|
||||
const r = rig({ probes: [true] });
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
assert.deepEqual(r.sleeps, [5000]);
|
||||
assert.equal(r.probed(), 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 1 attempt(s)'));
|
||||
assert.equal(r.ups(), 1, 'the module is told to reconnect now');
|
||||
assert.equal(r.order[r.order.length - 1], 'onUp', '…after the tile has been told');
|
||||
// The run ended well: the module's own reconnect does the rest, and fail() may speak for itself again.
|
||||
assert.equal(r.sup.message(), null);
|
||||
});
|
||||
|
||||
test('not running: three tries that never answer, then it gives up and stays quiet', async () => {
|
||||
const r = rig({ probes: [false, false, false] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'launch', 'isRunning', 'launch']);
|
||||
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
|
||||
assert.equal(r.probed(), 3);
|
||||
assert.deepEqual(r.statuses, [START(1), START(2), START(3), NO_START]);
|
||||
assert.ok(r.logs.includes(NO_START[1]));
|
||||
assert.equal(r.sup.message(), NO_START[1]); // fail() keeps this instead of "unreachable"
|
||||
assert.equal(r.ups(), 0, 'no reconnect nudge for a run that failed');
|
||||
|
||||
const before = { statuses: r.statuses.length, sleeps: r.sleeps.length, probed: r.probed() };
|
||||
for (let i = 0; i < 5; i += 1) assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
await r.sup.idle();
|
||||
assert.equal(r.actions().length, 6, 'no machine action once given up');
|
||||
assert.equal(r.statuses.length, before.statuses);
|
||||
assert.equal(r.sleeps.length, before.sleeps);
|
||||
assert.equal(r.probed(), before.probed);
|
||||
});
|
||||
|
||||
test('a reconnect nudge that throws does not spoil the run', async () => {
|
||||
const r = rig({ probes: [true], upError: 'boom' });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
assert.ok(r.logs.some((l) => l.includes('reconnect nudge failed (boom)')));
|
||||
assert.equal(r.sup.message(), null);
|
||||
});
|
||||
|
||||
/* ── the API is off ─────────────────────────────────────────────────── */
|
||||
|
||||
test('running with the API off: writes the preference, checks it took, restarts ProdCom once, then only looks and probes', async () => {
|
||||
const r = rig({ machine: { running: true, pref: null }, probes: [false, false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), [
|
||||
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
|
||||
'isRunning', 'isRunning', // attempts 2 and 3: still there, so nothing but the probe
|
||||
]);
|
||||
assert.deepEqual(r.statuses, [API_OFF_RESTART, BACK]);
|
||||
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
|
||||
assert.equal(r.probed(), 3);
|
||||
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
|
||||
});
|
||||
|
||||
test('the restart waits for the preference to read on: when it does not stick, nothing is quit', async () => {
|
||||
for (const [pref, what] of [[null, 'unset'], [false, 'off']]) {
|
||||
for (const apiRestart of [true, false]) {
|
||||
const r = rig({ machine: { running: true, pref }, writeSticks: false, settings: { apiRestart } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref'], `pref ${pref}, apiRestart ${apiRestart}`);
|
||||
assert.deepEqual(r.statuses, [NOT_STUCK(what)]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.sup.message(), NOT_STUCK(what)[1]);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, 4);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a preference that cannot be written: says so, restarts nothing', async () => {
|
||||
const r = rig({ machine: { running: true, pref: false }, writeError: "Could not turn on ProdCom's API (exit 1: no)" });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref']);
|
||||
assert.deepEqual(r.statuses, [['error', "Could not turn on ProdCom's API (exit 1: no)"]]);
|
||||
});
|
||||
|
||||
test('running with the API off and restarting not allowed: writes the preference and says so', async () => {
|
||||
const r = rig({ machine: { running: true, pref: false }, settings: { apiRestart: false } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [API_OFF_STAY]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.sup.message(), API_OFF_STAY[1]);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, 4);
|
||||
});
|
||||
|
||||
test('after the one restart, a ProdCom that is not running is opened again — never quit a second time', async () => {
|
||||
const r = rig({ runningSeq: [true, false, true], machine: { pref: null }, probes: [false, false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), [
|
||||
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
|
||||
'isRunning', 'launch', // attempt 2: gone again — a launch
|
||||
'isRunning', // attempt 3: there; just the probe
|
||||
]);
|
||||
assert.deepEqual(r.statuses, [API_OFF_RESTART, START(2), BACK]);
|
||||
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
|
||||
});
|
||||
|
||||
/* ── the API is on, and the port is silent ──────────────────────────── */
|
||||
|
||||
test('running with the API on: it is looked at again each attempt, and only the last blames the port', async () => {
|
||||
const r = rig({ machine: { running: true, pref: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [API_PORT], 'no verdict before the third look');
|
||||
assert.deepEqual(r.sleeps, [5000, 5000, 5000]);
|
||||
assert.equal(r.probed(), 3);
|
||||
assert.equal(r.sup.message(), API_PORT[1]);
|
||||
assert.equal(r.calls.includes('writeApiPref'), false);
|
||||
assert.equal(r.calls.includes('quit'), false);
|
||||
assert.equal(r.calls.includes('launch'), false);
|
||||
});
|
||||
|
||||
test('running with the API on, and it starts answering on the second look: no blame at all', async () => {
|
||||
const r = rig({ machine: { running: true, pref: true }, probes: [false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [BACK]);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
|
||||
});
|
||||
|
||||
test('running with the API on, then gone on the second look (it was quitting): it is opened', async () => {
|
||||
const r = rig({ runningSeq: [true, false], machine: { pref: true }, probes: [false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [START(2), BACK]);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
|
||||
});
|
||||
|
||||
test('gone only on the last look: opened, and if it still will not answer that is "would not start"', async () => {
|
||||
const r = rig({ runningSeq: [true, true, false], machine: { pref: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'isRunning', 'readApiPref', 'isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [START(3), NO_START]);
|
||||
});
|
||||
|
||||
test('a ProdCom this run just opened, still starting up with its API on, is waited for — not blamed on the port', async () => {
|
||||
// Attempt 1: not running → opened; the 5 s probe is false. Attempt 2: it is running now, its pref is on, the port is
|
||||
// still silent — that is a slow start, not a wrong port.
|
||||
const r = rig({ runningSeq: [false, true], machine: { pref: true }, probes: [false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [START(1), BACK], 'no error status on the way');
|
||||
assert.deepEqual(r.sleeps, [5000, 5000]);
|
||||
assert.equal(r.probed(), 2);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 2 attempt(s)'));
|
||||
assert.equal(r.calls.includes('quit'), false);
|
||||
assert.equal(r.calls.includes('writeApiPref'), false);
|
||||
});
|
||||
|
||||
test('a ProdCom this run just opened that turns out to have its API off still gets the one restart', async () => {
|
||||
const r = rig({ runningSeq: [false, true], machine: { pref: null }, probes: [false, false, true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), [
|
||||
'isRunning', 'launch',
|
||||
'isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit', 'launch',
|
||||
'isRunning',
|
||||
]);
|
||||
assert.deepEqual(r.statuses, [START(1), API_OFF_RESTART, BACK]);
|
||||
assert.equal(r.calls.filter((c) => c === 'quit').length, 1);
|
||||
assert.ok(r.logs.includes('ProdCom is up after 3 attempt(s)'));
|
||||
});
|
||||
|
||||
test('a ProdCom this run opened that never answers, API on: three tries, then "would not start"', async () => {
|
||||
const r = rig({ runningSeq: [false, true, true], machine: { pref: true }, probes: [false, false, false] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch', 'isRunning', 'readApiPref', 'isRunning', 'readApiPref']);
|
||||
assert.deepEqual(r.statuses, [START(1), NO_START]);
|
||||
});
|
||||
|
||||
/* ── when there is nothing to do ────────────────────────────────────── */
|
||||
|
||||
test('nothing to do for a ProdCom on another machine', async () => {
|
||||
const r = rig({ host: '10.1.1.50', machine: { local: false } });
|
||||
const why = r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(why, 'ProdCom at 10.1.1.50 is not on this machine');
|
||||
for (let i = 0; i < 4; i += 1) r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), []);
|
||||
assert.equal(r.statuses.length, 0);
|
||||
assert.equal(r.logs.filter((l) => l === why).length, 1, 'the reason is logged once, not on every tick');
|
||||
assert.equal(r.sup.message(), null);
|
||||
});
|
||||
|
||||
test('nothing to do when switched off', async () => {
|
||||
const r = rig({ settings: { autoStart: false } });
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.calls, []);
|
||||
assert.equal(r.statuses.length, 0);
|
||||
assert.equal(r.logs.length, 1);
|
||||
});
|
||||
|
||||
test('the setting is read live: switching it on later takes effect without a new supervisor', async () => {
|
||||
const r = rig({ settings: { autoStart: false }, probes: [true] });
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
r.settings.autoStart = true;
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
});
|
||||
|
||||
test('nothing to do off macOS', async () => {
|
||||
const r = rig({ platform: 'win32' });
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), 'ProdCom auto-start is macOS only');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), []);
|
||||
assert.equal(r.statuses.length, 0);
|
||||
assert.equal(r.logs.filter((l) => l === 'ProdCom auto-start is macOS only').length, 1);
|
||||
});
|
||||
|
||||
test('a kick while a run is in progress does nothing', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true] });
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await tick();
|
||||
assert.equal(h.held.length, 1, 'the run is waiting out its five seconds');
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(typeof r.sup.kick('ETIMEDOUT'), 'string');
|
||||
assert.equal(r.sup.message(), START(1)[1], 'while it works, its words are the tile\'s');
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch'], 'no second run started');
|
||||
h.releaseAll();
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.statuses, [START(1), BACK]);
|
||||
});
|
||||
|
||||
test('kick() never throws into the reconnect loop, whatever the machine layer does', async () => {
|
||||
const r = rig();
|
||||
const sup = createSupervisor({
|
||||
settings: () => r.settings,
|
||||
target: () => ({ host: '127.0.0.1', port: 24480 }),
|
||||
os: { platform: 'darwin', isLocalHost: () => { throw new Error('no network interfaces'); } },
|
||||
probe: async () => true,
|
||||
sleep: () => Promise.resolve(),
|
||||
log: (line) => r.logs.push(line),
|
||||
status: (s, m) => r.statuses.push([s, m]),
|
||||
});
|
||||
assert.equal(sup.kick('ECONNREFUSED'), 'ProdCom auto-start could not decide (no network interfaces)');
|
||||
assert.equal(r.statuses.length, 0);
|
||||
});
|
||||
|
||||
/* ── an outage ends, a new one begins ───────────────────────────────── */
|
||||
|
||||
test('after giving up, a successful connection re-arms it for the next outage', async () => {
|
||||
const r = rig({ machine: { running: true, pref: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
const first = r.actions().length;
|
||||
assert.equal(first, 6);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, first);
|
||||
|
||||
r.sup.noteReachable();
|
||||
assert.equal(r.sup.message(), null);
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null);
|
||||
await r.sup.idle();
|
||||
assert.equal(r.actions().length, first * 2);
|
||||
assert.deepEqual(r.statuses, [API_PORT, API_PORT]);
|
||||
});
|
||||
|
||||
test('a second outage logs its reasons again', async () => {
|
||||
const r = rig({ host: '10.1.1.50', machine: { local: false } });
|
||||
const why = 'ProdCom at 10.1.1.50 is not on this machine';
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(r.logs.filter((l) => l === why).length, 1);
|
||||
r.sup.noteReachable(); // the first outage is over
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(r.logs.filter((l) => l === why).length, 2);
|
||||
|
||||
// And a run that starts clears them too: "has given up" is said once per outage, not once ever.
|
||||
const g = rig({ machine: { running: true, pref: true } });
|
||||
const gaveUp = 'ProdCom auto-start has given up until ProdCom answers';
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
await g.sup.idle();
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
assert.equal(g.logs.filter((l) => l === gaveUp).length, 1);
|
||||
g.sup.noteReachable();
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
await g.sup.idle();
|
||||
g.sup.kick('ECONNREFUSED');
|
||||
assert.equal(g.logs.filter((l) => l === gaveUp).length, 2);
|
||||
});
|
||||
|
||||
test('a run that starts clears the reasons already logged, even with no connection in between', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true, true] });
|
||||
const busy = 'ProdCom auto-start is already working on it';
|
||||
const said = () => r.logs.filter((l) => l === busy).length;
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(said(), 1);
|
||||
h.releaseAll();
|
||||
await r.sup.idle(); // the run ended well; the module has not connected (noteReachable) yet
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null, 'a new run');
|
||||
await tick();
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
assert.equal(said(), 2, 'its reason is logged again');
|
||||
h.releaseAll();
|
||||
await r.sup.idle();
|
||||
});
|
||||
|
||||
test('a connection that succeeds mid-run ends the run without another word', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.deepEqual(r.statuses, [START(1)]);
|
||||
r.sup.noteReachable(); // the module got through on its own while we waited
|
||||
h.releaseAll();
|
||||
await r.sup.idle();
|
||||
assert.equal(r.probed(), 0, 'no probe after the outage was over');
|
||||
assert.deepEqual(r.statuses, [START(1)], 'nothing overwrites the module\'s own "Live"');
|
||||
assert.equal(r.ups(), 0);
|
||||
assert.equal(r.sup.message(), null);
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), null, 'and the next outage is a fresh run');
|
||||
await tick();
|
||||
assert.deepEqual(r.statuses, [START(1), START(1)]);
|
||||
r.sup.stop();
|
||||
await r.sup.idle();
|
||||
});
|
||||
|
||||
/* ── stop() and noteReachable() land mid-run ────────────────────────── */
|
||||
|
||||
test('stop() during the sleep ends the run: no more launches, no probes', async () => {
|
||||
const h = heldSleep();
|
||||
const r = rig({ sleep: h.sleep, probes: [true] });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.equal(h.held.length, 1);
|
||||
assert.equal(h.held[0].signal.aborted, false);
|
||||
r.sup.stop();
|
||||
assert.equal(h.held[0].signal.aborted, true, 'the sleep was told to cut itself short');
|
||||
await r.sup.idle(); // settles even though the fake sleep never does
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.deepEqual(r.statuses, [START(1)]);
|
||||
assert.equal(r.sup.message(), null);
|
||||
assert.equal(r.sup.kick('ECONNREFUSED'), 'stopped');
|
||||
h.releaseAll();
|
||||
await tick();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.ups(), 0);
|
||||
});
|
||||
|
||||
// Each machine call a run makes, held in flight; the run is ended (by the module
|
||||
// connecting, or by stop()) while the call is out, and nothing may follow it:
|
||||
// no status, no log, no further call, no reconnect nudge.
|
||||
const MID_CALL = [
|
||||
['isRunning', 'isRunning#1', { machine: { running: false } }],
|
||||
['launch', 'launch#1', { machine: { running: false } }],
|
||||
['readApiPref', 'readApiPref#1', { machine: { running: true, pref: null } }],
|
||||
['writeApiPref', 'writeApiPref#1', { machine: { running: true, pref: null } }],
|
||||
['readApiPref (the check that it took)', 'readApiPref#2', { machine: { running: true, pref: null } }],
|
||||
['quit', 'quit#1', { machine: { running: true, pref: null } }],
|
||||
['launch (after the quit)', 'launch#1', { machine: { running: true, pref: null } }],
|
||||
['probe', 'probe#1', { machine: { running: false }, probes: [true] }],
|
||||
];
|
||||
for (const [label, key, setup] of MID_CALL) {
|
||||
for (const how of ['noteReachable', 'stop']) {
|
||||
test(`${how}() while ${label} is in flight: nothing follows`, async () => {
|
||||
const r = rig({ ...setup, hold: { [key]: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.ok(r.isHeld(key), `${key} is in flight`);
|
||||
r.sup[how]();
|
||||
const snapshot = { calls: [...r.calls], statuses: [...r.statuses], logs: [...r.logs], order: [...r.order], sleeps: [...r.sleeps] };
|
||||
r.release(key);
|
||||
await r.sup.idle();
|
||||
await tick();
|
||||
assert.deepEqual(r.calls, snapshot.calls, 'no further machine call');
|
||||
assert.deepEqual(r.statuses, snapshot.statuses, 'no status');
|
||||
assert.deepEqual(r.logs, snapshot.logs, 'no log line');
|
||||
assert.deepEqual(r.order, snapshot.order, 'no nudge');
|
||||
assert.deepEqual(r.sleeps, snapshot.sleeps, 'no further wait');
|
||||
assert.equal(r.probed(), key === 'probe#1' ? 1 : 0, 'no further probe');
|
||||
assert.equal(r.ups(), 0);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// …and the same when the call that was in flight ends in an error: the catch
|
||||
// must not report a failure of a run that is already over.
|
||||
const MID_CALL_ERROR = [
|
||||
['isRunning', 'isRunning#1', { machine: { running: false }, runningError: 'pgrep failed' }],
|
||||
['launch', 'launch#1', { machine: { running: false }, launchError: 'ProdCom is not installed on this machine' }],
|
||||
['readApiPref', 'readApiPref#1', { machine: { running: true, pref: null }, readError: 'defaults failed' }],
|
||||
['writeApiPref', 'writeApiPref#1', { machine: { running: true, pref: null }, writeError: 'write failed' }],
|
||||
['quit', 'quit#1', { machine: { running: true, pref: null }, quitError: 'ProdCom would not quit' }],
|
||||
['launch (after the quit)', 'launch#1', { machine: { running: true, pref: null }, launchError: 'launch failed' }],
|
||||
];
|
||||
for (const [label, key, setup] of MID_CALL_ERROR) {
|
||||
for (const how of ['noteReachable', 'stop']) {
|
||||
test(`${how}() while ${label} is in flight and then fails: no error is reported`, async () => {
|
||||
const r = rig({ ...setup, hold: { [key]: true } });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await tick();
|
||||
assert.ok(r.isHeld(key), `${key} is in flight`);
|
||||
r.sup[how]();
|
||||
const snapshot = { calls: [...r.calls], statuses: [...r.statuses], logs: [...r.logs] };
|
||||
r.release(key);
|
||||
await r.sup.idle();
|
||||
await tick();
|
||||
assert.deepEqual(r.calls, snapshot.calls);
|
||||
assert.deepEqual(r.statuses, snapshot.statuses, 'no error status');
|
||||
assert.deepEqual(r.logs, snapshot.logs, 'no error log');
|
||||
assert.equal(r.sup.message(), null, 'and nothing latched as given up');
|
||||
if (how === 'noteReachable') assert.equal(r.sup.kick('ECONNREFUSED'), null, 'the next outage still gets a run');
|
||||
r.sup.stop();
|
||||
await r.sup.idle();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
test('ProdCom is not installed: says so and gives up', async () => {
|
||||
const r = rig({ launchError: 'ProdCom is not installed on this machine' });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'launch']);
|
||||
assert.deepEqual(r.statuses, [['error', 'ProdCom is not installed on this machine']]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(r.probed(), 0);
|
||||
assert.equal(r.sup.message(), 'ProdCom is not installed on this machine');
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
assert.equal(r.actions().length, 2);
|
||||
});
|
||||
|
||||
test('ProdCom will not quit: says so, does not open it again, gives up', async () => {
|
||||
const r = rig({ machine: { running: true, pref: null }, quitError: 'ProdCom would not quit' });
|
||||
r.sup.kick('ECONNREFUSED');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref', 'writeApiPref', 'readApiPref', 'quit']);
|
||||
assert.deepEqual(r.statuses, [API_OFF_RESTART, ['error', 'ProdCom would not quit']]);
|
||||
assert.equal(r.sleeps.length, 0);
|
||||
assert.equal(typeof r.sup.kick('ECONNREFUSED'), 'string');
|
||||
});
|
||||
|
||||
test('a machine call that fails for another reason gives up with its message', async () => {
|
||||
const r = rig({ machine: { running: true }, readError: "Could not read ProdCom's settings (timed out)" });
|
||||
r.sup.kick('ETIMEDOUT');
|
||||
await r.sup.idle();
|
||||
assert.deepEqual(r.actions(), ['isRunning', 'readApiPref'], 'not knowing the setting is not a reason to restart ProdCom');
|
||||
assert.deepEqual(r.statuses, [['error', "Could not read ProdCom's settings (timed out)"]]);
|
||||
});
|
||||
|
||||
/* ── the real machine layer, on a fake execFile ─────────────────────── */
|
||||
|
||||
const ok = (stdout = '') => ({ stdout });
|
||||
const exited = (code, stderr = '') => ({ error: Object.assign(new Error('Command failed'), { code }), stderr });
|
||||
const timedOut = () => ({ error: Object.assign(new Error('timed out'), { killed: true, signal: 'SIGTERM', code: null }) });
|
||||
const BUNDLE = 'com.prodcom.prodcom';
|
||||
|
||||
/** A fake child_process.execFile answering from `script(file, args, n)` (a result, or a promise of one). */
|
||||
function fakeExecFile(script) {
|
||||
const calls = [];
|
||||
const execFile = (file, args, opts, cb) => {
|
||||
calls.push({ file, args, opts, line: [file, ...args].join(' ') });
|
||||
Promise.resolve(script(file, args, calls.length)).then((res) => {
|
||||
const r = res || {};
|
||||
cb(r.error || null, r.stdout || '', r.stderr || '');
|
||||
});
|
||||
};
|
||||
return { execFile, calls, lines: () => calls.map((c) => c.line) };
|
||||
}
|
||||
|
||||
/** A fake clock: sleeping advances it, so a wait that would last ten seconds takes no time. */
|
||||
function fakeClock() {
|
||||
let t = 0;
|
||||
const slept = [];
|
||||
return {
|
||||
now: () => t,
|
||||
sleep: (ms) => {
|
||||
slept.push(ms);
|
||||
t += ms;
|
||||
return Promise.resolve(true);
|
||||
},
|
||||
slept,
|
||||
};
|
||||
}
|
||||
|
||||
test('default os: reading the preference — 1, 0, a missing key, a timeout', async () => {
|
||||
const answers = [ok('1\n'), ok('0\n'), exited(1, 'The domain/default pair of (com.prodcom.prodcom, applicationAPIEnabled) does not exist'), timedOut()];
|
||||
const x = fakeExecFile(() => answers.shift());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
assert.equal(await os.readApiPref(), true);
|
||||
assert.equal(await os.readApiPref(), false);
|
||||
assert.equal(await os.readApiPref(), null, 'a missing key is "not set", not an error');
|
||||
await assert.rejects(os.readApiPref(), /^Error: Could not read ProdCom's settings \(timed out\)$/);
|
||||
assert.equal(x.lines()[0], `/usr/bin/defaults read ${BUNDLE} applicationAPIEnabled`);
|
||||
assert.equal(x.calls[0].opts.timeout, 5000);
|
||||
});
|
||||
|
||||
test('default os: writing the preference, and what a failure says', async () => {
|
||||
const x = fakeExecFile((file, args, n) => (n === 1 ? ok() : exited(1, '\n Could not write domain com.prodcom.prodcom; exiting\nmore')));
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
await os.writeApiPref();
|
||||
assert.equal(x.lines()[0], `/usr/bin/defaults write ${BUNDLE} applicationAPIEnabled -bool true`);
|
||||
await assert.rejects(os.writeApiPref(), /^Error: Could not turn on ProdCom's API \(exit 1: Could not write domain com\.prodcom\.prodcom; exiting\)$/);
|
||||
});
|
||||
|
||||
test('default os: opening ProdCom is done in the background (-g), and a failed open reads as not installed', async () => {
|
||||
const answers = [ok(), exited(1, 'Unable to find application'), timedOut()];
|
||||
const x = fakeExecFile(() => answers.shift());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
await os.launch();
|
||||
assert.equal(x.lines()[0], `/usr/bin/open -g -b ${BUNDLE}`);
|
||||
assert.ok(x.calls[0].args.includes('-g'));
|
||||
await assert.rejects(os.launch(), /^Error: ProdCom is not installed on this machine$/);
|
||||
await assert.rejects(os.launch(), /^Error: Could not open ProdCom \(timed out\)$/);
|
||||
});
|
||||
|
||||
test('default os: is ProdCom running — pgrep 0 / 1, anything else is an error', async () => {
|
||||
const answers = [ok('123\n'), exited(1), exited(2, 'pgrep: bad'), timedOut()];
|
||||
const x = fakeExecFile(() => answers.shift());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
assert.equal(await os.isRunning(), true);
|
||||
assert.equal(await os.isRunning(), false);
|
||||
await assert.rejects(os.isRunning(), /Could not check whether ProdCom is running \(exit 2: pgrep: bad\)/);
|
||||
await assert.rejects(os.isRunning(), /Could not check whether ProdCom is running \(timed out\)/);
|
||||
assert.equal(x.lines()[0], '/usr/bin/pgrep -x ProdCom');
|
||||
});
|
||||
|
||||
test('default os: quit, the polite way — asked by Apple Event, then waited for', async () => {
|
||||
let pgreps = 0;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return ok();
|
||||
pgreps += 1;
|
||||
return pgreps < 3 ? ok('123\n') : exited(1); // still there twice, then gone
|
||||
});
|
||||
const clock = fakeClock();
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
|
||||
await os.quit(new AbortController().signal);
|
||||
assert.deepEqual(x.lines(), [
|
||||
`/usr/bin/osascript -e tell application id "${BUNDLE}" to quit`,
|
||||
'/usr/bin/pgrep -x ProdCom', '/usr/bin/pgrep -x ProdCom', '/usr/bin/pgrep -x ProdCom',
|
||||
]);
|
||||
assert.equal(x.calls[0].opts.timeout, 5000, 'the Apple Event gets five seconds');
|
||||
assert.deepEqual(clock.slept, [500, 500]);
|
||||
});
|
||||
|
||||
test('default os: quit, when it will not be asked — SIGTERM, then waited for', async () => {
|
||||
let pgreps = 0;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return exited(1, 'execution error: Not authorized to send Apple events (-1743)');
|
||||
if (file === '/usr/bin/pkill') return ok();
|
||||
pgreps += 1;
|
||||
return pgreps < 2 ? ok('123\n') : exited(1);
|
||||
});
|
||||
const clock = fakeClock();
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
|
||||
await os.quit();
|
||||
assert.deepEqual(x.lines().map((l) => l.split(' ').slice(0, 3).join(' ')), [
|
||||
'/usr/bin/osascript -e tell',
|
||||
'/usr/bin/pkill -x -TERM',
|
||||
'/usr/bin/pgrep -x ProdCom',
|
||||
'/usr/bin/pgrep -x ProdCom',
|
||||
]);
|
||||
assert.deepEqual(x.calls[1].args, ['-x', '-TERM', 'ProdCom']);
|
||||
});
|
||||
|
||||
test('default os: quit, and it will not go — "would not quit" after ten seconds', async () => {
|
||||
const x = fakeExecFile((file) => (file === '/usr/bin/pgrep' ? ok('123\n') : ok()));
|
||||
const clock = fakeClock();
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: clock.sleep, now: clock.now });
|
||||
await assert.rejects(os.quit(), /^Error: ProdCom would not quit$/);
|
||||
assert.equal(clock.now(), 10000);
|
||||
assert.equal(x.calls.filter((c) => c.file === '/usr/bin/pkill').length, 0, 'the Apple Event worked, so no SIGTERM');
|
||||
});
|
||||
|
||||
test('default os: quit, ended while the Apple Event is out: no SIGTERM, no look at the process list', async () => {
|
||||
const ctl = new AbortController();
|
||||
let finish;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return new Promise((resolve) => { finish = () => resolve(exited(1, 'denied')); });
|
||||
return ok('123\n');
|
||||
});
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: fakeClock().sleep });
|
||||
const quitting = os.quit(ctl.signal);
|
||||
await tick();
|
||||
ctl.abort();
|
||||
finish();
|
||||
await assert.rejects(quitting, /^Error: cancelled$/);
|
||||
assert.equal(x.calls.length, 1, 'only the osascript was ever run');
|
||||
});
|
||||
|
||||
test('default os: quit, ended while SIGTERM is out: no look at the process list', async () => {
|
||||
const ctl = new AbortController();
|
||||
let finish;
|
||||
const x = fakeExecFile((file) => {
|
||||
if (file === '/usr/bin/osascript') return exited(1, 'denied');
|
||||
if (file === '/usr/bin/pkill') return new Promise((resolve) => { finish = () => resolve(ok()); });
|
||||
return ok('123\n');
|
||||
});
|
||||
const os = defaultOs({ execFile: x.execFile, sleep: fakeClock().sleep });
|
||||
const quitting = os.quit(ctl.signal);
|
||||
await tick();
|
||||
ctl.abort();
|
||||
finish();
|
||||
await assert.rejects(quitting, /^Error: cancelled$/);
|
||||
assert.deepEqual(x.calls.map((c) => c.file), ['/usr/bin/osascript', '/usr/bin/pkill']);
|
||||
});
|
||||
|
||||
test('default os: quit, ended while it waits for the process to go: no further look', async () => {
|
||||
const ctl = new AbortController();
|
||||
const x = fakeExecFile((file) => (file === '/usr/bin/pgrep' ? ok('123\n') : ok()));
|
||||
const os = defaultOs({
|
||||
execFile: x.execFile,
|
||||
sleep: () => { ctl.abort(); return Promise.resolve(false); }, // the run ends during the first wait
|
||||
});
|
||||
await assert.rejects(os.quit(ctl.signal), /^Error: cancelled$/);
|
||||
assert.equal(x.calls.filter((c) => c.file === '/usr/bin/pgrep').length, 1);
|
||||
});
|
||||
|
||||
test('default os: quit, already ended before it starts: it does nothing at all', async () => {
|
||||
const ctl = new AbortController();
|
||||
ctl.abort();
|
||||
const x = fakeExecFile(() => ok());
|
||||
const os = defaultOs({ execFile: x.execFile });
|
||||
await assert.rejects(os.quit(ctl.signal), /^Error: cancelled$/);
|
||||
assert.equal(x.calls.length, 0);
|
||||
});
|
||||
|
||||
/* ── the pieces that need no machine ────────────────────────────────── */
|
||||
|
||||
test('isLocalHostName: this machine by every name it has, and no other', () => {
|
||||
const addrs = ['127.0.0.1', '::1', 'fe80::1', '10.3.11.152', 'fe80::aede:48ff:fe00:1122'];
|
||||
const here = 'AB-ProPresenter.local';
|
||||
for (const host of ['', 'localhost', 'LocalHost', '127.0.0.1', '::1', '[::1]', '0.0.0.0',
|
||||
'10.3.11.152', 'FE80::1', 'fe80:0:0:0:0:0:0:1', 'fe80::1%lo0',
|
||||
'ab-propresenter.local', 'AB-PROPRESENTER', 'ab-propresenter', 'ab-propresenter.local.']) {
|
||||
assert.equal(isLocalHostName(host, here, addrs), true, `"${host}" is this machine`);
|
||||
}
|
||||
for (const host of ['10.1.1.50', 'prodcom.local', 'na-ingest', 'example.com', '10.3.11.153']) {
|
||||
assert.equal(isLocalHostName(host, here, addrs), false, `"${host}" is not`);
|
||||
}
|
||||
// A hostname without .local matches the same machine reached as .local.
|
||||
assert.equal(isLocalHostName('studio.local', 'Studio', []), true);
|
||||
});
|
||||
|
||||
test('parseApiPref: defaults prints a bool as 1 or 0', () => {
|
||||
assert.equal(parseApiPref('1\n'), true);
|
||||
assert.equal(parseApiPref('0\n'), false);
|
||||
assert.equal(parseApiPref(' true '), true);
|
||||
assert.equal(parseApiPref(''), null);
|
||||
assert.equal(parseApiPref('banana'), null);
|
||||
});
|
||||
|
||||
test('the default sleep is cut short by its signal and leaves no timer behind', async () => {
|
||||
const ctl = new AbortController();
|
||||
const slept = abortableSleep(60 * 60 * 1000, ctl.signal); // an hour — would hold the test open if it were left running
|
||||
ctl.abort();
|
||||
assert.equal(await slept, false);
|
||||
assert.equal(await abortableSleep(60 * 60 * 1000, ctl.signal), false, 'already aborted: returns at once');
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
# ProPresenter Look — a ProdDash module
|
||||
|
||||
Shows the audience **look** live in ProPresenter right now, as big as the
|
||||
tile allows, so anyone away from the ProPresenter machine can tell at a glance
|
||||
which look the outputs are on.
|
||||
|
||||
- The look's name fills the tile and **flashes** when it changes.
|
||||
- **Modified** appears when someone changed the look by hand and it no longer
|
||||
matches any saved look.
|
||||
- **since 10:42 AM** says when the look last changed.
|
||||
- If ProPresenter stops answering, the status dot goes red and the tile keeps
|
||||
the **last known** look on screen, dimmed, until it comes back by itself.
|
||||
|
||||
## Install
|
||||
|
||||
This folder is a standard ProdDash module (id `propresenter-looks`). Per the
|
||||
ProdDash module guide, it installs one of two ways:
|
||||
|
||||
1. **From the admin page's catalog**, once the folder is in the ProdDash repo
|
||||
at `modules/propresenter-looks/`: `/admin` → Available modules →
|
||||
*ProPresenter Look* → Install.
|
||||
2. **Drop it in**: copy this folder to the ProdDash data directory's
|
||||
`modules/propresenter-looks/` and restart ProdDash.
|
||||
- macOS: `~/Library/Application Support/ProdDash/modules/`
|
||||
- Windows: `%APPDATA%\ProdDash\modules\`
|
||||
|
||||
Then add a tile from **+ Add tile → ProPresenter Look**.
|
||||
|
||||
## Configure (in `/admin`)
|
||||
|
||||
| Setting | What to enter |
|
||||
| --- | --- |
|
||||
| ProPresenter | Host / IP and port from ProPresenter's Settings › Network (Network enabled). |
|
||||
| Password | Only if ProPresenter's network API asks for one. |
|
||||
|
||||
Each tile's gear has three switches: **Heading**, **Live status**, **Since**.
|
||||
|
||||
## How it works
|
||||
|
||||
The server entry polls ProPresenter's network API once a second
|
||||
(`GET /v1/look/current`, plus `GET /v1/looks` every ten seconds to name the
|
||||
saved look it matches) and streams the result to tiles over SSE. Browsers
|
||||
never talk to ProPresenter. No dependencies. Needs ProdDash 1.5.0 or later and
|
||||
ProPresenter 7.9+ (or 20+).
|
||||
|
||||
## Testing without ProPresenter
|
||||
|
||||
```bash
|
||||
node tools/pp-looks-mock.js 1601
|
||||
```
|
||||
|
||||
Point the module at host `127.0.0.1`, port `1601`. The live look changes every
|
||||
8 seconds (`--every 3` for faster) and every fourth change is a hand-modified
|
||||
look. `--password <pw>` demands a password; `--no-list` acts like a
|
||||
ProPresenter without `GET /v1/looks`.
|
||||
@@ -0,0 +1,192 @@
|
||||
/* ProPresenter Look — client part.
|
||||
|
||||
The server part polls ProPresenter and streams the live audience look
|
||||
here over SSE; the tile only renders. The look's name fills the tile so
|
||||
it reads from across the booth, flashes once when it changes, and stays
|
||||
on screen dimmed — marked "last known" — while ProPresenter is
|
||||
unreachable. */
|
||||
|
||||
export default function create({ root, moduleApi }) {
|
||||
let view = null;
|
||||
let feedOffline = false;
|
||||
let stream = null;
|
||||
let resizeObserver = null;
|
||||
let flashTimer = null;
|
||||
let lastName = null;
|
||||
|
||||
root.innerHTML = `
|
||||
<div class="pl-screen">
|
||||
<header class="pl-top">
|
||||
<div class="pl-heading">Look</div>
|
||||
<div class="pl-live"><span class="pl-live-dot"></span><span class="pl-live-text">Connecting…</span></div>
|
||||
</header>
|
||||
<div class="pl-name-box"><div class="pl-name is-waiting">Connecting…</div></div>
|
||||
<footer class="pl-meta">
|
||||
<span class="pl-tag" hidden>Modified</span>
|
||||
<span class="pl-since" hidden></span>
|
||||
</footer>
|
||||
</div>`;
|
||||
|
||||
const screenEl = root.querySelector('.pl-screen');
|
||||
const topEl = root.querySelector('.pl-top');
|
||||
const headingEl = root.querySelector('.pl-heading');
|
||||
const liveEl = root.querySelector('.pl-live');
|
||||
const liveTextEl = root.querySelector('.pl-live-text');
|
||||
const nameBox = root.querySelector('.pl-name-box');
|
||||
const nameEl = root.querySelector('.pl-name');
|
||||
const metaEl = root.querySelector('.pl-meta');
|
||||
const tagEl = root.querySelector('.pl-tag');
|
||||
const sinceEl = root.querySelector('.pl-since');
|
||||
|
||||
const on = (key) => moduleApi.instanceSettings[key] !== false;
|
||||
|
||||
function timeText(ms) {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** Biggest font size at which the name fits its box without breaking a word. */
|
||||
function fitName() {
|
||||
const MIN = 11;
|
||||
const boxHeight = nameBox.clientHeight;
|
||||
const boxWidth = nameBox.clientWidth;
|
||||
nameEl.style.overflowWrap = 'normal';
|
||||
if (boxHeight < MIN || !boxWidth) {
|
||||
nameEl.style.fontSize = `${MIN}px`;
|
||||
return;
|
||||
}
|
||||
const fits = (size) => {
|
||||
nameEl.style.fontSize = `${size}px`;
|
||||
return nameEl.scrollWidth <= nameBox.clientWidth + 1 && nameEl.scrollHeight <= nameBox.clientHeight + 1;
|
||||
};
|
||||
let lo = MIN;
|
||||
let hi = Math.max(MIN, Math.floor(boxHeight));
|
||||
let best = MIN;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (fits(mid)) { best = mid; lo = mid + 1; } else { hi = mid - 1; }
|
||||
}
|
||||
nameEl.style.fontSize = `${best}px`;
|
||||
// One word too long even at MIN: let it break rather than overflow.
|
||||
if (best === MIN && nameEl.scrollWidth > nameBox.clientWidth + 1) nameEl.style.overflowWrap = 'anywhere';
|
||||
}
|
||||
|
||||
function flash() {
|
||||
screenEl.classList.remove('is-changed');
|
||||
void screenEl.offsetWidth; // restart the animation
|
||||
screenEl.classList.add('is-changed');
|
||||
clearTimeout(flashTimer);
|
||||
flashTimer = setTimeout(() => screenEl.classList.remove('is-changed'), 2600);
|
||||
}
|
||||
|
||||
function setStatus() {
|
||||
if (feedOffline) {
|
||||
moduleApi.setStatus('error', 'ProdDash server unreachable — reconnecting…');
|
||||
} else if (!view) {
|
||||
moduleApi.setStatus('connecting', 'Connecting…');
|
||||
} else if (!view.configured) {
|
||||
moduleApi.setStatus('error', 'No ProPresenter host configured — set one in Admin');
|
||||
} else if (view.reachable) {
|
||||
moduleApi.setStatus('ok', `Live look: ${view.look ? view.look.name : '—'}`);
|
||||
} else if (!view.updatedAt && !view.lastError) {
|
||||
moduleApi.setStatus('connecting', 'Reaching ProPresenter…');
|
||||
} else {
|
||||
moduleApi.setStatus('error', `ProPresenter unreachable${view.lastError ? ` (${view.lastError})` : ''} — reconnecting…`);
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
setStatus();
|
||||
const offline = feedOffline || Boolean(view && view.configured && !view.reachable && (view.updatedAt || view.lastError));
|
||||
const look = view && view.look;
|
||||
|
||||
screenEl.classList.toggle('is-offline', offline);
|
||||
headingEl.hidden = !on('showHeading');
|
||||
liveEl.hidden = !on('showLive') && !offline; // losing the feed always shows
|
||||
topEl.hidden = headingEl.hidden && liveEl.hidden;
|
||||
|
||||
liveEl.classList.toggle('is-live', !offline && Boolean(view && view.reachable));
|
||||
liveEl.classList.toggle('is-down', offline);
|
||||
if (feedOffline) liveTextEl.textContent = 'ProdDash offline';
|
||||
else if (offline) liveTextEl.textContent = look ? 'Unreachable · last known' : 'Unreachable';
|
||||
else if (view && view.reachable) liveTextEl.textContent = 'Live';
|
||||
else liveTextEl.textContent = 'Connecting…';
|
||||
|
||||
let name;
|
||||
let waiting = false;
|
||||
if (look) {
|
||||
name = look.name;
|
||||
} else if (view && !view.configured) {
|
||||
name = 'Set up in Admin';
|
||||
waiting = true;
|
||||
} else if (offline) {
|
||||
name = 'ProPresenter unreachable';
|
||||
waiting = true;
|
||||
} else {
|
||||
name = 'Connecting…';
|
||||
waiting = true;
|
||||
}
|
||||
nameEl.textContent = name;
|
||||
nameEl.classList.toggle('is-waiting', waiting);
|
||||
|
||||
if (look && lastName !== null && lastName !== `${look.name}|${look.modified}`) flash();
|
||||
if (look) lastName = `${look.name}|${look.modified}`;
|
||||
|
||||
tagEl.hidden = !(look && look.modified);
|
||||
const since = look && view.changedAt && on('showSince');
|
||||
sinceEl.hidden = !since;
|
||||
if (since) sinceEl.textContent = `since ${timeText(view.changedAt)}`;
|
||||
metaEl.hidden = tagEl.hidden && sinceEl.hidden;
|
||||
|
||||
fitName();
|
||||
}
|
||||
|
||||
function connect() {
|
||||
stream = moduleApi.sse('/stream', {
|
||||
open() {
|
||||
feedOffline = false;
|
||||
render();
|
||||
},
|
||||
error() {
|
||||
feedOffline = true;
|
||||
render();
|
||||
},
|
||||
events: {
|
||||
state(e) {
|
||||
try {
|
||||
view = JSON.parse(e.data);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
feedOffline = false;
|
||||
render();
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
start() {
|
||||
render();
|
||||
moduleApi.fetch('/state')
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((body) => {
|
||||
if (body && body.state && !view) {
|
||||
view = body.state;
|
||||
render();
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
connect();
|
||||
resizeObserver = new ResizeObserver(() => fitName());
|
||||
resizeObserver.observe(nameBox);
|
||||
},
|
||||
stop() {
|
||||
if (stream) stream.close();
|
||||
stream = null;
|
||||
if (resizeObserver) resizeObserver.disconnect();
|
||||
resizeObserver = null;
|
||||
clearTimeout(flashTimer);
|
||||
root.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"id": "propresenter-looks",
|
||||
"name": "ProPresenter Look",
|
||||
"version": "1.0.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "The audience look live in ProPresenter right now",
|
||||
"permissions": [
|
||||
{
|
||||
"kind": "local-network",
|
||||
"reason": "Reaching ProPresenter on this network"
|
||||
}
|
||||
],
|
||||
"client": "client.js",
|
||||
"server": "server.js",
|
||||
"style": "style.css",
|
||||
"minSize": {
|
||||
"w": 2,
|
||||
"h": 1
|
||||
},
|
||||
"defaultSize": {
|
||||
"w": 4,
|
||||
"h": 2
|
||||
},
|
||||
"configSchema": {
|
||||
"propresenter": {
|
||||
"type": "endpoint",
|
||||
"label": "ProPresenter",
|
||||
"default": {
|
||||
"host": "",
|
||||
"port": 1025
|
||||
},
|
||||
"help": "The host and port shown in ProPresenter's Settings › Network, with Network enabled."
|
||||
},
|
||||
"password": {
|
||||
"type": "password",
|
||||
"label": "Password",
|
||||
"default": "",
|
||||
"help": "Only if ProPresenter's network API asks for one. Leave empty otherwise."
|
||||
}
|
||||
},
|
||||
"instanceSchema": {
|
||||
"showHeading": {
|
||||
"type": "switch",
|
||||
"label": "Heading",
|
||||
"default": true
|
||||
},
|
||||
"showLive": {
|
||||
"type": "switch",
|
||||
"label": "Live status",
|
||||
"default": true
|
||||
},
|
||||
"showSince": {
|
||||
"type": "switch",
|
||||
"label": "Since",
|
||||
"default": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* ProPresenter Look — server part.
|
||||
*
|
||||
* Polls ProPresenter's network API for the live audience look and fans it
|
||||
* out to every tile over SSE. Browsers never talk to ProPresenter directly.
|
||||
*
|
||||
* GET /state → { state: <view> } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
*
|
||||
* ProPresenter's routes (ProPresenter 7.9+ / 20+):
|
||||
* GET /v1/look/current → { id: { uuid, name, index }, screens: [...] }
|
||||
* GET /v1/looks → [ { id: { uuid, name, index }, screens: [...] }, … ]
|
||||
* The current look carries a UUID of its own, never one from /v1/looks. Its
|
||||
* `index` points at the saved look it matches, or equals the number of saved
|
||||
* looks when someone has changed it by hand and it matches none.
|
||||
*/
|
||||
|
||||
const POLL_MS = 1000; // the look matters the moment it changes
|
||||
const RETRY_MS = 3000; // while ProPresenter is unreachable
|
||||
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
|
||||
const REQUEST_TIMEOUT_MS = 2500;
|
||||
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
function emptyView(configured = false) {
|
||||
return {
|
||||
configured,
|
||||
reachable: false,
|
||||
lastError: '',
|
||||
look: null, // { name, modified, screens }
|
||||
changedAt: 0, // ms epoch the look last changed while we watched; 0 = unknown
|
||||
updatedAt: 0, // ms epoch of the last good answer
|
||||
};
|
||||
}
|
||||
|
||||
function errorText(err) {
|
||||
return String(err?.cause?.code || err?.code || err?.message || err || 'failed');
|
||||
}
|
||||
|
||||
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
|
||||
function lookName(entry) {
|
||||
return String(entry?.id?.name ?? entry?.name ?? '').trim();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
init({ config, log }) {
|
||||
const streams = new Set();
|
||||
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 configured = Boolean(host && port);
|
||||
|
||||
let view = emptyView(configured);
|
||||
let lastFrame = '';
|
||||
let saved = []; // names of the saved looks, in ProPresenter's order
|
||||
let savedOk = false;
|
||||
let tick = 0;
|
||||
let stopped = false;
|
||||
let pollTimer = null;
|
||||
|
||||
function url(target) {
|
||||
const built = new URL(`http://${host}:${port}${target}`);
|
||||
if (password) built.searchParams.set('password', password);
|
||||
return built;
|
||||
}
|
||||
|
||||
async function requestJson(target) {
|
||||
const response = await fetch(url(target), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
function broadcast() {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(view);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// Polls land every second whether anything changed or not; only real
|
||||
// changes go out, so idle tiles stay idle.
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSaved() {
|
||||
try {
|
||||
const list = await requestJson('/v1/looks');
|
||||
saved = Array.isArray(list) ? list.map(lookName) : [];
|
||||
savedOk = true;
|
||||
} catch (err) {
|
||||
// An older ProPresenter without the list still answers look/current;
|
||||
// the live look's own name is shown then.
|
||||
if (err.status !== 404) throw err;
|
||||
saved = [];
|
||||
savedOk = false;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveLook(live) {
|
||||
const index = Number(live?.id?.index);
|
||||
const own = lookName(live);
|
||||
const screens = Array.isArray(live?.screens) ? live.screens.length : 0;
|
||||
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
|
||||
return { name: saved[index] || own || `Look ${index + 1}`, modified: false, screens };
|
||||
}
|
||||
// Changed by hand, matching no saved look.
|
||||
const modified = savedOk && Number.isInteger(index) && index >= saved.length;
|
||||
return { name: own || (modified ? 'Custom look' : 'Unnamed look'), modified, screens };
|
||||
}
|
||||
|
||||
async function pollTick() {
|
||||
if (stopped) return;
|
||||
const next = { ...view };
|
||||
try {
|
||||
tick += 1;
|
||||
if (tick % LOOKS_EVERY === 1 || !savedOk) await refreshSaved();
|
||||
const live = await requestJson('/v1/look/current');
|
||||
let look = resolveLook(live);
|
||||
// A look saved since our last read: refresh the list now rather than
|
||||
// calling it "Custom" for up to ten seconds.
|
||||
if (look.modified && !view.look?.modified && tick % LOOKS_EVERY !== 1) {
|
||||
await refreshSaved();
|
||||
look = resolveLook(live);
|
||||
}
|
||||
const before = view.look;
|
||||
if (before && (before.name !== look.name || before.modified !== look.modified)) {
|
||||
next.changedAt = Date.now();
|
||||
log(`look → ${look.name}${look.modified ? ' (modified)' : ''}`);
|
||||
} else if (!before) {
|
||||
log(`look is ${look.name}${look.modified ? ' (modified)' : ''}`);
|
||||
}
|
||||
next.look = look;
|
||||
next.reachable = true;
|
||||
next.lastError = '';
|
||||
next.updatedAt = Date.now();
|
||||
} catch (err) {
|
||||
if (view.reachable) log(`ProPresenter unreachable: ${errorText(err)}`);
|
||||
next.reachable = false;
|
||||
next.lastError = errorText(err); // "ECONNREFUSED" beats fetch's "fetch failed"
|
||||
tick = 0; // re-read the saved looks first when it comes back
|
||||
}
|
||||
if (stopped) return;
|
||||
view = next;
|
||||
broadcast();
|
||||
pollTimer = setTimeout(pollTick, view.reachable ? POLL_MS : RETRY_MS);
|
||||
}
|
||||
|
||||
if (configured) {
|
||||
log(`following the live look on ${host}:${port}`);
|
||||
pollTick();
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
}
|
||||
|
||||
const heartbeat = setInterval(() => {
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(': ping\n\n');
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = { streams, getView: () => view };
|
||||
|
||||
return {
|
||||
stop() {
|
||||
stopped = true;
|
||||
clearTimeout(pollTimer);
|
||||
clearInterval(heartbeat);
|
||||
for (const res of streams) {
|
||||
try { res.end(); } catch { /* already gone */ }
|
||||
}
|
||||
streams.clear();
|
||||
if (current && current.streams === streams) current = null;
|
||||
},
|
||||
health() {
|
||||
if (!configured) return { status: 'error', message: 'No ProPresenter host configured' };
|
||||
if (view.reachable) {
|
||||
return { status: 'ok', message: `Live look on ${host}:${port}: ${view.look ? view.look.name : '—'}` };
|
||||
}
|
||||
return {
|
||||
status: view.updatedAt || view.lastError ? 'error' : 'connecting',
|
||||
message: view.lastError ? `ProPresenter unreachable: ${view.lastError}` : `Reaching ${host}:${port}…`,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
routes() {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ state: current ? current.getView() : emptyView() }));
|
||||
},
|
||||
|
||||
'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');
|
||||
res.write(`event: state\ndata: ${JSON.stringify(current ? current.getView() : emptyView())}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
||||
// not queue frames forever; keepalive probes make the OS notice.
|
||||
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
||||
streams.add(res);
|
||||
req.on('close', () => streams.delete(res));
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
/* ProPresenter Look — the live look's name, as big as the tile allows.
|
||||
Colors come from the shell's variables only. */
|
||||
|
||||
[data-module="propresenter-looks"] .pl-screen {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
background: linear-gradient(
|
||||
160deg,
|
||||
color-mix(in srgb, var(--accent) 12%, transparent),
|
||||
color-mix(in srgb, var(--bg) 60%, transparent)
|
||||
);
|
||||
}
|
||||
|
||||
/* ── top row: heading and live status ── */
|
||||
|
||||
[data-module="propresenter-looks"] .pl-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-top[hidden],
|
||||
[data-module="propresenter-looks"] .pl-heading[hidden],
|
||||
[data-module="propresenter-looks"] .pl-live[hidden],
|
||||
[data-module="propresenter-looks"] .pl-meta[hidden],
|
||||
[data-module="propresenter-looks"] .pl-tag[hidden],
|
||||
[data-module="propresenter-looks"] .pl-since[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
[data-module="propresenter-looks"] .pl-heading {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[data-module="propresenter-looks"] .pl-live {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-live-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-live.is-live .pl-live-dot {
|
||||
background: var(--accent);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent);
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-dot {
|
||||
background: var(--danger);
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 16%, transparent);
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-live.is-down .pl-live-text {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* ── the look's name ── */
|
||||
|
||||
[data-module="propresenter-looks"] .pl-name-box {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-name {
|
||||
max-width: 100%;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
line-height: 1.05;
|
||||
text-align: center;
|
||||
white-space: normal;
|
||||
word-break: normal;
|
||||
hyphens: none;
|
||||
color: var(--text);
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-name.is-waiting {
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* Unreachable: the last known look stays, visibly stale. */
|
||||
[data-module="propresenter-looks"] .pl-screen.is-offline {
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-screen.is-offline .pl-name:not(.is-waiting) {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
/* ── footer: modified tag and when it changed ── */
|
||||
|
||||
[data-module="propresenter-looks"] .pl-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
[data-module="propresenter-looks"] .pl-tag {
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
font-size: 10px;
|
||||
color: var(--warn);
|
||||
background: color-mix(in srgb, var(--warn) 16%, transparent);
|
||||
}
|
||||
|
||||
/* A new look: one bright pulse so a glance catches the change. */
|
||||
[data-module="propresenter-looks"] .pl-screen.is-changed {
|
||||
animation: pl-changed 2.6s ease-out;
|
||||
}
|
||||
@keyframes pl-changed {
|
||||
0%, 20% { box-shadow: inset 0 0 0 3px var(--accent); background-color: color-mix(in srgb, var(--accent) 22%, transparent); }
|
||||
100% { box-shadow: inset 0 0 0 3px transparent; background-color: transparent; }
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
#!/usr/bin/env node
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Mock ProPresenter looks API, for developing the ProPresenter Look module
|
||||
* without the real thing.
|
||||
*
|
||||
* node tools/pp-looks-mock.js [port] (default 1601)
|
||||
* --every <seconds> how often the live look changes (default 8)
|
||||
* --password <pw> demand ?password=<pw> like a locked ProPresenter
|
||||
* --no-list answer 404 for /v1/looks (an older ProPresenter)
|
||||
*
|
||||
* Point the module at host 127.0.0.1 and this port. Every few changes the
|
||||
* live look is "modified by hand", so it matches no saved look.
|
||||
*/
|
||||
|
||||
const http = require('http');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const flag = (name) => {
|
||||
const i = args.indexOf(name);
|
||||
return i >= 0 ? args[i + 1] : undefined;
|
||||
};
|
||||
const port = Number(args.find((a) => /^\d+$/.test(a))) || 1601;
|
||||
const every = Number(flag('--every')) || 8;
|
||||
const password = flag('--password') || '';
|
||||
const noList = args.includes('--no-list');
|
||||
|
||||
const screen = (on) => ({ video_input: false, media: on, slide: on, announcements: on, props: on, messages: on, presentation: null, mask: null });
|
||||
const saved = ['Normal', 'Lyrics Only', 'Sermon', 'Countdown', 'Video Only'].map((name, index) => ({
|
||||
id: { uuid: crypto.randomUUID(), name, index },
|
||||
screens: [screen(true), screen(index !== 4)],
|
||||
}));
|
||||
|
||||
let live = 0;
|
||||
let modified = false;
|
||||
let changes = 0;
|
||||
const liveUuid = crypto.randomUUID();
|
||||
|
||||
setInterval(() => {
|
||||
changes += 1;
|
||||
modified = changes % 4 === 0;
|
||||
if (!modified) live = (live + 1) % saved.length;
|
||||
console.log(`live look → ${saved[live].id.name}${modified ? ' (modified)' : ''}`);
|
||||
}, every * 1000);
|
||||
|
||||
function currentLook() {
|
||||
return {
|
||||
id: { uuid: liveUuid, name: saved[live].id.name, index: modified ? saved.length : live },
|
||||
screens: saved[live].screens,
|
||||
};
|
||||
}
|
||||
|
||||
http.createServer((req, res) => {
|
||||
const url = new URL(req.url, 'http://x');
|
||||
const send = (code, body) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json' });
|
||||
res.end(body === undefined ? '' : JSON.stringify(body));
|
||||
};
|
||||
if (password && url.searchParams.get('password') !== password) return send(401, { error: 'password' });
|
||||
if (url.pathname === '/version') return send(200, { name: 'Mock', platform: 'mac', os_version: '15', host_description: 'ProPresenter 21.0 (mock)' });
|
||||
if (url.pathname === '/v1/looks') return noList ? send(404) : send(200, saved);
|
||||
if (url.pathname === '/v1/look/current') return send(200, currentLook());
|
||||
send(404);
|
||||
}).listen(port, () => {
|
||||
console.log(`mock ProPresenter looks on http://127.0.0.1:${port} — live look changes every ${every}s`);
|
||||
console.log(`live look → ${saved[live].id.name}`);
|
||||
});
|
||||
@@ -212,7 +212,8 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
|
||||
function renderLive() {
|
||||
liveEl.classList.remove('is-live', 'is-down');
|
||||
liveEl.classList.remove('is-live', 'is-down', 'is-look', 'is-modified');
|
||||
liveEl.removeAttribute('title');
|
||||
if (feedOffline) {
|
||||
liveEl.classList.add('is-down');
|
||||
liveTextEl.textContent = 'Feed offline';
|
||||
@@ -231,6 +232,18 @@ export default function create({ root, moduleApi }) {
|
||||
}
|
||||
if (state.reachable) {
|
||||
liveEl.classList.add('is-live');
|
||||
const lookName = state.look && state.look.name;
|
||||
if (lookName) {
|
||||
// The audience look replaces "Live". textContent, never innerHTML:
|
||||
// the name is whatever someone typed into ProPresenter.
|
||||
const modified = Boolean(state.look.modified);
|
||||
liveEl.classList.add('is-look');
|
||||
liveEl.classList.toggle('is-modified', modified);
|
||||
liveEl.title = modified ? 'Audience look (modified)' : 'Audience look';
|
||||
liveTextEl.textContent = lookName;
|
||||
moduleApi.setStatus('ok', `Live · ${lookName}`);
|
||||
return;
|
||||
}
|
||||
liveTextEl.textContent = 'Live';
|
||||
moduleApi.setStatus('ok', 'Following ProPresenter');
|
||||
return;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "propresenter-now-next",
|
||||
"name": "ProPresenter",
|
||||
"version": "1.1.1",
|
||||
"version": "1.2.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "What is live in ProPresenter and what comes next",
|
||||
"permissions": [
|
||||
@@ -50,7 +50,7 @@
|
||||
},
|
||||
"showLive": {
|
||||
"type": "switch",
|
||||
"label": "Live status",
|
||||
"label": "Audience look",
|
||||
"default": true,
|
||||
"group": "Show"
|
||||
},
|
||||
|
||||
@@ -10,6 +10,12 @@
|
||||
*
|
||||
* GET /state → { state: <view-model> } (instant paint for new tiles)
|
||||
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
||||
*
|
||||
* The view-model carries `look`: the live audience look as
|
||||
* { name, modified }, or null while it is unknown. A small poller of this
|
||||
* file's own reads it (GET /v1/look/current, with /v1/looks to name it) from
|
||||
* the same ProPresenter the core follows. Its failures only null `look`; they
|
||||
* never touch the core's reachable / lastError.
|
||||
*/
|
||||
|
||||
const { createProPresenterClient, clearedViewModel } = require('./propresenter-core');
|
||||
@@ -17,6 +23,17 @@ const { createProPresenterClient, clearedViewModel } = require('./propresenter-c
|
||||
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
||||
const HEARTBEAT_MS = 15000;
|
||||
|
||||
const LOOK_POLL_MS = 1000; // the look matters the moment it changes
|
||||
const LOOK_RETRY_MS = 3000; // while the look cannot be read
|
||||
const LOOKS_EVERY = 10; // re-read the saved looks every N polls
|
||||
const LOOK_TIMEOUT_MS = 2500;
|
||||
const LOOK_FAILS_BEFORE_NULL = 3; // consecutive failed reads before the look is called unknown
|
||||
|
||||
/** One look's name, trimmed — ProPresenter leaves the current look's name blank on some versions. */
|
||||
function lookName(entry) {
|
||||
return String(entry?.id?.name ?? entry?.name ?? '').trim();
|
||||
}
|
||||
|
||||
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
||||
let current = null;
|
||||
|
||||
@@ -31,30 +48,38 @@ module.exports = {
|
||||
const port = Number(config.port ?? ep.port) || 1025;
|
||||
const enabled = Boolean(host && port);
|
||||
let latest = clearedViewModel();
|
||||
let look = null; // { name, modified } or null while unknown
|
||||
let lastFrame = '';
|
||||
|
||||
/** The core's view-model plus the live look — what every tile and GET /state sees. */
|
||||
const snapshot = () => ({ ...latest, look });
|
||||
|
||||
function broadcast() {
|
||||
if (!streams.size) return;
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(snapshot());
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// The poll loop emits every tick whether anything changed or not.
|
||||
// Broadcasting only real changes keeps idle tiles genuinely idle.
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const client = createProPresenterClient({
|
||||
onData(view) {
|
||||
latest = view;
|
||||
if (!streams.size) return;
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.stringify(view);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
// The poll loop emits every tick whether anything changed or not.
|
||||
// Broadcasting only real changes keeps idle tiles genuinely idle.
|
||||
if (frame === lastFrame) return;
|
||||
lastFrame = frame;
|
||||
const payload = `event: state\ndata: ${frame}\n\n`;
|
||||
for (const res of streams) {
|
||||
try {
|
||||
res.write(payload);
|
||||
} catch {
|
||||
streams.delete(res);
|
||||
}
|
||||
}
|
||||
broadcast();
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,8 +90,122 @@ module.exports = {
|
||||
password: String(config.password || ''),
|
||||
});
|
||||
|
||||
/* ── audience look ──────────────────────────────────────────────
|
||||
Same reads and rules as the ProPresenter Look module. ProPresenter
|
||||
sometimes leaves the live look's name blank, so the saved list is
|
||||
consulted: the look's `index` points at the saved look it matches, or
|
||||
is past the end when someone has changed it by hand. */
|
||||
|
||||
const password = String(config.password || '');
|
||||
let saved = []; // names of the saved looks, in ProPresenter's order
|
||||
let savedOk = false; // the list exists and is in `saved`
|
||||
let savedTried = false; // /v1/looks has answered (a 404 counts: do not ask again every poll)
|
||||
let lookTick = 0;
|
||||
let lookFails = 0; // consecutive failed reads of the look
|
||||
let lookStopped = false;
|
||||
let lookTimer = null;
|
||||
|
||||
function lookUrl(target) {
|
||||
const built = new URL(`http://${host}:${port}${target}`);
|
||||
if (password) built.searchParams.set('password', password);
|
||||
return built;
|
||||
}
|
||||
|
||||
async function lookJson(target) {
|
||||
const response = await fetch(lookUrl(target), {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: AbortSignal.timeout(LOOK_TIMEOUT_MS),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = new Error(`ProPresenter ${response.status} ${response.statusText}`);
|
||||
err.status = response.status;
|
||||
throw err;
|
||||
}
|
||||
const text = await response.text();
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function refreshSaved() {
|
||||
try {
|
||||
const list = await lookJson('/v1/looks');
|
||||
// A real empty array is a valid (empty) list. Anything else that
|
||||
// answered 200 is not a list, so it names nothing — an empty list
|
||||
// would call every look modified.
|
||||
saved = Array.isArray(list) ? list.map(lookName) : [];
|
||||
savedOk = Array.isArray(list);
|
||||
savedTried = true;
|
||||
} catch (err) {
|
||||
// An older ProPresenter without the list still answers look/current;
|
||||
// the live look's own name is shown then. Remembered, so the list is
|
||||
// asked for again only on the normal cadence, not on every poll.
|
||||
if (err.status !== 404) throw err;
|
||||
saved = [];
|
||||
savedOk = false;
|
||||
savedTried = true;
|
||||
}
|
||||
}
|
||||
|
||||
/** { name, modified }, or null when there is no name to show (the pill then says "Live"). */
|
||||
function resolveLook(live) {
|
||||
const index = Number(live?.id?.index);
|
||||
const own = lookName(live);
|
||||
if (savedOk && Number.isInteger(index) && index >= 0 && index < saved.length) {
|
||||
return { name: saved[index] || own || `Look ${index + 1}`, modified: false };
|
||||
}
|
||||
// Changed by hand, matching no saved look.
|
||||
if (savedOk && Number.isInteger(index) && index >= saved.length) {
|
||||
return { name: own || 'Custom look', modified: true };
|
||||
}
|
||||
// No saved list to name it from: the live look's own name, or nothing.
|
||||
return own ? { name: own, modified: false } : null;
|
||||
}
|
||||
|
||||
function setLook(next) {
|
||||
if (JSON.stringify(next) === JSON.stringify(look)) return;
|
||||
look = next;
|
||||
broadcast();
|
||||
}
|
||||
|
||||
async function pollLook() {
|
||||
if (lookStopped) return;
|
||||
let next = null;
|
||||
let readOk = false;
|
||||
try {
|
||||
lookTick += 1;
|
||||
if (lookTick % LOOKS_EVERY === 1 || !savedTried) await refreshSaved();
|
||||
const live = await lookJson('/v1/look/current');
|
||||
if (!live || typeof live !== 'object') throw new Error('no look in the answer');
|
||||
next = resolveLook(live);
|
||||
// A look saved since our last read: refresh the list now rather than
|
||||
// calling it "Custom" for up to ten seconds.
|
||||
if (next?.modified && !look?.modified && lookTick % LOOKS_EVERY !== 1) {
|
||||
await refreshSaved();
|
||||
next = resolveLook(live);
|
||||
}
|
||||
readOk = true;
|
||||
} catch {
|
||||
// A list that exists may have changed while ProPresenter was away:
|
||||
// re-read it first when it comes back. One that is known not to exist
|
||||
// (404) is not asked for again on every failed retry.
|
||||
if (savedOk) lookTick = 0;
|
||||
}
|
||||
if (lookStopped) return;
|
||||
if (readOk) {
|
||||
lookFails = 0;
|
||||
setLook(next);
|
||||
} else {
|
||||
// One slow or dropped read must not blank the pill: the last good look
|
||||
// stands until the reads have failed several times running. A real
|
||||
// outage shows as "Reconnecting…" through the core, not as this name.
|
||||
lookFails += 1;
|
||||
if (lookFails >= LOOK_FAILS_BEFORE_NULL) setLook(null);
|
||||
}
|
||||
lookTimer = setTimeout(pollLook, readOk ? LOOK_POLL_MS : LOOK_RETRY_MS);
|
||||
}
|
||||
|
||||
if (enabled) {
|
||||
client.start();
|
||||
pollLook();
|
||||
log(`following ProPresenter at ${host}:${port}`);
|
||||
} else {
|
||||
log('no ProPresenter host configured — set one in /admin');
|
||||
@@ -83,10 +222,12 @@ module.exports = {
|
||||
}, HEARTBEAT_MS);
|
||||
heartbeat.unref?.();
|
||||
|
||||
current = { streams, getLatest: () => latest };
|
||||
current = { streams, getLatest: snapshot };
|
||||
|
||||
return {
|
||||
stop() {
|
||||
lookStopped = true;
|
||||
clearTimeout(lookTimer);
|
||||
clearInterval(heartbeat);
|
||||
client.stop();
|
||||
for (const res of streams) {
|
||||
@@ -135,7 +276,7 @@ module.exports = {
|
||||
instanceSchema: {
|
||||
cleanTitles: { type: 'switch', label: 'Tidy titles', default: true },
|
||||
showPlaylist: { type: 'switch', label: 'Playlist', default: true, group: 'Show' },
|
||||
showLive: { type: 'switch', label: 'Live status', default: true, group: 'Show' },
|
||||
showLive: { type: 'switch', label: 'Audience look', default: true, group: 'Show' },
|
||||
showNowLabel: { type: 'switch', label: 'Now label', default: true, group: 'Show' },
|
||||
showSlideCount: { type: 'switch', label: 'Slide count', default: true, group: 'Show' },
|
||||
showProgress: { type: 'switch', label: 'Progress bar', default: true, group: 'Show' },
|
||||
@@ -149,7 +290,7 @@ module.exports = {
|
||||
return {
|
||||
'GET /state': (req, res) => {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
res.end(JSON.stringify({ state: current ? current.getLatest() : clearedViewModel() }));
|
||||
res.end(JSON.stringify({ state: current ? current.getLatest() : { ...clearedViewModel(), look: null } }));
|
||||
},
|
||||
|
||||
'GET /stream': (req, res) => {
|
||||
@@ -160,7 +301,7 @@ module.exports = {
|
||||
'X-Accel-Buffering': 'no',
|
||||
});
|
||||
res.write('retry: 3000\n\n');
|
||||
const view = current ? current.getLatest() : clearedViewModel();
|
||||
const view = current ? current.getLatest() : { ...clearedViewModel(), look: null };
|
||||
res.write(`event: state\ndata: ${JSON.stringify(view)}\n\n`);
|
||||
if (!current) return void res.end();
|
||||
const { streams } = current;
|
||||
|
||||
@@ -141,6 +141,24 @@
|
||||
transition: opacity 0.6s ease 1.4s;
|
||||
}
|
||||
|
||||
/* The audience look is information, not a status chip: it stays, in the
|
||||
name as typed, in the normal text colour. A look changed by hand (matching
|
||||
no saved look) is set in italic. */
|
||||
[data-module="propresenter-now-next"] .nn-live.is-live.is-look .nn-live-text {
|
||||
opacity: 1;
|
||||
transition: none;
|
||||
max-width: 14em;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: none;
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
[data-module="propresenter-now-next"] .nn-live.is-look.is-modified .nn-live-text {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ── NOW card ──
|
||||
FIXED height so the title box is deterministic and the NOW/NEXT layout
|
||||
cannot oscillate (see the wall display's notes). */
|
||||
|
||||
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
|
||||
el.sources.hidden = !on('showSources') || !s.sources.length;
|
||||
if (!el.sources.hidden) {
|
||||
el.sources.innerHTML = s.sources.map((x) => `
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}" title="${esc(x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…'))}">
|
||||
<span class="vc-src${x.connected ? ' is-on' : ''}${x.offNetwork ? ' is-off-network' : ''}" title="${esc((x.offNetwork ? `${x.offNetwork === 'by name' ? 'Set by name' : 'Not on the wired network'} (${s.wiredNetwork}) · ` : '') + (x.connected ? `${x.name || x.label} · ${x.address}` : (x.error || 'Joining…')))}">
|
||||
<span class="vc-dot"></span>${esc(x.label)}
|
||||
</span>`).join('');
|
||||
}
|
||||
@@ -186,6 +186,8 @@ export default function create({ root, moduleApi }) {
|
||||
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
||||
const connected = s.sources.filter((x) => x.connected);
|
||||
const waiting = s.sources.filter((x) => !x.connected);
|
||||
const off = s.sources.filter((x) => x.offNetwork);
|
||||
if (off.length) return moduleApi.setStatus('error', `${off.map((x) => x.label).join(', ')} not on the wired network (${s.wiredNetwork})`);
|
||||
if (!connected.length) {
|
||||
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
||||
return moduleApi.setStatus('error', why);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "vista-cues",
|
||||
"name": "Vista Cues",
|
||||
"version": "1.0.0",
|
||||
"version": "1.1.0",
|
||||
"proddash": ">=1.5.0",
|
||||
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
||||
"permissions": [
|
||||
@@ -131,6 +131,13 @@
|
||||
"default": true,
|
||||
"group": "This machine",
|
||||
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
|
||||
},
|
||||
"wiredNetwork": {
|
||||
"type": "string",
|
||||
"label": "Wired network",
|
||||
"default": "",
|
||||
"group": "This machine",
|
||||
"help": "The network MIDI must come over, like 10.1.0.0/16 (several: comma between). A sender set by name or to an address outside it shows red here and on the tile; it still joins. Empty: any network."
|
||||
}
|
||||
},
|
||||
"configGroups": {
|
||||
|
||||
@@ -49,6 +49,44 @@ function emptyState(extra = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The wired network(s) MIDI must ride ("10.1.0.0/16, 10.2.0.0/16"). The Macs
|
||||
* here are also on Wi-Fi and the Dante networks, and Bonjour lists each
|
||||
* session once per network, so a sender typed or picked from the wrong copy
|
||||
* works — until Wi-Fi hiccups mid-service. Returns [{ base, bits, text }];
|
||||
* throws on a typo so the admin page says so at once.
|
||||
*/
|
||||
function parseNets(text) {
|
||||
return String(text || '').split(/[\s,]+/).filter(Boolean).map((n) => {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})(?:\/(\d{1,2}))?$/.exec(n);
|
||||
const parts = m ? m.slice(1, 5).map(Number) : [];
|
||||
const bits = m && m[5] !== undefined ? Number(m[5]) : 32;
|
||||
if (!m || parts.some((x) => x > 255) || bits > 32) {
|
||||
throw new Error(`Wired network: "${n}" is not a network like 10.1.0.0/16`);
|
||||
}
|
||||
return { base: ipNumber(parts.join('.')), bits, text: n };
|
||||
});
|
||||
}
|
||||
|
||||
function ipNumber(a) {
|
||||
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(String(a || '').trim());
|
||||
return m ? m.slice(1, 5).reduce((n, x) => n * 256 + Number(x), 0) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Why an address is not on the wired network: '' when it is (or there is no
|
||||
* rule), 'by name' for a host name (it resolves to whichever network answers
|
||||
* first), 'not wired' for an address outside every wired network.
|
||||
*/
|
||||
function offNetwork(nets, address) {
|
||||
if (!nets.length || !address) return '';
|
||||
const v = ipNumber(address);
|
||||
if (v === null) return 'by name';
|
||||
if (address.startsWith('127.')) return '';
|
||||
const span = (bits) => 2 ** (32 - bits);
|
||||
return nets.some((n) => Math.floor(v / span(n.bits)) === Math.floor(n.base / span(n.bits))) ? '' : 'not wired';
|
||||
}
|
||||
|
||||
function endpointOf(v) {
|
||||
const ep = v && typeof v === 'object' ? v : {};
|
||||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||||
@@ -62,6 +100,8 @@ module.exports = {
|
||||
const exclusive = config.exclusive === true;
|
||||
const headTakesOver = config.headTakesOver !== false;
|
||||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
||||
const nets = parseNets(config.wiredNetwork);
|
||||
const netText = nets.map((n) => n.text).join(', ');
|
||||
if (mappingErrors.length) {
|
||||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
||||
}
|
||||
@@ -187,6 +227,9 @@ module.exports = {
|
||||
label: s.label,
|
||||
name: p ? p.name : '',
|
||||
address: inv?.address || s.host,
|
||||
// A name is judged as typed: it may resolve to Wi-Fi next time even if it is wired now.
|
||||
offNetwork: offNetwork(nets, s.host) || offNetwork(nets, inv?.address || ''),
|
||||
host: s.host,
|
||||
configured: true,
|
||||
connected: Boolean(p),
|
||||
error: p ? '' : (inv?.error || ''),
|
||||
@@ -195,7 +238,7 @@ module.exports = {
|
||||
}
|
||||
for (const p of session.participants.values()) {
|
||||
if (seen.has(p.key) || !p.connected) continue;
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
out.push({ label: p.label || p.name, name: p.name, address: p.address, offNetwork: offNetwork(nets, p.address), configured: false, connected: true, error: '', lastHeard: p.lastHeard });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -219,6 +262,7 @@ module.exports = {
|
||||
sessionName,
|
||||
port,
|
||||
advertised,
|
||||
wiredNetwork: netText,
|
||||
sources: sources(),
|
||||
current: cur || null,
|
||||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||||
@@ -304,6 +348,16 @@ module.exports = {
|
||||
health() {
|
||||
if (session.error) return { status: 'error', message: session.error };
|
||||
const src = sources();
|
||||
// Off the wired network: still joined (the cues keep working), but said first and in red.
|
||||
const off = src.filter((s) => s.offNetwork);
|
||||
if (off.length) {
|
||||
return {
|
||||
status: 'error',
|
||||
message: `${off.map((s) => (s.offNetwork === 'by name'
|
||||
? `${s.label} is set by name (${s.host})`
|
||||
: `${s.label} ${s.configured ? `(${s.address})` : `at ${s.address}`} is not on the wired network`)).join(' · ')}: use its address on ${netText}`,
|
||||
};
|
||||
}
|
||||
const on = src.filter((s) => s.connected);
|
||||
const lines = mappingErrors.length ? ` · ${mappingErrors.length} cue-list line${mappingErrors.length === 1 ? '' : 's'} not understood (first: line ${mappingErrors[0].line})` : '';
|
||||
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||||
@@ -323,6 +377,25 @@ module.exports = {
|
||||
};
|
||||
},
|
||||
|
||||
// For the tests.
|
||||
_offNetwork: (text, address) => offNetwork(parseNets(text), address),
|
||||
|
||||
/**
|
||||
* One picker entry, so the + menu gives Vista Cues a heading of its own —
|
||||
* a module with no list sits headless under whichever module precedes it
|
||||
* (Slack, alphabetically). Tiles added before this keep working: they
|
||||
* have no variant and use the module's schema.
|
||||
*/
|
||||
tiles() {
|
||||
return [{
|
||||
id: 'live',
|
||||
name: 'Live cue lists',
|
||||
description: 'The main scene Vista is running, the FX on top, and what fired them',
|
||||
defaultSize: { w: 5, h: 4 },
|
||||
minSize: { w: 2, h: 1 },
|
||||
}];
|
||||
},
|
||||
|
||||
routes() {
|
||||
const sendJson = (res, code, body) => {
|
||||
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
||||
|
||||
@@ -180,6 +180,8 @@
|
||||
}
|
||||
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
|
||||
[data-module="vista-cues"] .vc-src.is-on .vc-dot { background: var(--accent); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network { color: var(--danger); }
|
||||
[data-module="vista-cues"] .vc-src.is-off-network .vc-dot { background: var(--danger); }
|
||||
@container (max-height: 140px) {
|
||||
[data-module="vista-cues"] .vc-sources { display: none; }
|
||||
}
|
||||
|
||||
@@ -363,6 +363,53 @@ async function freePair() {
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
await test('wired network: addresses outside it, and names, are flagged', () => {
|
||||
const mod = require('./server');
|
||||
const off = mod._offNetwork;
|
||||
assert.strictEqual(off('', '10.6.9.193'), ''); // no rule: any network
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.1.3.227'), '');
|
||||
assert.strictEqual(off('10.1.0.0/16', '10.6.9.193'), 'not wired'); // Wi-Fi
|
||||
assert.strictEqual(off('10.1.0.0/16', '169.254.12.3'), 'not wired'); // Dante
|
||||
assert.strictEqual(off('10.1.0.0/16', 'NA-Tracks.local'), 'by name');
|
||||
assert.strictEqual(off('10.1.0.0/16', '127.0.0.1'), ''); // Companion on this machine
|
||||
assert.strictEqual(off('10.1.0.0/16, 10.6.0.0/20', '10.6.9.193'), ''); // several
|
||||
assert.strictEqual(off('10.1.3.227', '10.1.3.227'), ''); // a single address
|
||||
assert.throws(() => off('wifi', '10.1.3.227'), /not a network like 10\.1\.0\.0\/16/);
|
||||
assert.throws(() => off('10.1.0.0/40', '10.1.3.227'), /not a network/);
|
||||
});
|
||||
|
||||
await test('server entry: a sender off the wired network still joins, and says so', async () => {
|
||||
const senderPort = await freePair();
|
||||
const sender = await fakeSender(senderPort, 'Fake Tracks');
|
||||
const port = await freePair();
|
||||
const mod = require('./server');
|
||||
assert.throws(() => mod.init({ config: { sessionPort: port, advertise: false, wiredNetwork: '10.1/16' }, log: () => {} }), /Wired network/);
|
||||
const handle = mod.init({
|
||||
config: {
|
||||
sessionPort: port,
|
||||
advertise: false,
|
||||
wiredNetwork: '10.1.0.0/16',
|
||||
source2Name: 'Tracks',
|
||||
source2: { host: 'localhost', port: senderPort }, // a name: it may resolve to Wi-Fi next time
|
||||
cueLists: '60 = Walk-in',
|
||||
},
|
||||
log: () => {},
|
||||
});
|
||||
await until(() => sender.peers.length > 0); // joined all the same
|
||||
const h = handle.health();
|
||||
assert.strictEqual(h.status, 'error');
|
||||
assert.match(h.message, /^Tracks is set by name \(localhost\): use its address on 10\.1\.0\.0\/16$/);
|
||||
const routes = mod.routes({});
|
||||
let body = '';
|
||||
await new Promise((r) => routes['GET /state']({}, { writeHead() {}, end(b) { body = b; r(); } }));
|
||||
const st = JSON.parse(body).state;
|
||||
assert.strictEqual(st.wiredNetwork, '10.1.0.0/16');
|
||||
assert.strictEqual(st.sources[0].offNetwork, 'by name');
|
||||
handle.stop();
|
||||
sender.close();
|
||||
await wait(80);
|
||||
});
|
||||
|
||||
console.log(`\n${passed} passed`);
|
||||
process.exit(0);
|
||||
})().catch((e) => {
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
/**
|
||||
* 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