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:
@@ -186,15 +186,60 @@ function assignMembersToSlots(slots, members) {
|
||||
});
|
||||
}
|
||||
|
||||
/* ── photos by instrument ──────────────────────────────────────────────
|
||||
* A person can have a photo per instrument, named "Name - Instrument" ("DJ - Drums.jpg",
|
||||
* "DJ - Vocals.jpg"); a photo with no instrument word is their everyday one. Sync picks the photo
|
||||
* whose instrument matches the spot the person is in that week. Instruments group into families so
|
||||
* the usual shorthands agree: Vox/Vocals/Singer, Keys/Piano/Pad/Synth, Drums/Perc, Bass,
|
||||
* Guitar/Electric/Acoustic/Rhythm, Leader. */
|
||||
const ROLE_FAMILY = {
|
||||
vocals: 'vocals', keys: 'keys', piano: 'keys', pad: 'keys', synth: 'keys', organ: 'keys',
|
||||
drums: 'drums', percussion: 'drums', bass: 'bass',
|
||||
guitar: 'guitar', electric: 'guitar', acoustic: 'guitar', rhythm: 'guitar', leader: 'leader',
|
||||
};
|
||||
/** The instrument families a label names ("[LEADER, VOX 1, ACOUSTIC] MIX 1" → leader, vocals, guitar). */
|
||||
function roleFamilies(text) {
|
||||
const out = new Set();
|
||||
for (const t of normalizeLabel(text).split(' ')) { const f = ROLE_FAMILY[normalizeToken(t)]; if (f) out.add(f); }
|
||||
return out;
|
||||
}
|
||||
/** The instrument families a SPOT is for: its bracketed role, else the PCO position it was filled by. */
|
||||
function spotFamilies(slotLabel, positionLabel) {
|
||||
const fromSlot = roleFamilies(slotRoleText(slotLabel));
|
||||
return fromSlot.size ? fromSlot : roleFamilies(positionLabel || '');
|
||||
}
|
||||
/** Split a photo's name into the person and the instruments it shows: "DJ Drums" → "DJ" + {drums}.
|
||||
* The first word is always the person; instrument words after it are read off, other words
|
||||
* ("action", "2") are just part of the photo's description. */
|
||||
function photoParts(libName) {
|
||||
const words = normalizeLabel(libName).split(' ').filter(Boolean);
|
||||
const roles = new Set(); const person = [];
|
||||
words.forEach((w, i) => {
|
||||
const f = i > 0 ? ROLE_FAMILY[normalizeToken(w)] : null;
|
||||
if (f) roles.add(f); else if (!roles.size) person.push(w);
|
||||
});
|
||||
return { person: person.join(' '), roles };
|
||||
}
|
||||
/** The key a remembered photo pick is stored under: per person AND instrument, so DJ's drum pick
|
||||
* never overrides his vocal photo. A spot naming several ("Leader, Vox 1, Acoustic") files under
|
||||
* its most specific: leader, then vocals, drums, keys, bass, guitar. No family → everyday pick. */
|
||||
const ROLE_PRIORITY = ['leader', 'vocals', 'drums', 'keys', 'bass', 'guitar'];
|
||||
function photoKey(personId, families) {
|
||||
const f = ROLE_PRIORITY.find((r) => families && families.has(r)) || '';
|
||||
return f ? `${personId}|${f}` : String(personId || '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Best library photo for a Planning Center person, matched by name so Sync can use the folder
|
||||
* photo instead of the PCO headshot. Handles full names, "First L" and first-name-only files,
|
||||
* preferring the most specific. Returns the entry, or null when no library photo is a confident
|
||||
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
|
||||
* not a reason to fall back to PCO: the first of them wins (the booth prefers its own photos).
|
||||
* `entries` is [{ ...whatever, name }].
|
||||
* `entries` is [{ ...whatever, name }]. `want` (optional) is the set of instrument families the
|
||||
* person is on this week: a photo for that instrument beats their everyday photo, which beats a
|
||||
* photo of them on something else — but any photo of theirs still beats PCO's headshot.
|
||||
*/
|
||||
function bestLibraryMatch(pcoName, entries) {
|
||||
function bestLibraryMatch(pcoName, entries, want) {
|
||||
const target = normalizeLabel(pcoName);
|
||||
if (!target) return null;
|
||||
const T = target.split(' ').filter(Boolean);
|
||||
@@ -226,13 +271,17 @@ function bestLibraryMatch(pcoName, entries) {
|
||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||
return 0;
|
||||
};
|
||||
const wanted = want && want.size ? want : null;
|
||||
let best = null;
|
||||
let bestScore = 0;
|
||||
for (const e of entries) {
|
||||
const s = scoreOf(e.name);
|
||||
const parts = photoParts(e.name);
|
||||
let s = scoreOf(parts.roles.size ? parts.person : e.name);
|
||||
if (s < 60) continue; // not them
|
||||
if (parts.roles.size && wanted) s += [...parts.roles].some((f) => wanted.has(f)) ? 20 : -20;
|
||||
if (s > bestScore) { bestScore = s; best = e; }
|
||||
}
|
||||
if (bestScore < 60) return null; // no confident match → PCO photo
|
||||
if (!best) return null; // no confident match → PCO photo
|
||||
return best;
|
||||
}
|
||||
|
||||
@@ -244,13 +293,17 @@ function bestLibraryMatch(pcoName, entries) {
|
||||
*/
|
||||
function samePerson(pcoName, libName) {
|
||||
if (bestLibraryMatch(pcoName, [{ name: libName }])) return true;
|
||||
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = normalizeLabel(libName).split(' ')[0] || '';
|
||||
const a = normalizeLabel(pcoName).split(' ')[0] || ''; const b = photoParts(libName).person.split(' ')[0] || '';
|
||||
if (a && a === b) return true;
|
||||
return a.length >= 3 && b.length >= 3 && a.slice(0, 3) === b.slice(0, 3);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
samePerson,
|
||||
roleFamilies,
|
||||
spotFamilies,
|
||||
photoParts,
|
||||
photoKey,
|
||||
normalizeTeamName,
|
||||
isAllowedTeamName,
|
||||
bestLibraryMatch,
|
||||
|
||||
@@ -116,3 +116,28 @@ console.log('assignment:', JSON.stringify(out));
|
||||
assert.ok(!samePerson('Al Smith', 'Alex'), 'two-letter names never match by prefix');
|
||||
console.log('samePerson: all assertions passed ✓');
|
||||
}
|
||||
|
||||
// --- a photo per instrument: "DJ - Drums", "DJ - Vocals", plain "DJ" for everything else ---
|
||||
{
|
||||
const { bestLibraryMatch, photoParts, spotFamilies, roleFamilies, photoKey } = require('./matching');
|
||||
// File names arrive with the dash turned to a space: "DJ - Drums.jpg" → "DJ Drums".
|
||||
const lib = [{ id: 'e', name: 'DJ' }, { id: 'd', name: 'DJ Drums' }, { id: 'v', name: 'DJ Vocals' }, { id: 'k', name: 'Kevin Piano' }, { id: 'm', name: 'Mike Powell Leader' }];
|
||||
const pick = (name, slot, pos) => { const e = bestLibraryMatch(name, lib, spotFamilies(slot, pos)); return e ? e.id : null; };
|
||||
assert.strictEqual(pick('DJ McLean', '[DRUMS]', 'Drums'), 'd', 'on drums → drum photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[VOX 3] MIX 3', 'Vocalist'), 'v', 'on vocals → vocal photo');
|
||||
assert.strictEqual(pick('DJ McLean', '[KEYS 2]', 'Keys 2'), 'e', 'on keys, no keys photo → everyday photo');
|
||||
assert.strictEqual(pick('DJ McLean', 'STAGE MANAGER', 'Stage Manager'), 'e', 'no instrument → everyday photo');
|
||||
assert.strictEqual(pick('Kevin Michel', '[KEYS 2]', 'Keys 2'), 'k', 'Piano and Keys are one family');
|
||||
assert.strictEqual(pick('Kevin Michel', '[DRUMS]', 'Drums'), 'k', 'only photo is on piano → still his own photo, not PCO');
|
||||
assert.strictEqual(pick('Mike Powell', '[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]'), 'm', 'full name + instrument');
|
||||
assert.strictEqual(pick('Craig Strok', '[DRUMS]', 'Drums'), null, 'nobody else\'s instrument photo is borrowed');
|
||||
assert.deepStrictEqual([...photoParts('DJ Drums action').roles], ['drums'], 'extra words after the instrument are ignored');
|
||||
assert.strictEqual(photoParts('Joshua P').roles.size, 0, 'a plain name has no instrument');
|
||||
assert.strictEqual(photoParts('Keys').person, 'keys', 'the first word is always the person');
|
||||
assert.deepStrictEqual([...spotFamilies('[BASS] MIX 6', 'Guitar [Bass]')], ['bass'], 'the spot decides, not the position');
|
||||
assert.deepStrictEqual([...roleFamilies('Guitar [Rhythm]')], ['guitar'], 'rhythm guitar is guitar');
|
||||
assert.strictEqual(photoKey('dj', new Set(['drums'])), 'dj|drums');
|
||||
assert.strictEqual(photoKey('dj', new Set()), 'dj');
|
||||
assert.strictEqual(photoKey('mp', spotFamilies('[LEADER, VOX 1, ACOUSTIC] MIX 1', '[Leader]')), 'mp|leader', 'leader spot files under leader');
|
||||
console.log('photos by instrument: all assertions passed ✓');
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
|
||||
Reference in New Issue
Block a user