Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6fffefc6c0 | ||
|
|
227e3bb846 | ||
|
|
c043834d1e | ||
|
|
1fd7dcdf1e | ||
|
|
e59629694e | ||
|
|
6da6f4c31e | ||
|
|
8e4a0e849e | ||
|
|
df2f0494f8 | ||
|
|
0548cb54fa | ||
|
|
c1152b4ab3 | ||
|
|
a692b6a9e2 | ||
|
|
e670e1ee72 | ||
|
|
4b0f161c93 | ||
|
|
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/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/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/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)
|
## Reference code (read-only forks — don't develop these here)
|
||||||
|
|
||||||
|
|||||||
@@ -515,17 +515,57 @@ below. Nothing is installed without someone clicking.
|
|||||||
read from the account), and a folded **Timing** group for the poll
|
read from the account), and a folded **Timing** group for the poll
|
||||||
intervals and a position trim.
|
intervals and a position trim.
|
||||||
Tile settings: cue name, previous cue, playhead, cue list.
|
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
|
- **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
|
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.
|
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
|
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
|
"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
|
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
|
title bar opens that library — the spots pinned on top, the photos (with a
|
||||||
to swap, rename or clear. Planning Center is optional auto-fill on top, not
|
name search) scrolling below — drag a photo onto a slot (mouse, or press and
|
||||||
the source of truth: **Sync** reads the upcoming plan's team members and
|
hold on a touchscreen), drag between slots to swap, rename or clear.
|
||||||
drops each into the slot matching their position, filling only slots nobody
|
Dropping a photo onto the person already in a spot makes it *their* photo
|
||||||
placed by hand, so a guest pinned on Thursday survives Sunday's sync.
|
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
|
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
|
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
|
is one. Admin config: Planning Center credentials, service type and plan, the
|
||||||
@@ -598,8 +638,15 @@ board — a plan, its team members, birthdays and photos — run ProdDash with
|
|||||||
`BAND_LINEUP_API_BASE=http://127.0.0.1:24710`) and
|
`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
|
`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
|
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
|
sender at `127.0.0.1:5104` and paste the sheet it prints) and
|
||||||
the [module guide](docs/MODULE-GUIDE.md).
|
`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
|
## Install as a desktop web app
|
||||||
|
|
||||||
|
|||||||
+144
-56
@@ -1,5 +1,5 @@
|
|||||||
// Band Lineup — client. Renders the shared stage board from /state + /stream.
|
// 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
|
// 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
|
// come back over SSE, so every Band Lineup screen stays in sync. Birthday week
|
||||||
// (Wed→Tue) rains confetti.
|
// (Wed→Tue) rains confetti.
|
||||||
@@ -14,26 +14,108 @@ export default function create({ root, moduleApi }) {
|
|||||||
let selectedLibId = ''; // click-to-place fallback for touch
|
let selectedLibId = ''; // click-to-place fallback for touch
|
||||||
const canvases = new Set();
|
const canvases = new Set();
|
||||||
let raf = null;
|
let raf = null;
|
||||||
// Auto-scroll the editor while dragging a photo (native DnD won't do it reliably).
|
/* ---------- drag (pointer events: one path for mouse, pen and touch) ----------
|
||||||
let dragScrolling = false;
|
* Native HTML drag-and-drop never fires for a finger, drops onto a card's child elements
|
||||||
let dragY = 0;
|
* flicker, and a board update arriving mid-drag rebuilt the editor out from under it. This
|
||||||
let dragBody = null;
|
* drives the drag by hand instead: a mouse drags as soon as it moves; a finger holds still for
|
||||||
let dragRaf = null;
|
* a moment first (so a quick swipe still scrolls the editor). A floating copy of the photo
|
||||||
function startDragAutoScroll() {
|
* follows the pointer, the spot under it lights up, and the editor auto-scrolls near its
|
||||||
if (dragRaf) return;
|
* edges. Board updates that arrive during a drag wait until it ends. */
|
||||||
const tick = () => {
|
const DRAG_MOVE_PX = 6;
|
||||||
if (!dragScrolling || !dragBody || !dragBody.isConnected) { dragRaf = null; return; }
|
const TOUCH_HOLD_MS = 220;
|
||||||
const r = dragBody.getBoundingClientRect();
|
let drag = null; // { kind: 'lib'|'slot', id, body, ghost, over, x, y, raf }
|
||||||
const zone = 90; // generous edge band so you don't have to be pixel-accurate
|
let pending = null; // a pointerdown that may become a drag
|
||||||
let dy = 0;
|
let renderHeld = false; // a state update arrived mid-drag; render when it ends
|
||||||
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;
|
function slotUnder(x, y) {
|
||||||
if (dy) dragBody.scrollTop += dy;
|
const hit = document.elementFromPoint(x, y);
|
||||||
dragRaf = requestAnimationFrame(tick);
|
const cell = hit && hit.closest ? hit.closest('.bl-edit-slot') : null;
|
||||||
};
|
return cell && root.contains(cell) ? cell : null;
|
||||||
dragRaf = requestAnimationFrame(tick);
|
|
||||||
}
|
}
|
||||||
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) ---------- */
|
/* ---------- confetti (always animates) ---------- */
|
||||||
function makeParticle(w, h, intensity, seedY) {
|
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 stopConfetti() { canvases.clear(); if (raf) { cancelAnimationFrame(raf); raf = null; } }
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
|
if (drag) { renderHeld = true; return; }
|
||||||
stopConfetti();
|
stopConfetti();
|
||||||
stopParallax();
|
stopParallax();
|
||||||
root.innerHTML = '';
|
|
||||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
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');
|
const board = el('div', 'bl-board');
|
||||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
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 ---------- */
|
/* ---------- editor ---------- */
|
||||||
|
let libFilter = ''; // the photo search box, kept across re-renders
|
||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
|
||||||
// board on top and the picture picker (library) below it.
|
// 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 wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||||
const body = el('div', 'bl-editor-body');
|
const body = el('div', 'bl-editor-body bl-editor-split');
|
||||||
// Feed the drag auto-scroller the pointer position anywhere over the editor.
|
|
||||||
body.addEventListener('dragover', (e) => { dragY = e.clientY; });
|
|
||||||
|
|
||||||
// Slots (drop targets)
|
// Slots (drop targets)
|
||||||
const boardEl = el('div', 'bl-edit-board');
|
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', '+'));
|
photo.appendChild(slot.person ? photoNode(slot.person) : el('div', 'bl-edit-drop', '+'));
|
||||||
cell.appendChild(photo);
|
cell.appendChild(photo);
|
||||||
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
// An occupied card is a drag SOURCE too — move/swap it to another slot.
|
||||||
if (slot.person) {
|
if (slot.person) makeDraggable(cell, 'slot', String(slot.index), body, slot.person.name);
|
||||||
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);
|
|
||||||
}
|
|
||||||
const nameRow = el('div', 'bl-edit-namerow');
|
const nameRow = el('div', 'bl-edit-namerow');
|
||||||
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
const nameBtn = el('button', 'bl-edit-name', (slot.person && slot.person.name) || 'Name…');
|
||||||
nameBtn.addEventListener('click', () => renameSlot(slot.index, slot.person ? slot.person.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); }
|
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(nameRow);
|
||||||
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
cell.appendChild(el('div', 'bl-edit-label', slot.label || `Slot ${slot.index + 1}`));
|
||||||
// drop
|
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); });
|
||||||
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); });
|
|
||||||
boardEl.appendChild(cell);
|
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 lib = el('div', 'bl-library');
|
||||||
const list = state?.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.'));
|
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) {
|
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');
|
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 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…');
|
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';
|
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' }); });
|
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.appendChild(del);
|
||||||
item.addEventListener('dragstart', (e) => {
|
makeDraggable(item, 'lib', person.id, body, person.name);
|
||||||
e.dataTransfer.setData('text/plain', 'lib:' + person.id); e.dataTransfer.effectAllowed = 'copy';
|
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||||
dragBody = body; dragScrolling = true; startDragAutoScroll();
|
|
||||||
});
|
|
||||||
item.addEventListener('dragend', stopDragAutoScroll);
|
|
||||||
item.addEventListener('click', () => { selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
|
||||||
lib.appendChild(item);
|
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);
|
wrap.appendChild(body);
|
||||||
|
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
root.appendChild(wrap);
|
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) {
|
function assign(slotIndex, personId) {
|
||||||
@@ -260,7 +344,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
title: 'Sync',
|
title: 'Sync',
|
||||||
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
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).
|
// 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 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>';
|
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();
|
stopConfetti();
|
||||||
stopParallax();
|
stopParallax();
|
||||||
stopDragAutoScroll();
|
stopDragAutoScroll();
|
||||||
|
document.removeEventListener('pointermove', onPointerMove);
|
||||||
|
document.removeEventListener('pointerup', onPointerUp);
|
||||||
|
document.removeEventListener('pointercancel', onPointerUp);
|
||||||
|
document.removeEventListener('touchmove', onTouchMove);
|
||||||
root.innerHTML = '';
|
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.
|
* member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot.
|
||||||
*/
|
*/
|
||||||
const MIN_SCORE = 60;
|
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) {
|
function assignMembersToSlots(slots, members) {
|
||||||
const pool = members.slice();
|
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);
|
const role = slotRoleText(slotLabel);
|
||||||
let bestIndex = -1;
|
let bestIndex = -1;
|
||||||
let bestScore = MIN_SCORE - 1;
|
let bestScore = MIN_SCORE - 1;
|
||||||
@@ -128,18 +180,66 @@ function assignMembersToSlots(slots, members) {
|
|||||||
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
if (score > bestScore) { bestScore = score; bestIndex = i; }
|
||||||
}
|
}
|
||||||
if (bestIndex < 0) return null;
|
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
|
* 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,
|
* 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
|
* preferring the most specific. Returns the entry, or null when no library photo is a confident
|
||||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
|
||||||
* `entries` is [{ ...whatever, name }].
|
* 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);
|
const target = normalizeLabel(pcoName);
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
const T = target.split(' ').filter(Boolean);
|
const T = target.split(' ').filter(Boolean);
|
||||||
@@ -171,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
|
|||||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
const wanted = want && want.size ? want : null;
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
let tie = false;
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const s = scoreOf(e.name);
|
const parts = photoParts(e.name);
|
||||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
let s = scoreOf(parts.roles.size ? parts.person : e.name);
|
||||||
else if (s === bestScore && s > 0) tie = true;
|
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;
|
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 = {
|
module.exports = {
|
||||||
|
samePerson,
|
||||||
|
roleFamilies,
|
||||||
|
spotFamilies,
|
||||||
|
photoParts,
|
||||||
|
photoKey,
|
||||||
normalizeTeamName,
|
normalizeTeamName,
|
||||||
isAllowedTeamName,
|
isAllowedTeamName,
|
||||||
bestLibraryMatch,
|
bestLibraryMatch,
|
||||||
@@ -194,5 +314,7 @@ module.exports = {
|
|||||||
numbersIn,
|
numbersIn,
|
||||||
scoreMatch,
|
scoreMatch,
|
||||||
assignMembersToSlots,
|
assignMembersToSlots,
|
||||||
|
noteScore,
|
||||||
|
mixNumbers,
|
||||||
MIN_SCORE,
|
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');
|
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||||
// exact full name beats a first-name photo
|
// 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');
|
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)
|
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
|
||||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
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 ✓');
|
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",
|
"id": "band-lineup",
|
||||||
"name": "Band Lineup",
|
"name": "Band Lineup",
|
||||||
"version": "1.11.0",
|
"version": "1.13.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const path = require('path');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const crypto = require('crypto');
|
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 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(/\/+$/, '');
|
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 settings = readJsonFile(settingsPath, {}) || {};
|
||||||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
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
|
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}`); } }
|
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||||
|
|
||||||
const CHOIR_LABEL = 'CHOIR';
|
const CHOIR_LABEL = 'CHOIR';
|
||||||
@@ -247,7 +268,7 @@ module.exports = {
|
|||||||
return {
|
return {
|
||||||
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
|
||||||
reachable: false, lastError: '', lastFetched: 0, columns,
|
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 })),
|
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
|
||||||
library: [], choir: choirOn,
|
library: [], choir: choirOn,
|
||||||
};
|
};
|
||||||
@@ -313,7 +334,7 @@ module.exports = {
|
|||||||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
* 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 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 extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||||
function scanFolder() {
|
function scanFolder() {
|
||||||
if (!photoFolder) return false;
|
if (!photoFolder) return false;
|
||||||
@@ -449,11 +470,13 @@ module.exports = {
|
|||||||
if (!e) return null;
|
if (!e) return null;
|
||||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||||
if (e.pco) {
|
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 || '';
|
manual = false; personId = e.pco.personId || '';
|
||||||
name = String(e.pco.name || '').trim();
|
name = String(e.pco.name || '').trim();
|
||||||
file = e.pco.file || '';
|
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
||||||
birthday = e.pco.birthday || '';
|
file = (lib && lib.file) || e.pco.file || '';
|
||||||
|
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
||||||
} else if (e.personId) {
|
} else if (e.personId) {
|
||||||
const lib = library[e.personId];
|
const lib = library[e.personId];
|
||||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
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
|
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
|
||||||
if (pco.configured && serviceTypeId) {
|
if (pco.configured && serviceTypeId) {
|
||||||
const plan = pickPlan(await listPlans(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.'; }
|
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
|
||||||
else {
|
else {
|
||||||
|
|
||||||
@@ -511,6 +535,7 @@ module.exports = {
|
|||||||
personId,
|
personId,
|
||||||
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
|
||||||
label: String(m.attributes?.team_position_name || '').trim(),
|
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(),
|
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
|
||||||
});
|
});
|
||||||
if (personId) seen.add(personId);
|
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 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) || '';
|
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 eff = effectiveSlots();
|
||||||
|
const planId = String(plan.id);
|
||||||
const openIdx = []; const manualNames = new Set();
|
const openIdx = []; const manualNames = new Set();
|
||||||
for (let i = 0; i < eff.length; i += 1) {
|
for (let i = 0; i < eff.length; i += 1) {
|
||||||
const e = board[String(i)];
|
const e = board[String(i)];
|
||||||
if (e && (e.personId || e.name)) {
|
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
|
||||||
const nm = e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
|
||||||
if (nm) manualNames.add(normName(nm));
|
if (nm) manualNames.add(normName(nm));
|
||||||
} else openIdx.push(i);
|
} else openIdx.push(i);
|
||||||
}
|
}
|
||||||
@@ -537,11 +576,12 @@ module.exports = {
|
|||||||
for (let k = 0; k < openIdx.length; k += 1) {
|
for (let k = 0; k < openIdx.length; k += 1) {
|
||||||
const i = openIdx[k]; const m = assigned[k];
|
const i = openIdx[k]; const m = assigned[k];
|
||||||
if (m) {
|
if (m) {
|
||||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
|
||||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
|
||||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
||||||
} else if (board[String(i)] && board[String(i)].pco) {
|
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
|
||||||
delete board[String(i)]; // was synced, no longer on the plan
|
} else if (board[String(i)]) {
|
||||||
|
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
saveBoard();
|
saveBoard();
|
||||||
@@ -580,6 +620,9 @@ module.exports = {
|
|||||||
current = {
|
current = {
|
||||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||||
|
planId: () => (latest.plan && latest.plan.id) || '',
|
||||||
|
rememberPhoto,
|
||||||
|
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
|
||||||
photoUrlFor, libraryList,
|
photoUrlFor, libraryList,
|
||||||
setChoir, choirOn: () => choirOn,
|
setChoir, choirOn: () => choirOn,
|
||||||
};
|
};
|
||||||
@@ -670,6 +713,10 @@ module.exports = {
|
|||||||
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
|
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.' });
|
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];
|
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 (a === undefined) delete current.board[to]; else current.board[to] = a;
|
||||||
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
if (b === undefined) delete current.board[from]; else current.board[from] = b;
|
||||||
current.saveBoard(); current.rebuildLocal();
|
current.saveBoard(); current.rebuildLocal();
|
||||||
@@ -684,7 +731,14 @@ module.exports = {
|
|||||||
const personId = String(q.get('personId') || '').trim();
|
const personId = String(q.get('personId') || '').trim();
|
||||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
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.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];
|
else delete current.board[slot];
|
||||||
current.saveBoard(); current.rebuildLocal();
|
current.saveBoard(); current.rebuildLocal();
|
||||||
json(res, 200, { ok: true });
|
json(res, 200, { ok: true });
|
||||||
@@ -702,6 +756,7 @@ module.exports = {
|
|||||||
const name = String(q.get('name') || '').trim();
|
const name = String(q.get('name') || '').trim();
|
||||||
const existing = current.board[index] || { personId: '', name: '' };
|
const existing = current.board[index] || { personId: '', name: '' };
|
||||||
existing.name = name;
|
existing.name = name;
|
||||||
|
existing.plan = current.planId();
|
||||||
if (!existing.personId && !name) delete current.board[index];
|
if (!existing.personId && !name) delete current.board[index];
|
||||||
else current.board[index] = existing;
|
else current.board[index] = existing;
|
||||||
current.saveBoard(); current.rebuildLocal();
|
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;
|
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; }
|
[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);
|
||||||
|
}
|
||||||
@@ -160,7 +160,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
el.sources.hidden = !on('showSources') || !s.sources.length;
|
el.sources.hidden = !on('showSources') || !s.sources.length;
|
||||||
if (!el.sources.hidden) {
|
if (!el.sources.hidden) {
|
||||||
el.sources.innerHTML = s.sources.map((x) => `
|
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 class="vc-dot"></span>${esc(x.label)}
|
||||||
</span>`).join('');
|
</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');
|
if (!s.listening) return moduleApi.setStatus('error', s.error || 'Not listening for MIDI');
|
||||||
const connected = s.sources.filter((x) => x.connected);
|
const connected = s.sources.filter((x) => x.connected);
|
||||||
const waiting = 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) {
|
if (!connected.length) {
|
||||||
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
const why = waiting.length ? `Joining ${waiting.map((x) => `${x.label}${x.error ? ` (${x.error})` : ''}`).join(', ')}` : 'No MIDI session connected';
|
||||||
return moduleApi.setStatus('error', why);
|
return moduleApi.setStatus('error', why);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "vista-cues",
|
"id": "vista-cues",
|
||||||
"name": "Vista Cues",
|
"name": "Vista Cues",
|
||||||
"version": "1.0.1",
|
"version": "1.1.0",
|
||||||
"proddash": ">=1.5.0",
|
"proddash": ">=1.5.0",
|
||||||
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
"description": "Which Vista 3 cue lists are live: hears the network MIDI that ProPresenter and Tracks send Vista",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
@@ -131,6 +131,13 @@
|
|||||||
"default": true,
|
"default": true,
|
||||||
"group": "This machine",
|
"group": "This machine",
|
||||||
"help": "List this machine in Audio MIDI Setup's Directory on the network (macOS)."
|
"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": {
|
"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) {
|
function endpointOf(v) {
|
||||||
const ep = v && typeof v === 'object' ? v : {};
|
const ep = v && typeof v === 'object' ? v : {};
|
||||||
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
return { host: String(ep.host || '').trim(), port: Number(ep.port) || 5004 };
|
||||||
@@ -62,6 +100,8 @@ module.exports = {
|
|||||||
const exclusive = config.exclusive === true;
|
const exclusive = config.exclusive === true;
|
||||||
const headTakesOver = config.headTakesOver !== false;
|
const headTakesOver = config.headTakesOver !== false;
|
||||||
const { rules, errors: mappingErrors, groups } = parseMapping(config.cueLists, { middleC });
|
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) {
|
if (mappingErrors.length) {
|
||||||
for (const e of mappingErrors) log(`cue lists line ${e.line} skipped (${e.why}): ${e.text}`);
|
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,
|
label: s.label,
|
||||||
name: p ? p.name : '',
|
name: p ? p.name : '',
|
||||||
address: inv?.address || s.host,
|
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,
|
configured: true,
|
||||||
connected: Boolean(p),
|
connected: Boolean(p),
|
||||||
error: p ? '' : (inv?.error || ''),
|
error: p ? '' : (inv?.error || ''),
|
||||||
@@ -195,7 +238,7 @@ module.exports = {
|
|||||||
}
|
}
|
||||||
for (const p of session.participants.values()) {
|
for (const p of session.participants.values()) {
|
||||||
if (seen.has(p.key) || !p.connected) continue;
|
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;
|
return out;
|
||||||
}
|
}
|
||||||
@@ -219,6 +262,7 @@ module.exports = {
|
|||||||
sessionName,
|
sessionName,
|
||||||
port,
|
port,
|
||||||
advertised,
|
advertised,
|
||||||
|
wiredNetwork: netText,
|
||||||
sources: sources(),
|
sources: sources(),
|
||||||
current: cur || null,
|
current: cur || null,
|
||||||
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
// The rest in sheet order of their group, so a colour chip stays put as it changes.
|
||||||
@@ -304,6 +348,16 @@ module.exports = {
|
|||||||
health() {
|
health() {
|
||||||
if (session.error) return { status: 'error', message: session.error };
|
if (session.error) return { status: 'error', message: session.error };
|
||||||
const src = sources();
|
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 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 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';
|
const map = rules.length ? '' : ' · no cue lists mapped yet';
|
||||||
@@ -323,6 +377,9 @@ 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 —
|
* 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
|
* a module with no list sits headless under whichever module precedes it
|
||||||
|
|||||||
@@ -180,6 +180,8 @@
|
|||||||
}
|
}
|
||||||
[data-module="vista-cues"] .vc-src.is-on { color: var(--text); }
|
[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-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) {
|
@container (max-height: 140px) {
|
||||||
[data-module="vista-cues"] .vc-sources { display: none; }
|
[data-module="vista-cues"] .vc-sources { display: none; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -363,6 +363,53 @@ async function freePair() {
|
|||||||
await wait(80);
|
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`);
|
console.log(`\n${passed} passed`);
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
})().catch((e) => {
|
})().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