diff --git a/README.md b/README.md index eae34dd..56bcd19 100644 --- a/README.md +++ b/README.md @@ -525,17 +525,22 @@ below. Nothing is installed without someone clicking. name search) scrolling below — drag a photo onto a slot (mouse, or press and hold on a touchscreen), drag between slots to swap, rename or clear. Dropping a photo onto the person already in a spot makes it *their* photo - (a nickname like "Rose" for Rosaliz counts): they keep their Planning Center - name and spot, and every later Sync uses that photo for them. + for that instrument (a nickname like "Rose" for Rosaliz counts): they keep + their Planning Center name and spot, and every later Sync uses it whenever + they are on that instrument. Planning Center is optional auto-fill on top, not the source of truth: **Sync** reads the upcoming plan's team members and places each one. A person whose Planning Center note names their mic ("Mix 8, VOX 5") goes to that slot — this is how the leader and vocalists, whose position is just "Vocalist", land on the right mic — and everyone else goes to the slot matching their position, one slot per person. Photos come from the library - whenever it has one of them — their remembered pick, else the best name - match, even when several photos fit — and Planning Center's headshot only - when it has none. Anything placed or moved by + whenever it has one of them, and Planning Center's headshot only when it + has none. Someone who plays more than one thing can have a photo per + instrument, named `Name - Instrument` (`DJ - Drums.jpg`, `DJ - Vocals.jpg`; + Vox/Vocals, Keys/Piano, Drums, Bass, Guitar/Electric/Acoustic, Leader), and + a plain `DJ.jpg` for everything else: Sync uses the one for the spot they + are in that week. A photo dropped onto someone is remembered for that + instrument only. Anything placed or moved by hand for that plan stays put through later syncs; last week's people are replaced. Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and diff --git a/modules/band-lineup/matching.js b/modules/band-lineup/matching.js index 550c13f..b2d7261 100644 --- a/modules/band-lineup/matching.js +++ b/modules/band-lineup/matching.js @@ -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, diff --git a/modules/band-lineup/matching.test.js b/modules/band-lineup/matching.test.js index 933ae79..1d65579 100644 --- a/modules/band-lineup/matching.test.js +++ b/modules/band-lineup/matching.test.js @@ -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 ✓'); +} diff --git a/modules/band-lineup/server.js b/modules/band-lineup/server.js index e3aed0f..60a6f63 100644 --- a/modules/band-lineup/server.js +++ b/modules/band-lineup/server.js @@ -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 ("|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];