571 lines
20 KiB
JavaScript
571 lines
20 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* Service Times — server part.
|
|
*
|
|
* Owns the one connection to the church website, keeps every campus's Sunday
|
|
* service times in memory, and fans them — together with the production marks
|
|
* an operator puts on a service — out to every tile over SSE. Browsers never
|
|
* talk to the website.
|
|
*
|
|
* GET /state → { state } (instant paint for new tiles)
|
|
* GET /stream → SSE, `state` events (change-detected, heartbeated)
|
|
* POST /mark → mark one service (replay / do not use / issue)
|
|
* POST /clear → drop every mark (a fresh week)
|
|
* POST /refresh → re-read the website now
|
|
* GET /campuses → { options } — the campuses in play, for the admin
|
|
* broadcast picker and a tile's campus list
|
|
* GET /discovered → { options } — every campus page the website publishes,
|
|
* for the admin "Campuses" picker
|
|
*
|
|
* Why the marks live here and not in a tile's settings: a mark is the booth's
|
|
* shared answer to "is this service usable?", and every campus watching the
|
|
* board has to see the same one. Per-tile settings are per browser, so they
|
|
* would let two people disagree about whether the 9:30 is going out. Marks
|
|
* are written to the ProdDash data directory so a restart mid-morning does
|
|
* not lose them, and they expire on their own (admin "Marks") so last week's
|
|
* never greet a new Sunday.
|
|
*
|
|
* The website is the source of truth for times. Manual campuses (admin) are a
|
|
* stopgap for a campus the site has not published yet; the moment the site
|
|
* carries it, the site's times take over and the admin page says the manual
|
|
* line can go.
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const os = require('os');
|
|
const path = require('path');
|
|
const {
|
|
parseCampusPage,
|
|
discoverCampusSlugs,
|
|
parseManualCampuses,
|
|
mergeCampuses,
|
|
parseFeeds,
|
|
assignRoles,
|
|
slugify,
|
|
} = require('./parse');
|
|
|
|
/** Abort a hung page read well before the refresh cadence. */
|
|
const REQUEST_TIMEOUT_MS = 12000;
|
|
/** SSE comment ping cadence — keeps idle connections alive through sleepy Wi-Fi. */
|
|
const HEARTBEAT_MS = 15000;
|
|
/** Fastest allowed re-read: service times change a few times a year. */
|
|
const MIN_REFRESH_MINUTES = 5;
|
|
/** Backoff after a failed read — keep the last good times on screen meanwhile. */
|
|
const RETRY_MS = 60000;
|
|
/** Guard against a redirect loop or a runaway campus list. */
|
|
const MAX_CAMPUSES = 40;
|
|
const BODY_LIMIT_BYTES = 64 * 1024;
|
|
|
|
const MARK_STATUSES = new Set(['ok', 'issue', 'replay', 'skip']);
|
|
const NOTE_LIMIT = 280;
|
|
|
|
/** Set by init(), read by the routes — both are rebuilt together on remount. */
|
|
let current = null;
|
|
|
|
/* ── small helpers ──────────────────────────────────────────────────── */
|
|
|
|
function sendJson(res, code, body) {
|
|
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
res.end(JSON.stringify(body));
|
|
}
|
|
|
|
function readJsonBody(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let data = '';
|
|
let size = 0;
|
|
req.on('data', (chunk) => {
|
|
size += chunk.length;
|
|
if (size > BODY_LIMIT_BYTES) {
|
|
reject(new Error('Request body too large.'));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
data += chunk;
|
|
});
|
|
req.on('end', () => {
|
|
if (!data.trim()) return resolve({});
|
|
try {
|
|
const body = JSON.parse(data);
|
|
resolve(body && typeof body === 'object' ? body : {});
|
|
} catch {
|
|
reject(new Error('Malformed JSON.'));
|
|
}
|
|
});
|
|
req.on('error', reject);
|
|
});
|
|
}
|
|
|
|
/** Route handlers are async — the shell only catches synchronous throws. */
|
|
function guard(fn) {
|
|
return (req, res) => {
|
|
Promise.resolve(fn(req, res)).catch((err) => {
|
|
try {
|
|
sendJson(res, err && err.status ? err.status : 500, { error: err?.message || String(err) });
|
|
} catch { /* the client is already gone */ }
|
|
});
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Where ProdDash keeps per-machine state, resolved the way the shell does.
|
|
* Never the checkout: the repo folder is replaced on update.
|
|
*/
|
|
function resolveDataDir() {
|
|
const root = path.join(__dirname, '..', '..');
|
|
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
|
|
if (fromEnv) return path.resolve(root, fromEnv);
|
|
const home = os.homedir();
|
|
if (process.platform === 'win32') {
|
|
return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'ProdDash');
|
|
}
|
|
if (process.platform === 'darwin') {
|
|
return path.join(home, 'Library', 'Application Support', 'ProdDash');
|
|
}
|
|
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'proddash');
|
|
}
|
|
|
|
const MARKS_FILE = path.join(resolveDataDir(), 'service-times-marks.json');
|
|
|
|
function markKey(campus, service) {
|
|
return `${campus}::${service}`;
|
|
}
|
|
|
|
/* ── admin config shaping ───────────────────────────────────────────── */
|
|
|
|
function siteBase(config) {
|
|
const raw = String(config.siteUrl || '').trim().replace(/\/+$/, '');
|
|
if (!raw) return '';
|
|
return /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
|
|
}
|
|
|
|
function listSetting(value) {
|
|
if (Array.isArray(value)) return value.map((v) => slugify(v)).filter(Boolean);
|
|
if (typeof value === 'string') {
|
|
return value.split(/[\n,]+/).map((v) => slugify(v)).filter(Boolean);
|
|
}
|
|
return [];
|
|
}
|
|
|
|
/* ── the website ────────────────────────────────────────────────────── */
|
|
|
|
async function getText(url) {
|
|
const response = await fetch(url, {
|
|
redirect: 'follow',
|
|
headers: { Accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8' },
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
if (!response.ok) {
|
|
const err = new Error(`${response.status} ${response.statusText || ''}`.trim());
|
|
err.status = response.status;
|
|
throw err;
|
|
}
|
|
return response.text();
|
|
}
|
|
|
|
/** Every campus page the site publishes — the sitemap first, the index page as a fallback. */
|
|
async function discover(base, log) {
|
|
const sources = [];
|
|
for (const target of ['/page-sitemap.xml', '/wp-sitemap.xml', '/locations/', '/']) {
|
|
try {
|
|
sources.push(await getText(base + target));
|
|
if (target.includes('sitemap')) break; // a sitemap is the fuller list
|
|
} catch (err) {
|
|
log(`discovery: ${target} — ${err.message}`);
|
|
}
|
|
}
|
|
if (!sources.length) return [];
|
|
return discoverCampusSlugs(...sources).slice(0, MAX_CAMPUSES);
|
|
}
|
|
|
|
async function readCampus(base, slug) {
|
|
try {
|
|
const html = await getText(`${base}/locations/${slug}/`);
|
|
return parseCampusPage(html, { slug });
|
|
} catch (err) {
|
|
return {
|
|
slug,
|
|
name: slugify(slug).split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
|
services: [],
|
|
error: `Page unreadable: ${err.message}`,
|
|
};
|
|
}
|
|
}
|
|
|
|
/* ── module ─────────────────────────────────────────────────────────── */
|
|
|
|
module.exports = {
|
|
init({ config, log }) {
|
|
const streams = new Set();
|
|
const base = siteBase(config);
|
|
|
|
const chosen = listSetting(config.campuses);
|
|
const broadcast = new Set(listSetting(config.broadcast));
|
|
const { feeds, errors: feedErrors } = parseFeeds(config.feeds);
|
|
const { campuses: manualCampuses, errors: manualErrors } = parseManualCampuses(config.manual);
|
|
const retentionHours = Math.max(0, Number(config.retentionHours) || 0);
|
|
const refreshMs = Math.max(MIN_REFRESH_MINUTES, Number(config.refreshMinutes) || 360) * 60000;
|
|
|
|
let marks = new Map();
|
|
let marksWritable = true;
|
|
let discovered = [];
|
|
let siteCampuses = [];
|
|
let reachable = false;
|
|
let lastError = base ? '' : 'No website address configured';
|
|
let lastFetched = 0;
|
|
let timer = null;
|
|
let stopped = false;
|
|
let lastFrame = '';
|
|
|
|
/* ── marks: load, save, expire ──────────────────────────────────── */
|
|
|
|
function loadMarks() {
|
|
try {
|
|
const saved = JSON.parse(fs.readFileSync(MARKS_FILE, 'utf8'));
|
|
for (const [key, value] of Object.entries(saved?.marks || {})) {
|
|
if (value && typeof value === 'object') marks.set(key, value);
|
|
}
|
|
log(`${marks.size} mark(s) restored`);
|
|
} catch {
|
|
/* no marks yet — the usual case */
|
|
}
|
|
}
|
|
|
|
function saveMarks() {
|
|
const payload = { marks: Object.fromEntries(marks), savedAt: Date.now() };
|
|
try {
|
|
fs.mkdirSync(path.dirname(MARKS_FILE), { recursive: true });
|
|
const tmp = `${MARKS_FILE}.tmp`;
|
|
fs.writeFileSync(tmp, JSON.stringify(payload, null, 2) + '\n');
|
|
fs.renameSync(tmp, MARKS_FILE);
|
|
marksWritable = true;
|
|
} catch (err) {
|
|
// Never fall back to writing inside the checkout: the marks stay in
|
|
// memory and health() says they will not survive a restart.
|
|
if (marksWritable) log(`marks cannot be saved (${err.message}) — keeping them in memory only`);
|
|
marksWritable = false;
|
|
}
|
|
}
|
|
|
|
/** Drop marks older than the retention window. Returns true if any went. */
|
|
function expireMarks() {
|
|
if (!retentionHours) return false;
|
|
const cutoff = Date.now() - retentionHours * 3600000;
|
|
let dropped = false;
|
|
for (const [key, mark] of marks) {
|
|
if (!(Number(mark?.at) > cutoff)) {
|
|
marks.delete(key);
|
|
dropped = true;
|
|
}
|
|
}
|
|
if (dropped) saveMarks();
|
|
return dropped;
|
|
}
|
|
|
|
/* ── the view every tile draws ──────────────────────────────────── */
|
|
|
|
function campusList() {
|
|
const wanted = chosen.length
|
|
? siteCampuses.filter((c) => chosen.includes(c.slug))
|
|
: siteCampuses;
|
|
const merged = assignRoles(mergeCampuses(wanted, manualCampuses), { broadcast, feeds });
|
|
|
|
// Marks ride along on the service they belong to.
|
|
for (const campus of merged) {
|
|
campus.services = campus.services.map((service) => {
|
|
const mark = marks.get(markKey(campus.slug, service.id)) || null;
|
|
return { ...service, mark };
|
|
});
|
|
}
|
|
return merged;
|
|
}
|
|
|
|
function snapshot() {
|
|
return {
|
|
configured: Boolean(base),
|
|
reachable,
|
|
lastError,
|
|
lastFetched,
|
|
siteUrl: base,
|
|
refreshMinutes: refreshMs / 60000,
|
|
retentionHours,
|
|
marksWritable,
|
|
campuses: campusList(),
|
|
discovered,
|
|
manualErrors,
|
|
feedErrors,
|
|
now: Date.now(),
|
|
};
|
|
}
|
|
|
|
function broadcastState({ force = false } = {}) {
|
|
if (!streams.size) return;
|
|
const view = snapshot();
|
|
let frame;
|
|
try {
|
|
frame = JSON.stringify(view);
|
|
} catch {
|
|
return;
|
|
}
|
|
// `now` changes every call; compare without it so idle tiles stay idle.
|
|
const compare = frame.replace(/,"now":\d+/, '');
|
|
if (!force && compare === lastFrame) return;
|
|
lastFrame = compare;
|
|
const payload = `event: state\ndata: ${frame}\n\n`;
|
|
for (const res of streams) {
|
|
try {
|
|
res.write(payload);
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}
|
|
|
|
/* ── the refresh loop ───────────────────────────────────────────── */
|
|
|
|
async function refresh() {
|
|
if (stopped) return;
|
|
if (!base) {
|
|
reachable = false;
|
|
lastError = 'No website address configured';
|
|
return;
|
|
}
|
|
try {
|
|
const found = await discover(base, log);
|
|
if (found.length) discovered = found;
|
|
const slugs = chosen.length ? chosen : discovered;
|
|
if (!slugs.length) throw new Error('No campus pages found on the site');
|
|
|
|
const read = await Promise.all(slugs.slice(0, MAX_CAMPUSES).map((slug) => readCampus(base, slug)));
|
|
const usable = read.filter((campus) => campus.services.length);
|
|
|
|
// A total failure keeps the last good times on screen — a site outage
|
|
// must not blank the board in the middle of a service.
|
|
if (!usable.length && siteCampuses.length) {
|
|
throw new Error(read[0]?.error || 'No service times found on the site');
|
|
}
|
|
siteCampuses = read;
|
|
reachable = true;
|
|
lastFetched = Date.now();
|
|
const broken = read.filter((campus) => campus.error);
|
|
lastError = broken.length ? `${broken.length} campus page(s) unreadable: ${broken[0].error}` : '';
|
|
log(`read ${usable.length}/${read.length} campus page(s) from ${base}`);
|
|
} catch (err) {
|
|
reachable = false;
|
|
lastError = err?.message || String(err);
|
|
log(`refresh failed: ${lastError}`);
|
|
}
|
|
}
|
|
|
|
function schedule(delay) {
|
|
clearTimeout(timer);
|
|
if (stopped) return;
|
|
timer = setTimeout(async () => {
|
|
await refresh();
|
|
expireMarks();
|
|
broadcastState();
|
|
schedule(reachable ? refreshMs : RETRY_MS);
|
|
}, delay);
|
|
timer.unref?.();
|
|
}
|
|
|
|
loadMarks();
|
|
expireMarks();
|
|
if (base) {
|
|
log(`reading service times from ${base} every ${Math.round(refreshMs / 60000)} min`);
|
|
} else {
|
|
log('no website address configured — set one in /admin');
|
|
}
|
|
// First read immediately, then on the configured cadence.
|
|
schedule(0);
|
|
|
|
const heartbeat = setInterval(() => {
|
|
expireMarks();
|
|
broadcastState();
|
|
for (const res of streams) {
|
|
try {
|
|
res.write(': ping\n\n');
|
|
} catch {
|
|
streams.delete(res);
|
|
}
|
|
}
|
|
}, HEARTBEAT_MS);
|
|
heartbeat.unref?.();
|
|
|
|
current = {
|
|
streams,
|
|
snapshot,
|
|
broadcastState,
|
|
refreshNow: async () => {
|
|
await refresh();
|
|
broadcastState({ force: true });
|
|
},
|
|
setMark(campusSlug, serviceId, patch) {
|
|
const key = markKey(slugify(campusSlug), String(serviceId || ''));
|
|
const status = MARK_STATUSES.has(patch.status) ? patch.status : 'ok';
|
|
if (status === 'ok' && !patch.note && !patch.source) {
|
|
marks.delete(key);
|
|
} else {
|
|
marks.set(key, {
|
|
status,
|
|
note: String(patch.note || '').slice(0, NOTE_LIMIT),
|
|
source: String(patch.source || '').slice(0, 120),
|
|
at: Date.now(),
|
|
});
|
|
}
|
|
saveMarks();
|
|
broadcastState({ force: true });
|
|
},
|
|
clearMarks() {
|
|
const had = marks.size;
|
|
marks.clear();
|
|
saveMarks();
|
|
broadcastState({ force: true });
|
|
return had;
|
|
},
|
|
};
|
|
|
|
return {
|
|
stop() {
|
|
stopped = true;
|
|
clearTimeout(timer);
|
|
clearInterval(heartbeat);
|
|
for (const res of streams) {
|
|
try { res.end(); } catch { /* already gone */ }
|
|
}
|
|
streams.clear();
|
|
if (current && current.streams === streams) current = null;
|
|
},
|
|
health() {
|
|
if (!base) return { status: 'error', message: 'No website address configured' };
|
|
const notes = [];
|
|
if (!marksWritable) notes.push('marks are memory-only — the data directory is not writable');
|
|
if (manualErrors.length) notes.push(manualErrors[0]);
|
|
if (feedErrors.length) notes.push(feedErrors[0]);
|
|
const superseded = campusList().filter((c) => c.supersededManual).map((c) => c.name);
|
|
if (superseded.length) {
|
|
notes.push(`the website now publishes ${superseded.join(', ')} — the manual line(s) can be removed`);
|
|
}
|
|
const suffix = notes.length ? ` — ${notes.join('; ')}` : '';
|
|
if (!reachable) {
|
|
return { status: 'error', message: `${lastError || 'Website unreachable'}${suffix}` };
|
|
}
|
|
const count = campusList().reduce((sum, c) => sum + c.services.length, 0);
|
|
const warn = lastError || notes.length;
|
|
return {
|
|
status: warn ? 'connecting' : 'ok',
|
|
message: `${count} service(s) across ${campusList().length} campus(es)${lastError ? ` — ${lastError}` : ''}${suffix}`,
|
|
};
|
|
},
|
|
};
|
|
},
|
|
|
|
/**
|
|
* The board, plus one entry per campus. A campus booth wants its own times
|
|
* and the service it is taking resi from — not the whole network — so those
|
|
* entries fix the campus and drop the campus picker from their gear.
|
|
*/
|
|
tiles() {
|
|
const board = {
|
|
id: 'board',
|
|
name: 'Service Times',
|
|
description: 'Every campus, its services, and what is marked for replay or unusable',
|
|
defaultSize: { w: 6, h: 5 },
|
|
minSize: { w: 3, h: 3 },
|
|
settings: {},
|
|
};
|
|
const campuses = current ? current.snapshot().campuses : [];
|
|
return [
|
|
board,
|
|
...campuses.map((campus) => ({
|
|
id: campus.slug,
|
|
name: campus.name,
|
|
description: `${campus.name} service times`,
|
|
defaultSize: { w: 3, h: 4 },
|
|
minSize: { w: 2, h: 2 },
|
|
settings: { campuses: [campus.slug] },
|
|
instanceSchema: {
|
|
showDay: { type: 'boolean', label: 'Day', default: true, group: 'Show' },
|
|
showRole: { type: 'boolean', label: 'Role', default: true, group: 'Show' },
|
|
showSource: { type: 'boolean', label: 'Resi source', default: true, group: 'Show' },
|
|
showDependents: { type: 'boolean', label: 'Campuses fed', default: true, group: 'Show' },
|
|
showNotes: { type: 'boolean', label: 'Notes', default: true, group: 'Show' },
|
|
showUpdated: { type: 'boolean', label: 'Updated line', default: false, group: 'Show' },
|
|
},
|
|
instanceGroups: { Show: { collapsed: true, columns: 2 } },
|
|
})),
|
|
];
|
|
},
|
|
|
|
routes() {
|
|
return {
|
|
'GET /state': (req, res) => {
|
|
sendJson(res, 200, { state: current ? current.snapshot() : null });
|
|
},
|
|
|
|
'GET /stream': (req, res) => {
|
|
res.writeHead(200, {
|
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
'Cache-Control': 'no-cache, no-transform',
|
|
Connection: 'keep-alive',
|
|
'X-Accel-Buffering': 'no',
|
|
});
|
|
res.write('retry: 3000\n\n');
|
|
res.write(`event: state\ndata: ${JSON.stringify(current ? current.snapshot() : null)}\n\n`);
|
|
if (!current) return void res.end();
|
|
const { streams } = current;
|
|
// A viewer that stops reading (locked tablet, sleeping laptop) must
|
|
// not queue frames forever; keepalive probes make the OS notice.
|
|
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
|
|
streams.add(res);
|
|
req.on('close', () => streams.delete(res));
|
|
},
|
|
|
|
'POST /mark': guard(async (req, res) => {
|
|
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
|
const body = await readJsonBody(req);
|
|
const campus = String(body.campus || '').trim();
|
|
const service = String(body.service || '').trim();
|
|
if (!campus || !service) return sendJson(res, 400, { error: 'campus and service are required' });
|
|
current.setMark(campus, service, {
|
|
status: body.status,
|
|
note: body.note,
|
|
source: body.source,
|
|
});
|
|
sendJson(res, 200, { ok: true });
|
|
}),
|
|
|
|
'POST /clear': guard(async (req, res) => {
|
|
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
|
sendJson(res, 200, { ok: true, cleared: current.clearMarks() });
|
|
}),
|
|
|
|
'POST /refresh': guard(async (req, res) => {
|
|
if (!current) return sendJson(res, 503, { error: 'Module is restarting' });
|
|
await current.refreshNow();
|
|
sendJson(res, 200, { ok: true, state: current.snapshot() });
|
|
}),
|
|
|
|
'GET /campuses': (req, res) => {
|
|
const campuses = current ? current.snapshot().campuses : [];
|
|
sendJson(res, 200, {
|
|
options: campuses.map((campus) => ({ value: campus.slug, label: campus.name })),
|
|
});
|
|
},
|
|
|
|
'GET /discovered': (req, res) => {
|
|
const state = current ? current.snapshot() : null;
|
|
const names = new Map((state?.campuses || []).map((c) => [c.slug, c.name]));
|
|
const slugs = state?.discovered?.length ? state.discovered : [...names.keys()];
|
|
sendJson(res, 200, {
|
|
options: slugs.map((slug) => ({
|
|
value: slug,
|
|
label: names.get(slug)
|
|
|| slug.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' '),
|
|
})),
|
|
});
|
|
},
|
|
};
|
|
},
|
|
};
|