Overflow Tap: speak Overflow's Dashboard API, as its own helper app does
The first cut guessed a write route on Overflow's public API, which documents
only reads for Tap. With the helper app's bundle in hand (Overflow ProPresenter
Integration 1.2.3, read 2026-10-11) the module now makes exactly the calls the
helper makes, against the Dashboard's own backend:
POST /nonprofit-dashboard/authentication/login/jwt a Dashboard sign-in → JWT
GET /nonprofit-dashboard/propresenter/nonprofits/{id}/keywords
the Keyword Mappings
POST …/nonprofits/{id}/keywords?syncNonprofit=true { keywords } = the switch
So the keyword → destination → Tap groups mapping stays where Overflow keeps
it (Dashboard › Integrations › ProPresenter › Keyword Mappings) and is read
from there: on start, every five minutes, on the tile's refresh button, and
from every trigger's answer. Admin config shrinks to the ProPresenter endpoint,
the Overflow Dashboard login (plus a Nonprofit ID only for an @overflow.co
staff login) and the alert switch; the API-key, Tap-groups, keyword-text and
match-mode fields are gone.
Behaviour follows the helper: both the presentation layer (/v1/status/slide)
and the announcement layer (/v1/announcement/slide_index + /active) are
watched; when a layer's set of matched keywords changes to a non-empty set it
is sent; matching is per word, case-insensitive, punctuation around the word
ignored. The session token is cached across re-inits for the same login so an
admin saving config repeatedly cannot trip Overflow's "too many attempts";
a 401 signs in again once; a refused sign-in is not retried within 20 s.
tools/overflow-tap-mock.js now plays the Dashboard API (any email/password
signs in; --fail-switch, --expire=<s>, --login=<email>:<password>) and gives
the mock ProPresenter an announcement layer (--no-announcements for an older
one). Verified against it: sign-in, keyword load, triggers on both layers,
token expiry → re-login, 500 → NOT SWITCHED + alert + retries, wrong login →
the reason on the tile and in the health line, and recovery. keywords.selftest.js
pins the matching rule (8 cases).
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
6b51044be6
commit
459d8b9c38
@@ -1,27 +1,21 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Keyword mappings — reading the admin page's Keywords text and deciding
|
||||
* which keyword a slide's notes carry.
|
||||
* 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:
|
||||
*
|
||||
* parseKeywordLines(text) → [{ line, keyword, destination, groups, problem }]
|
||||
* keywordMatches(notes, keyword, mode) → boolean
|
||||
* matchKeyword(notes, mappings, mode) → the first mapping whose keyword the notes carry, or null
|
||||
* cleanNotes(raw) → the notes with ProPresenter's invisible padding removed
|
||||
* 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 Keywords text is one mapping per line:
|
||||
*
|
||||
* give = Give Online
|
||||
* connect -> Connect Card @ Lobby, Kids Wing
|
||||
* # a comment
|
||||
*
|
||||
* `=`, `->` and `→` all separate the keyword from the destination. A trailing
|
||||
* ` @ ` names the Tap groups this line switches (by name or id); without it the
|
||||
* admin page's chosen groups apply. The destination is the name Overflow shows
|
||||
* for it, or its id — resolving that against the account is the server's job,
|
||||
* so a parse never needs the network.
|
||||
* 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;
|
||||
@@ -34,68 +28,39 @@ function norm(s) {
|
||||
return String(s ?? '').trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function parseKeywordLines(text) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
cleanNotes(text).split('\n').forEach((raw, i) => {
|
||||
const line = raw.trim();
|
||||
if (!line || line.startsWith('#')) return;
|
||||
const m = /^(.*?)\s*(?:=|->|→)\s*(.*)$/.exec(line);
|
||||
const entry = { line: i + 1, keyword: '', destination: '', groups: [], problem: '' };
|
||||
if (!m) {
|
||||
entry.keyword = line;
|
||||
entry.problem = 'No destination — write it as "keyword = Destination name"';
|
||||
out.push(entry);
|
||||
return;
|
||||
}
|
||||
entry.keyword = m[1].trim();
|
||||
let rest = m[2].trim();
|
||||
const at = rest.indexOf('@');
|
||||
if (at >= 0) {
|
||||
entry.groups = rest.slice(at + 1).split(',').map((s) => s.trim()).filter(Boolean);
|
||||
rest = rest.slice(0, at).trim();
|
||||
}
|
||||
entry.destination = rest;
|
||||
if (!entry.keyword) entry.problem = 'No keyword before the =';
|
||||
else if (!entry.destination) entry.problem = 'No destination after the =';
|
||||
else if (seen.has(norm(entry.keyword))) entry.problem = 'Repeated keyword — the first line wins';
|
||||
if (entry.keyword) seen.add(norm(entry.keyword));
|
||||
out.push(entry);
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeRe(s) {
|
||||
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Does this slide's notes carry the keyword?
|
||||
* word — the keyword (a word or a phrase) bounded by the start/end of the
|
||||
* notes or by something that is not a letter, digit or underscore.
|
||||
* `give` fires on "give" and "give!" but not on "forgiveness".
|
||||
* anywhere — plain substring, what Overflow's own helper does.
|
||||
* line — a line of the notes is exactly the keyword.
|
||||
* All three ignore case and collapse runs of whitespace inside a phrase.
|
||||
*/
|
||||
function keywordMatches(notes, keyword, mode = 'word') {
|
||||
function keywordMatches(notes, keyword) {
|
||||
const n = cleanNotes(notes);
|
||||
const k = norm(keyword);
|
||||
if (!k || !n.trim()) return false;
|
||||
if (mode === 'line') return n.split('\n').some((l) => norm(l) === k);
|
||||
const flat = n.toLowerCase().replace(/\s+/g, ' ');
|
||||
if (mode === 'anywhere') return flat.includes(k);
|
||||
const re = new RegExp(`(?:^|[^\\p{L}\\p{N}_])${escapeRe(k)}(?=$|[^\\p{L}\\p{N}_])`, 'u');
|
||||
return re.test(flat);
|
||||
}
|
||||
|
||||
/** The first mapping (in admin order) whose keyword the notes carry, or null. */
|
||||
function matchKeyword(notes, mappings, mode = 'word') {
|
||||
for (const m of mappings || []) {
|
||||
if (!m || !m.keyword) continue;
|
||||
if (keywordMatches(notes, m.keyword, mode)) return m;
|
||||
/** 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 null;
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = { cleanNotes, norm, parseKeywordLines, keywordMatches, matchKeyword };
|
||||
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 };
|
||||
|
||||
Reference in New Issue
Block a user