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>
This commit is contained in:
2026-10-20 13:40:00 -04:00
co-authored by Claude Opus 5.5
parent e59629694e
commit 1fd7dcdf1e
4 changed files with 121 additions and 28 deletions
+28 -18
View File
@@ -32,7 +32,7 @@ const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson } = require('./matching');
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(/\/+$/, '');
@@ -219,19 +219,26 @@ module.exports = {
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 (PCO person id → library id), learned
// when a library photo is dropped onto that person. Sync uses it before any name matching.
// 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(pcoPersonId, libId) {
if (!pcoPersonId || !libId || settings.photoFor[pcoPersonId] === libId) return;
settings.photoFor[pcoPersonId] = libId; saveSettings();
function rememberPhoto(key, libId) {
if (!key || !libId || settings.photoFor[key] === libId) return;
settings.photoFor[key] = 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;
// 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}`); } }
@@ -327,7 +334,7 @@ module.exports = {
* 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();
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;
@@ -546,8 +553,8 @@ module.exports = {
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 };
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
@@ -569,9 +576,10 @@ module.exports = {
for (let k = 0; k < openIdx.length; k += 1) {
const i = openIdx[k]; const m = assigned[k];
if (m) {
const lib = libraryPhotoFor(m.personId, m.name); // the booth's own photos win over the PCO headshot
// 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 } };
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
}
@@ -614,6 +622,7 @@ module.exports = {
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,
};
@@ -726,7 +735,8 @@ module.exports = {
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);
// 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];