2026-09-22 09:15:22 -04:00
|
|
|
|
'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');
|
|
|
|
|
|
|
2026-10-20 13:28:49 -04:00
|
|
|
|
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson } = require('./matching');
|
2026-09-22 09:15:22 -04:00
|
|
|
|
|
|
|
|
|
|
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
|
2026-10-20 13:28:49 -04:00
|
|
|
|
// The photo the booth picked for a Planning Center person (PCO person id → library id), 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(pcoPersonId, libId) {
|
|
|
|
|
|
if (!pcoPersonId || !libId || settings.photoFor[pcoPersonId] === libId) return;
|
|
|
|
|
|
settings.photoFor[pcoPersonId] = libId; saveSettings();
|
|
|
|
|
|
}
|
|
|
|
|
|
// Library photo for a scheduled person: their remembered pick, else the best name match.
|
|
|
|
|
|
function libraryPhotoFor(personId, name) {
|
|
|
|
|
|
const picked = settings.photoFor[personId];
|
|
|
|
|
|
if (picked && library[picked] && library[picked].file) return { id: picked, ...library[picked] };
|
|
|
|
|
|
const m = bestLibraryMatch(name, Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file })));
|
|
|
|
|
|
return m && m.file ? m : null;
|
|
|
|
|
|
}
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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,
|
2026-10-13 11:03:31 -04:00
|
|
|
|
plan: settings.lastPlan || null, // the last synced plan, so hand placements after a restart stamp right
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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, ' ').trim();
|
|
|
|
|
|
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) {
|
2026-10-20 13:28:49 -04:00
|
|
|
|
// 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).
|
2026-09-22 09:15:22 -04:00
|
|
|
|
manual = false; personId = e.pco.personId || '';
|
|
|
|
|
|
name = String(e.pco.name || '').trim();
|
2026-10-20 13:28:49 -04:00
|
|
|
|
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
|
|
|
|
|
file = (lib && lib.file) || e.pco.file || '';
|
|
|
|
|
|
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
2026-09-22 09:15:22 -04:00
|
|
|
|
} 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));
|
2026-10-13 11:03:31 -04:00
|
|
|
|
latest.plan = plan ? { id: String(plan.id), title: plan.title, dates: plan.dates } : null;
|
|
|
|
|
|
if (plan) { settings.lastPlan = latest.plan; saveSettings(); }
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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(),
|
2026-10-20 13:14:39 -04:00
|
|
|
|
note: String(m.attributes?.notes || '').trim(), // "Mix 8, VOX 5" — where the booth wants them this week
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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) || '';
|
|
|
|
|
|
|
2026-10-20 13:28:49 -04:00
|
|
|
|
// 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(m.personId, e.personId);
|
|
|
|
|
|
board[i] = { pco: { name: m.name, file: library[e.personId].file, libId: e.personId, birthday: m.birthday || '', personId: m.personId }, plan: e.plan };
|
|
|
|
|
|
}
|
2026-09-22 09:15:22 -04:00
|
|
|
|
|
2026-10-13 11:03:31 -04:00
|
|
|
|
// 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.
|
2026-09-22 09:15:22 -04:00
|
|
|
|
const eff = effectiveSlots();
|
2026-10-13 11:03:31 -04:00
|
|
|
|
const planId = String(plan.id);
|
2026-09-22 09:15:22 -04:00
|
|
|
|
const openIdx = []; const manualNames = new Set();
|
|
|
|
|
|
for (let i = 0; i < eff.length; i += 1) {
|
|
|
|
|
|
const e = board[String(i)];
|
2026-10-20 13:11:56 -04:00
|
|
|
|
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;
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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) {
|
2026-10-20 13:28:49 -04:00
|
|
|
|
const lib = libraryPhotoFor(m.personId, m.name); // the booth's own photos win over the PCO headshot
|
|
|
|
|
|
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 } };
|
2026-10-13 11:03:31 -04:00
|
|
|
|
} else if (board[String(i)]) {
|
|
|
|
|
|
delete board[String(i)]; // synced or stale, and no longer on the plan
|
2026-09-22 09:15:22 -04:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
2026-10-13 11:03:31 -04:00
|
|
|
|
planId: () => (latest.plan && latest.plan.id) || '',
|
2026-10-20 13:28:49 -04:00
|
|
|
|
rememberPhoto,
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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];
|
2026-10-20 13:11:56 -04:00
|
|
|
|
// 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;
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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.' });
|
2026-10-20 13:28:49 -04:00
|
|
|
|
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.
|
|
|
|
|
|
current.rememberPhoto(here.pco.personId, 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() };
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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;
|
2026-10-13 11:03:31 -04:00
|
|
|
|
existing.plan = current.planId();
|
2026-09-22 09:15:22 -04:00
|
|
|
|
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) }); }
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|