Files
christianandClaude Opus 5.5 1fd7dcdf1e Band Lineup 1.13.0: a photo per instrument, picked by the spot each person is in that week
DJ plays drums one week and sings the next; one photo can't fit both. A
person can now have a photo per instrument, named "Name - Instrument"
("DJ - Drums.jpg", "DJ - Vocals.jpg"), plus a plain "DJ.jpg" for
everything else. Sync reads the instrument family of the spot each person
lands in (its bracketed role, else their PCO position) and picks:

1. the photo picked by hand for them on that instrument;
2. a photo named for that instrument;
3. their everyday pick, else their best-named photo. A photo of them on
   another instrument still beats PCO's headshot.

Families keep the shorthands together: Vox/Vocals/Singer, Keys/Piano/Pad,
Drums/Perc, Bass, Guitar/Electric/Acoustic/Rhythm, Leader. A photo
dropped onto someone is remembered per person per instrument
("<id>|drums"), so a drum pick never overrides the vocal photo. A spot
naming several roles files under its most specific: Leader, then Vocals,
Drums, Keys, Bass, Guitar. Photo names from files now collapse the spaces
left by "Name - Instrument".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 13:40:00 -04:00

820 lines
46 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
/**
* Band Lineup — server part.
*
* Owns the shared stage board and the uploaded-photo library, plus the one
* Planning Center connection. The board is server-side and global: whoever
* edits it (drag a library photo onto a slot, rename, clear) changes it for
* every Band Lineup tile on every screen, over SSE. Planning Center sync is an
* optional auto-fill — it only fills slots nobody has placed by hand. Browsers
* never talk to Planning Center directly.
*
* GET /state → { state } (board + library; instant paint)
* GET /stream → SSE `state` events (change-detected, heartbeat)
* GET /library → { people } (the drag-in photo library)
* GET /service-types → { options } (admin "Service type" select)
* GET /plans → { options } (admin "Plan" select)
* GET /people → { options } (gear "Force birthday" select)
* GET /photo/<file> → image bytes (library + cached PCO photos)
* POST /library/<id> → rename / set birthday / replace photo (raw image body; ?name=&birthday=)
* POST /library/<id>/delete→ remove from the library and any slot
* POST /assign → ?slot=&personId= place a library person in a slot
* POST /move → ?from=&to= move or swap one slot's occupant
* POST /slot/<index>/name → ?name= rename the occupant (or make a name-only spot)
* POST /slot/<index>/clear → empty the slot
* POST /choir → ?on=1|0 toggle the extra CHOIR slot (shared)
* POST /sync → run a Planning Center + photo-folder sync now
*/
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
const SERVICES = '/services/v2';
const PEOPLE = '/people/v2';
const REQUEST_TIMEOUT_MS = 12000;
const PHOTO_TIMEOUT_MS = 15000;
const MIN_POLL_S = 10;
const RETRY_MS = 15000;
const HEARTBEAT_MS = 15000;
const MODULE_ID = 'band-lineup';
const MAX_UPLOAD = 20 * 1024 * 1024;
let current = null; // set by init(), read by routes; rebuilt together on remount
/* ── data directory (library + board + photo cache live here, never in the repo) ── */
function resolveDataDir() {
const home = os.homedir();
const fromEnv = String(process.env.PRODDASH_DATA_DIR || '').trim();
const root = path.join(__dirname, '..', '..');
if (fromEnv) return path.resolve(root, fromEnv);
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');
}
/* ── small helpers ─────────────────────────────────────────────────── */
function lines(text) { return String(text || '').split('\n').map((s) => s.trim()); }
function nonEmptyLines(text) { return lines(text).filter(Boolean); }
function localYmd(d) { return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }
function birthdayMmdd(raw) {
const s = String(raw || '').trim();
const full = s.match(/^\d{4}-(\d{2})-(\d{2})$/);
if (full) return `${full[1]}-${full[2]}`;
return /^\d{2}-\d{2}$/.test(s) ? s : '';
}
function birthdayWeekSet(now = new Date()) {
const daysSinceWed = (now.getDay() - 3 + 7) % 7;
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - daysSinceWed);
const set = new Set();
for (let i = 0; i < 7; i += 1) {
const d = new Date(start.getFullYear(), start.getMonth(), start.getDate() + i);
set.add(`${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`);
}
return set;
}
function extFromContentType(ct) {
const t = String(ct || '').toLowerCase();
if (t.includes('png')) return 'png';
if (t.includes('webp')) return 'webp';
if (t.includes('gif')) return 'gif';
return 'jpg';
}
function contentTypeForFile(file) {
const e = path.extname(file).toLowerCase();
if (e === '.png') return 'image/png';
if (e === '.webp') return 'image/webp';
if (e === '.gif') return 'image/gif';
return 'image/jpeg';
}
function json(res, status, body) {
res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
}
function readJsonFile(file, fallback) { try { return JSON.parse(fs.readFileSync(file, 'utf8')) || fallback; } catch { return fallback; } }
function newId() { return crypto.randomBytes(6).toString('hex'); }
function photoUrlFor(file) { return file ? `/api/modules/${MODULE_ID}/photo/${encodeURIComponent(file)}` : ''; }
/* ── Planning Center client ────────────────────────────────────────── */
function createPcoClient(config) {
const token = String(config.accessToken || '').trim();
const appId = String(config.appId || '').trim();
const secret = String(config.secret || '');
let auth = '';
if (token) auth = `Bearer ${token}`;
else if (appId && secret) auth = 'Basic ' + Buffer.from(`${appId}:${secret}`).toString('base64');
const configured = Boolean(auth);
async function get(target, params) {
const url = new URL(/^https?:\/\//.test(target) ? target : API_BASE + target);
for (const [k, v] of Object.entries(params || {})) url.searchParams.set(k, String(v));
let response;
try {
response = await fetch(url, { headers: { Authorization: auth, Accept: 'application/json' }, signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS) });
} catch (err) {
const e = new Error(`Planning Center unreachable: ${err?.message || err}`); e.network = true; throw e;
}
if (!response.ok) { const e = new Error(`Planning Center ${response.status}`); e.status = response.status; throw e; }
const text = await response.text();
return text ? JSON.parse(text) : {};
}
async function getAll(target, params) {
const data = []; const included = []; let next = null;
let query = { per_page: 100, ...(params || {}) }; let guard = 0;
do {
const body = next ? await get(next) : await get(target, query);
if (Array.isArray(body.data)) data.push(...body.data);
if (Array.isArray(body.included)) included.push(...body.included);
next = body?.links?.next || null; query = null; guard += 1;
} while (next && guard < 50);
return { data, included };
}
return { configured, get, getAll };
}
/* ── Google Drive (public "anyone with the link" folder, no API key) ── */
const DRIVE_TIMEOUT_MS = 30000;
const DRIVE_UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15) AppleWebKit/537.36';
/** Pull the folder id out of a Drive share link (or '' if it isn't a Drive folder link). */
function parseDriveFolderId(s) {
const str = String(s || '');
if (!/drive\.google\.com/i.test(str)) return '';
const m = /\/folders\/([A-Za-z0-9_-]{10,})/.exec(str) || /[?&]id=([A-Za-z0-9_-]{10,})/.exec(str);
return m ? m[1] : '';
}
/** List a public folder's files via Drive's embedded folder view — [{ id, name }]. */
async function driveList(folderId) {
const resp = await fetch(`https://drive.google.com/embeddedfolderview?id=${encodeURIComponent(folderId)}#list`, {
headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS),
});
if (!resp.ok) throw new Error(`Drive folder list ${resp.status}`);
const html = await resp.text();
const out = []; const seen = new Set();
const re = /id="entry-([A-Za-z0-9_-]{20,})"[\s\S]*?flip-entry-title[^>]*>([^<]+)</g;
let m;
while ((m = re.exec(html))) {
const id = m[1]; const name = m[2].trim();
if (id && !seen.has(id)) { seen.add(id); out.push({ id, name }); }
}
return out;
}
/** Download one public Drive file at full resolution (falls back to a large thumbnail). */
async function driveFetchImage(fileId) {
const base = 'https://drive.usercontent.google.com/download';
const opts = { headers: { 'User-Agent': DRIVE_UA }, redirect: 'follow', signal: AbortSignal.timeout(DRIVE_TIMEOUT_MS) };
let resp = await fetch(`${base}?id=${fileId}&export=download`, opts);
let ct = String(resp.headers.get('content-type') || '');
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
// Large-file virus-scan interstitial: grab the confirm token and retry.
if (ct.includes('text/html')) {
const html = await resp.text();
const conf = /name="confirm"\s+value="([^"]+)"/.exec(html) || /[?&]confirm=([0-9A-Za-z_-]+)/.exec(html);
const uuid = /name="uuid"\s+value="([^"]+)"/.exec(html);
if (conf) {
resp = await fetch(`${base}?id=${fileId}&export=download&confirm=${conf[1]}${uuid ? `&uuid=${uuid[1]}` : ''}`, opts);
ct = String(resp.headers.get('content-type') || '');
if (resp.ok && ct.startsWith('image/')) return { buf: Buffer.from(await resp.arrayBuffer()), ext: extFromContentType(ct) };
}
}
const th = await fetch(`https://drive.google.com/thumbnail?id=${fileId}&sz=w2000`, opts);
const tct = String(th.headers.get('content-type') || '');
if (th.ok && tct.startsWith('image/')) return { buf: Buffer.from(await th.arrayBuffer()), ext: extFromContentType(tct) };
throw new Error('not an image');
}
/* ── the module ────────────────────────────────────────────────────── */
module.exports = {
init({ config, log }) {
const streams = new Set();
const dataDir = path.join(resolveDataDir(), MODULE_ID);
const photoDir = path.join(dataDir, 'photos');
const libraryPath = path.join(dataDir, 'library.json');
const boardPath = path.join(dataDir, 'board.json');
try { fs.mkdirSync(photoDir, { recursive: true }); } catch (err) { log(`cannot create ${photoDir}: ${err?.message || err}`); }
const slots = nonEmptyLines(config.slots).length ? lines(config.slots) : [];
const teamKeywords = nonEmptyLines(config.teams).length ? nonEmptyLines(config.teams) : ['band', 'worship', 'vocal', 'production', 'communication'];
const columns = Math.max(1, Number(config.columns) || 7);
const serviceTypeId = String(config.serviceTypeId || '').trim();
const planIdCfg = String(config.planId || '').trim();
// Photos folder: the admin "Photos folder" field wins when set; otherwise it's whatever the
// tile's "Choose folder…" picker saved into module state. Admin can't host a native picker
// (the shell renders plain fields), so the picker lives in the tile as the no-typing option.
const settingsPath = path.join(dataDir, 'settings.json');
const settings = readJsonFile(settingsPath, {}) || {};
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
// The photo the booth picked for a Planning Center person on an instrument ("<PCO person id>|drums"
// → library id; a bare person id is their everyday pick), learned when a library photo is dropped
// onto that person. Sync uses it before any name matching.
if (!settings.photoFor || typeof settings.photoFor !== 'object') settings.photoFor = {};
function rememberPhoto(key, libId) {
if (!key || !libId || settings.photoFor[key] === libId) return;
settings.photoFor[key] = libId; saveSettings();
}
// Library photo for a scheduled person in a spot whose instruments are `fams`:
// 1. the photo picked for them on this instrument;
// 2. a photo named for this instrument ("DJ - Drums");
// 3. their everyday pick, else their best-named photo (an other-instrument photo of them
// still beats PCO's headshot).
function libraryPhotoFor(personId, name, fams) {
const live = (id) => (id && library[id] && library[id].file ? { id, ...library[id] } : null);
const forRole = live(settings.photoFor[photoKey(personId, fams)]);
if (forRole) return forRole;
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })), fams);
if (m && [...photoParts(m.name).roles].some((f) => fams.has(f))) return m;
return live(settings.photoFor[personId]) || (m && m.file ? m : null);
}
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
const CHOIR_LABEL = 'CHOIR';
// The board's slots plus an extra CHOIR spot when the toggle is on. Its index is slots.length,
// so its assignment persists in board.json across toggles.
const effectiveSlots = () => (choirOn ? slots.concat([CHOIR_LABEL]) : slots);
const pco = createPcoClient(config);
// Persisted, shared editor state.
let library = readJsonFile(libraryPath, {}); // { id: { name, file, birthday } }
let board = readJsonFile(boardPath, {}); // { slotIndex: { personId, name } }
function saveLibrary() { try { fs.writeFileSync(libraryPath, JSON.stringify(library, null, 2)); } catch (err) { log(`save library failed: ${err?.message || err}`); } }
function saveBoard() { try { fs.writeFileSync(boardPath, JSON.stringify(board, null, 2)); } catch (err) { log(`save board failed: ${err?.message || err}`); } }
let latest = clearedState();
let lastFrame = '';
let celebrateTimer = null;
let drivePollTimer = null;
let watcher = null;
let watchDebounce = null;
let stopped = false;
let syncing = false;
function clearedState() {
return {
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
reachable: false, lastError: '', lastFetched: 0, columns,
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
slots: effectiveSlots().map((label, index) => ({ index, label, person: null })),
library: [], choir: choirOn,
};
}
function libraryList() {
return Object.entries(library)
.map(([id, v]) => ({ id, name: v.name || '', photo: photoUrlFor(v.file || ''), birthday: v.birthday || '' }))
.sort((a, b) => a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }));
}
function broadcast() {
if (!streams.size) return;
let frame; try { frame = JSON.stringify(latest); } catch { return; }
if (frame === lastFrame) return;
lastFrame = frame;
const payload = `event: state\ndata: ${frame}\n\n`;
for (const res of streams) { try { res.write(payload); } catch { streams.delete(res); } }
}
async function listPlans(stId) {
const [future, past] = await Promise.all([
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'future', order: 'sort_date', per_page: 6 }),
pco.get(`${SERVICES}/service_types/${encodeURIComponent(stId)}/plans`, { filter: 'past', order: '-sort_date', per_page: 2 }),
]);
const rows = [...(future.data || []), ...(past.data || [])];
return rows.map((rec) => ({
id: String(rec.id), sortDate: Date.parse(rec.attributes?.sort_date || '') || 0,
title: String(rec.attributes?.title || rec.attributes?.series_title || '').trim(),
dates: String(rec.attributes?.dates || rec.attributes?.short_dates || '').trim(),
}));
}
function pickPlan(plans) {
if (planIdCfg) return plans.find((p) => p.id === planIdCfg) || { id: planIdCfg, title: '', dates: '' };
const todayY = localYmd(new Date());
const today = plans.find((p) => p.sortDate && localYmd(new Date(p.sortDate)) === todayY);
if (today) return today;
const now = Date.now();
const upcoming = plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate);
return upcoming[0] || plans.sort((a, b) => b.sortDate - a.sortDate)[0] || null;
}
async function cachePcoPhoto(personId, url) {
if (!url) return '';
const hash = crypto.createHash('sha1').update(url).digest('hex').slice(0, 8);
const existing = fs.existsSync(photoDir) ? fs.readdirSync(photoDir).find((f) => f.startsWith(`pco-${personId}-${hash}.`)) : null;
if (existing) return existing;
try {
const resp = await fetch(url, { signal: AbortSignal.timeout(PHOTO_TIMEOUT_MS) });
if (!resp.ok) return '';
const buf = Buffer.from(await resp.arrayBuffer());
if (!buf.length) return '';
const file = `pco-${personId}-${hash}.${extFromContentType(resp.headers.get('content-type'))}`;
fs.writeFileSync(path.join(photoDir, file), buf);
return file;
} catch { return ''; }
}
/**
* Import a folder of photos into the library and keep it in sync: new files are added
* (filename → name), changed files refreshed, and files removed from the folder dropped
* from the library (and any slot they filled). Manually-uploaded photos (no folderKey) are
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
*/
const IMG_RE = /\.(jpe?g|png|webp|gif)$/i;
const cleanNameFromFile = (f) => f.replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim(); // "DJ - Drums.jpg" → "DJ Drums"
function extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
function scanFolder() {
if (!photoFolder) return false;
let names; try { names = fs.readdirSync(photoFolder); } catch { return false; } // folder gone/unreadable
const files = names.filter((n) => IMG_RE.test(n));
const present = new Set(files);
const byKey = new Map();
for (const [id, v] of Object.entries(library)) if (v.folderKey) byKey.set(v.folderKey, id);
let changed = false;
for (const f of files) {
let mtime = 0; try { mtime = fs.statSync(path.join(photoFolder, f)).mtimeMs; } catch { continue; }
const id = byKey.get(f);
try {
if (id) {
const v = library[id];
if (v.mtime === mtime && v.file) continue; // unchanged
const dest = `lib-${id}.${extOf(f)}`;
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
if (v.file && v.file !== dest) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
v.file = dest; v.mtime = mtime; changed = true; // keep any in-app rename/birthday
} else {
const nid = newId();
const dest = `lib-${nid}.${extOf(f)}`;
fs.copyFileSync(path.join(photoFolder, f), path.join(photoDir, dest));
library[nid] = { name: cleanNameFromFile(f), file: dest, birthday: '', folderKey: f, mtime };
changed = true;
}
} catch (err) { log(`folder import of ${f} failed: ${err?.message || err}`); }
}
for (const [id, v] of Object.entries(library)) {
if (v.folderKey && !present.has(v.folderKey)) {
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
delete library[id];
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
changed = true;
}
}
// De-duplicate: a hand-uploaded photo that shares a name with a folder photo is dropped
// (the folder wins), and any slot it filled repoints to the folder photo. Fixes the case
// where someone uploaded people by hand and then pointed at a folder of the same people.
const folderByName = new Map();
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.name) folderByName.set(normName(v.name), id);
for (const [id, v] of Object.entries(library)) {
if (v.folderKey || !v.name) continue;
const keepId = folderByName.get(normName(v.name));
if (!keepId) continue;
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
delete library[id];
changed = true;
}
if (changed) { saveLibrary(); saveBoard(); }
return changed;
}
// Import a public Google Drive folder into the library (file name → name), keeping it in sync.
// Keyed by Drive file id (folderKey "drive:<id>"). On a list failure we keep the last-good library.
async function scanDrive(folderId) {
let files;
try { files = await driveList(folderId); } catch (err) { log(`Drive list failed: ${err?.message || err}`); return false; }
files = files.filter((f) => IMG_RE.test(f.name));
const present = new Set(files.map((f) => `drive:${f.id}`));
const have = new Set();
for (const v of Object.values(library)) if (v.folderKey && v.folderKey.startsWith('drive:')) have.add(v.folderKey);
let changed = false;
for (const f of files) {
const key = `drive:${f.id}`;
if (have.has(key)) continue; // already imported (by Drive file id)
let img;
try { img = await driveFetchImage(f.id); } catch (err) { log(`Drive image ${f.name} failed: ${err?.message || err}`); continue; }
const nid = newId();
const dest = `lib-${nid}.${img.ext}`;
try { fs.writeFileSync(path.join(photoDir, dest), img.buf); library[nid] = { name: cleanNameFromFile(f.name), file: dest, birthday: '', folderKey: key }; changed = true; }
catch (err) { log(`write ${dest} failed: ${err?.message || err}`); }
}
for (const [id, v] of Object.entries(library)) {
if (v.folderKey && v.folderKey.startsWith('drive:') && !present.has(v.folderKey)) {
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
delete library[id];
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) delete board[slot];
changed = true;
}
}
// Drive photo wins over a same-named hand-upload (repoint any slot to it).
const driveByName = new Map();
for (const [id, v] of Object.entries(library)) if (v.folderKey && v.folderKey.startsWith('drive:') && v.name) driveByName.set(normName(v.name), id);
for (const [id, v] of Object.entries(library)) {
if ((v.folderKey && v.folderKey.startsWith('drive:')) || !v.name) continue;
const keepId = driveByName.get(normName(v.name));
if (!keepId) continue;
if (v.file) { try { fs.unlinkSync(path.join(photoDir, v.file)); } catch { /* ignore */ } }
for (const [slot, b] of Object.entries(board)) if (b && b.personId === id) b.personId = keepId;
delete library[id]; changed = true;
}
if (changed) { saveLibrary(); saveBoard(); }
return changed;
}
// Import the configured photo source — a Drive folder link, or a local folder path.
async function syncPhotos() {
const fid = parseDriveFolderId(photoFolder);
if (fid) return scanDrive(fid);
if (photoFolder) return scanFolder();
return false;
}
function startWatch() {
if (parseDriveFolderId(photoFolder)) return; // Drive folders are polled, not fs.watched
if (!photoFolder) return;
try {
watcher = fs.watch(photoFolder, { persistent: false }, () => {
clearTimeout(watchDebounce);
watchDebounce = setTimeout(() => { if (!stopped && scanFolder()) rebuildLocal(); }, 800);
});
log(`watching photo folder ${photoFolder}`);
} catch (err) { log(`cannot watch photo folder: ${err?.message || err}`); }
}
function setChoir(on) { choirOn = Boolean(on); settings.choir = choirOn; saveSettings(); rebuildLocal(); }
// Remove cached PCO photos no longer referenced. Library files (lib-*) are never pruned here.
function prunePcoPhotos(keep) {
let files; try { files = fs.readdirSync(photoDir); } catch { return; }
for (const f of files) { if (f.startsWith('pco-') && !keep.has(f)) { try { fs.unlinkSync(path.join(photoDir, f)); } catch { /* ignore */ } } }
}
const normName = (s) => String(s || '').trim().toLowerCase().replace(/\s+/g, ' ');
// Resolve one board slot to a display person (or null). The board is the source of truth —
// there is no live PCO overlay, so clearing a slot stays cleared until the next Sync. A PCO
// entry prefers a same-named library photo, so uploading a photo updates the board without a re-sync.
function resolveSlot(i, weekSet, keep) {
const e = board[String(i)];
if (!e) return null;
let name = '', file = '', birthday = '', manual = true, personId = '';
if (e.pco) {
// A synced person. Their library photo (if any) is read live, so replacing that photo
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
manual = false; personId = e.pco.personId || '';
name = String(e.pco.name || '').trim();
const lib = e.pco.libId ? library[e.pco.libId] : null;
file = (lib && lib.file) || e.pco.file || '';
birthday = e.pco.birthday || (lib && lib.birthday) || '';
} else if (e.personId) {
const lib = library[e.personId];
if (!lib) return null; // its library photo was deleted → slot is empty
personId = e.personId;
name = String(e.name || lib.name || '').trim();
file = lib.file || ''; birthday = lib.birthday || '';
} else if (e.name) {
name = String(e.name).trim();
} else return null;
if (file) keep.add(file);
return { name, photo: photoUrlFor(file), birthday, celebrating: Boolean(birthday) && weekSet.has(birthday), manual, personId };
}
// Repaint the display from the board (no Planning Center calls). Cheap — used after every edit
// and on a slow timer so a birthday week rolling over lights up on its own.
function renderBoard() {
const weekSet = birthdayWeekSet();
const keep = new Set();
const outSlots = effectiveSlots().map((label, i) => ({ index: i, label, person: resolveSlot(i, weekSet, keep) }));
for (const v of Object.values(library)) if (v.file) keep.add(v.file);
prunePcoPhotos(keep);
latest = {
configured: pco.configured, serviceTypeSet: Boolean(serviceTypeId),
reachable: latest.reachable, lastError: latest.lastError || '', lastFetched: latest.lastFetched || 0,
columns, plan: latest.plan || null, slots: outSlots, library: libraryList(), choir: choirOn,
};
}
function rebuildLocal() { renderBoard(); broadcast(); }
// Pull the current plan from Planning Center and write matched people INTO the board. Fills only
// slots nobody placed by hand; drops PCO entries no longer scheduled; leaves manual placements be.
async function syncNow() {
if (syncing) return;
syncing = true;
try {
await syncPhotos(); // import the photo source (Drive folder or local path) on every sync
if (pco.configured && serviceTypeId) {
const plan = pickPlan(await listPlans(serviceTypeId));
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
if (!plan) { latest.reachable = true; latest.lastError = 'No plan found for that service type.'; }
else {
const tm = await pco.getAll(`${SERVICES}/service_types/${encodeURIComponent(serviceTypeId)}/plans/${encodeURIComponent(plan.id)}/team_members`, { include: 'person,team' });
const byKey = new Map((tm.included || []).map((e) => [`${e.type}:${e.id}`, e]));
const members = []; const seen = new Set();
for (const m of tm.data || []) {
const status = String(m.attributes?.status || '').trim();
if (status === 'D' || /^declined$/i.test(status)) continue;
const personId = String(m.relationships?.person?.data?.id || '');
const team = byKey.get(`Team:${String(m.relationships?.team?.data?.id || '')}`);
if (!isAllowedTeamName(String(team?.attributes?.name || ''), teamKeywords)) continue;
const person = byKey.get(`Person:${personId}`);
members.push({
personId,
name: String(person?.attributes?.full_name || person?.attributes?.name || m.attributes?.name || 'Unknown').trim() || 'Unknown',
label: String(m.attributes?.team_position_name || '').trim(),
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
photoUrl: String(person?.attributes?.photo_url || person?.attributes?.photo_thumbnail_url || '').trim(),
});
if (personId) seen.add(personId);
}
const bdays = new Map();
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) || '';
// 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 was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
// from before placements were stamped) is stale: Sync replaces it like any synced slot.
const eff = effectiveSlots();
const planId = String(plan.id);
const openIdx = []; const manualNames = new Set();
for (let i = 0; i < eff.length; i += 1) {
const e = board[String(i)];
if (e && (e.personId || e.name || e.pco) && e.plan === planId) {
const nm = e.pco ? e.pco.name : e.personId ? (library[e.personId] && library[e.personId].name) : e.name;
if (nm) manualNames.add(normName(nm));
} else openIdx.push(i);
}
const pool = members.filter((m) => !manualNames.has(normName(m.name)));
const assigned = assignMembersToSlots(openIdx.map((i) => eff[i]), pool);
for (let k = 0; k < openIdx.length; k += 1) {
const i = openIdx[k]; const m = assigned[k];
if (m) {
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
const file = lib ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
board[String(i)] = { pco: { name: m.name, file, libId: lib ? lib.id : '', birthday: m.birthday || '', personId: m.personId, position: m.label } };
} else if (board[String(i)]) {
delete board[String(i)]; // synced or stale, and no longer on the plan
}
}
saveBoard();
latest.reachable = true; latest.lastError = ''; latest.lastFetched = Date.now();
log(`synced ${members.length} scheduled from ${plan.dates || plan.id}`);
} // end else (plan found)
} // end if (Planning Center configured)
} catch (err) {
latest.reachable = false; latest.lastError = err?.message || String(err);
log(`sync failed: ${err?.message || err}`);
} finally {
syncing = false;
renderBoard(); broadcast();
}
}
const heartbeat = setInterval(() => { for (const res of streams) { try { res.write(': ping\n\n'); } catch { streams.delete(res); } } }, HEARTBEAT_MS);
heartbeat.unref?.();
// Repaint on a slow timer so a birthday week (Wed→Tue) rolling over lights up unattended.
celebrateTimer = setInterval(() => { if (!stopped) rebuildLocal(); }, 15 * 60 * 1000);
celebrateTimer.unref?.();
// A Drive folder can't be fs.watched — re-import it on a slow timer so photos added/removed
// in Drive show up without a manual Sync.
drivePollTimer = setInterval(() => {
if (stopped || !parseDriveFolderId(photoFolder)) return;
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch(() => {});
}, 5 * 60 * 1000);
drivePollTimer.unref?.();
if (pco.configured && serviceTypeId) log('ready — press Sync to load the plan from Planning Center');
else log('manual board (add Planning Center credentials + a service type in /admin to enable Sync)');
rebuildLocal(); // paint the saved board immediately
startWatch(); // local folders keep in sync via fs.watch (Drive folders are polled)
syncPhotos().then((ch) => { if (ch) rebuildLocal(); }).catch((err) => log(`initial photo import failed: ${err?.message || err}`));
current = {
streams, getLatest: () => latest, pco, listPlans, photoDir,
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
planId: () => (latest.plan && latest.plan.id) || '',
rememberPhoto,
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
photoUrlFor, libraryList,
setChoir, choirOn: () => choirOn,
};
return {
stop() {
stopped = true; if (celebrateTimer) clearInterval(celebrateTimer); if (drivePollTimer) clearInterval(drivePollTimer); clearInterval(heartbeat);
if (watcher) { try { watcher.close(); } catch { /* gone */ } watcher = null; }
if (watchDebounce) clearTimeout(watchDebounce);
for (const res of streams) { try { res.end(); } catch { /* gone */ } }
streams.clear(); if (current && current.streams === streams) current = null;
},
health() {
if (!pco.configured || !serviceTypeId) return { status: 'ok', message: 'Manual board (add Planning Center to enable Sync)' };
if (latest.lastError) return { status: 'error', message: latest.lastError };
if (latest.reachable) return { status: 'ok', message: latest.plan?.dates ? `Synced: ${latest.plan.dates}` : 'Synced' };
return { status: 'ok', message: 'Ready — press Sync to load the plan' };
},
};
},
routes({ log }) {
// Read an image request body (raw bytes) with a size cap.
function readBody(req, cb) {
const chunks = []; let size = 0; let bad = false;
req.on('data', (c) => { size += c.length; if (size > MAX_UPLOAD) { bad = true; req.destroy(); } else chunks.push(c); });
req.on('end', () => cb(bad ? null : Buffer.concat(chunks)));
req.on('error', () => cb(null));
}
return {
'GET /state': (req, res) => json(res, 200, { state: current ? current.getLatest() : 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.getLatest() : null)}\n\n`);
if (!current) return void res.end();
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
current.streams.add(res);
req.on('close', () => current && current.streams.delete(res));
},
'GET /library': (req, res) => json(res, 200, { people: current ? current.libraryList() : [] }),
// Update a library entry: ?name=&birthday= and/or a replacement image body. /library/<id>/delete removes it.
'POST /library/*': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
const del = /\/delete$/.test(rest);
const id = rest.replace(/\/delete$/, '').trim();
const entry = current.library[id];
if (!entry) return json(res, 404, { error: 'No such library photo.' });
if (del) {
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
delete current.library[id];
for (const [slot, v] of Object.entries(current.board)) if (v && v.personId === id) delete current.board[slot];
current.saveLibrary(); current.saveBoard(); current.rebuildLocal();
return json(res, 200, { ok: true });
}
const q = new URLSearchParams(req.search || '');
readBody(req, (buf) => {
if (q.has('name')) entry.name = String(q.get('name') || '').trim();
if (q.has('birthday')) entry.birthday = birthdayMmdd(q.get('birthday'));
if (buf && buf.length) {
if (entry.file) { try { fs.unlinkSync(path.join(current.photoDir, entry.file)); } catch { /* ignore */ } }
const file = `lib-${id}.${extFromContentType(req.headers['content-type'])}`;
try { fs.writeFileSync(path.join(current.photoDir, file), buf); entry.file = file; } catch (err) { return json(res, 500, { error: `write failed: ${err?.message || err}` }); }
}
current.saveLibrary(); current.rebuildLocal();
json(res, 200, { ok: true });
});
},
// Toggle the extra CHOIR slot on/off (?on=1|0, or omit to flip). Shared across all screens.
'POST /choir': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const p = new URLSearchParams(req.search || '');
const on = p.has('on') ? (p.get('on') === '1' || p.get('on') === 'true') : !current.choirOn();
current.setChoir(on);
json(res, 200, { ok: true, choir: on });
},
// Move (or swap) one slot's occupant to another slot: ?from=<i>&to=<j>.
'POST /move': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const p = new URLSearchParams(req.search || '');
const from = String(p.get('from') || ''); const to = String(p.get('to') || '');
if (!/^\d+$/.test(from) || !/^\d+$/.test(to) || from === to) return json(res, 400, { error: 'Bad move.' });
const a = current.board[from]; const b = current.board[to];
// Moving a spot is a hand placement for this plan: Sync must not move it back.
const plan = current.planId();
if (a) a.plan = plan;
if (b) b.plan = plan;
if (a === undefined) delete current.board[to]; else current.board[to] = a;
if (b === undefined) delete current.board[from]; else current.board[from] = b;
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
},
// Place a library person in a slot: ?slot=<index>&personId=<id> (empty personId clears the slot).
'POST /assign': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const q = new URLSearchParams(req.search || '');
const slot = String(q.get('slot') || '').trim();
const personId = String(q.get('personId') || '').trim();
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
const here = current.board[slot];
if (personId && here && here.pco && here.pco.personId && samePerson(here.pco.name, current.library[personId].name)) {
// A photo of the person already in this spot: it's their photo now — this week and every
// Sync after. They keep their Planning Center name and their spot.
// Remembered for this instrument only: DJ's pick on drums leaves his vocal photo alone.
current.rememberPhoto(photoKey(here.pco.personId, spotFamilies(current.slotLabel(slot), here.pco.position)), personId);
current.board[slot] = { pco: { ...here.pco, libId: personId, file: current.library[personId].file }, plan: current.planId() };
} else if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
else delete current.board[slot];
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
},
// Rename a slot's occupant, or make a name-only spot (no photo). /slot/<index>/clear empties it.
'POST /slot/*': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
const rest = decodeURIComponent(req.wildcard || '').replace(/^\/+/, '');
const clearing = /\/clear$/.test(rest);
const index = rest.replace(/\/(clear|name)$/, '').trim();
if (!/^\d+$/.test(index)) return json(res, 400, { error: 'Bad slot.' });
if (clearing) { delete current.board[index]; current.saveBoard(); current.rebuildLocal(); return json(res, 200, { ok: true }); }
const q = new URLSearchParams(req.search || '');
const name = String(q.get('name') || '').trim();
const existing = current.board[index] || { personId: '', name: '' };
existing.name = name;
existing.plan = current.planId();
if (!existing.personId && !name) delete current.board[index];
else current.board[index] = existing;
current.saveBoard(); current.rebuildLocal();
json(res, 200, { ok: true });
},
// Force an immediate Planning Center refresh (the "Sync" button). Auto-fill also
// runs on its own timer; this just does it now.
'POST /sync': (req, res) => {
if (!current) return json(res, 503, { error: 'Module not running.' });
current.refresh(); // scans the photos folder + pulls the plan if Planning Center is configured
json(res, 200, { ok: true });
},
'GET /people': (req, res) => {
const state = current ? current.getLatest() : null;
const options = [{ value: '', label: 'Off' }];
for (const slot of state?.slots || []) if (slot.person && slot.person.name) options.push({ value: String(slot.index), label: slot.person.name });
json(res, 200, { options });
},
'GET /photo/*': (req, res) => {
if (!current) return void res.writeHead(404).end();
const name = path.basename(decodeURIComponent(req.wildcard || '').replace(/^\/+/, ''));
if (!name || name.includes('..')) return void res.writeHead(400).end();
fs.readFile(path.join(current.photoDir, name), (err, buf) => {
if (err) return void res.writeHead(404).end();
res.writeHead(200, { 'Content-Type': contentTypeForFile(name), 'Cache-Control': 'public, max-age=60' });
res.end(buf);
});
},
'GET /service-types': async (req, res) => {
if (!current) return json(res, 503, { options: [], error: 'Module not running.' });
if (!current.pco.configured) return json(res, 200, { options: [], error: 'No Planning Center credentials yet.' });
try {
const [folderBody, stBody] = await Promise.all([current.pco.getAll(`${SERVICES}/folders`), current.pco.getAll(`${SERVICES}/service_types`)]);
const folders = new Map();
for (const f of folderBody.data || []) folders.set(String(f.id), { name: String(f.attributes?.name || 'Folder'), parentId: f.relationships?.parent?.data?.id ? String(f.relationships.parent.data.id) : '' });
const pathOf = (parentId) => { const names = []; let id = parentId; const seen = new Set(); while (id && folders.has(id) && !seen.has(id)) { seen.add(id); names.unshift(folders.get(id).name); id = folders.get(id).parentId; } return names.join(' › '); };
const options = (stBody.data || []).map((st) => ({ value: String(st.id), label: String(st.attributes?.name || 'Service type'), group: pathOf(st.relationships?.parent?.data?.id ? String(st.relationships.parent.data.id) : '') })).sort((a, b) => a.group.localeCompare(b.group) || a.label.localeCompare(b.label));
json(res, 200, { options });
} catch (err) { log(`service type list failed: ${err?.message || err}`); json(res, err?.status === 401 ? 401 : 502, { options: [], error: err?.message || String(err) }); }
},
'GET /plans': async (req, res) => {
if (!current) return json(res, 503, { options: [] });
const params = new URLSearchParams(req.search || '');
const stId = String(params.get('serviceTypeId') || '').trim();
const auto = { value: '', label: 'Automatic — today\'s plan, else the next upcoming' };
if (!current.pco.configured || !stId) return json(res, 200, { options: [auto] });
try {
const plans = await current.listPlans(stId); const now = Date.now();
const ordered = [...plans.filter((p) => p.sortDate >= now - 12 * 3600000).sort((a, b) => a.sortDate - b.sortDate), ...plans.filter((p) => p.sortDate < now - 12 * 3600000).sort((a, b) => b.sortDate - a.sortDate)];
json(res, 200, { options: [auto, ...ordered.map((p) => ({ value: p.id, label: [p.dates, p.title].filter(Boolean).join(' — ') || p.id }))] });
} catch (err) { json(res, 502, { options: [auto], error: err?.message || String(err) }); }
},
};
},
};