'use strict'; /** * Slot ↔ Planning Center position matching for the Band Lineup module. * * A "slot" is a mic/pack label the operator wrote for a spot on stage, e.g. * "[LEADER, VOX 1, ACOUSTIC] MIX 1". A scheduled person carries their PCO * team-position name, e.g. "Vox 1" or "Acoustic / Worship Leader". We assign * people to slots by loose token overlap — the same logic the backstage * Electron app used, lifted here unchanged so the board matches what the booth * is used to. Pure functions only: no I/O, so this file is unit-testable. */ function normalizeTeamName(value) { return String(value || '').trim().toLowerCase(); } /** Does a person's PCO team count as a band/stage team we display? `keywords` comes from admin config. */ function isAllowedTeamName(value, keywords) { const normalized = normalizeTeamName(value); if (!normalized) return false; return (keywords || []).some((kw) => { const k = normalizeTeamName(kw); return k && (normalized === k || normalized.includes(k)); }); } function normalizeLabel(value) { return String(value || '') .trim() .toLowerCase() .replace(/[^a-z0-9]+/g, ' ') .replace(/\s+/g, ' ') .trim(); } /** Fold a single token to a canonical instrument/role word (drops mic/pack noise, pluralises down). */ function normalizeToken(token) { const normalized = String(token || '').trim().toLowerCase(); if (!normalized) return ''; if (/^\d+$/.test(normalized)) return ''; if (['mix', 'mic', 'slot', 'channel', 'ch', 'pack', 'wireless', 'input', 'headset'].includes(normalized)) return ''; if (['vox', 'vocal', 'vocalist', 'vocalists', 'vocals', 'singer', 'singers'].includes(normalized)) return 'vocals'; if (['keys', 'keyboard', 'keyboards'].includes(normalized)) return 'keys'; if (['drum', 'drums', 'drummer', 'drummers'].includes(normalized)) return 'drums'; if (['perc', 'percussionist', 'percussionists'].includes(normalized)) return 'percussion'; if (['gtr', 'guitar', 'guitars'].includes(normalized)) return 'guitar'; if (['electric', 'electricguitar'].includes(normalized)) return 'electric'; if (['acoustic', 'acousticguitar'].includes(normalized)) return 'acoustic'; if (['b', 'bandleader', 'leader', 'lead'].includes(normalized)) return normalized === 'b' ? '' : 'leader'; if (['worship', 'calltoworship'].includes(normalized)) return normalized === 'calltoworship' ? 'call' : normalized; if (normalized.endsWith('s') && normalized.length > 4) return normalized.slice(0, -1); return normalized; } function labelTokens(value) { const normalized = normalizeLabel(value); if (!normalized) return []; const tokens = normalized.split(' ').map((t) => normalizeToken(t)).filter(Boolean); if (!tokens.length && normalized) return [normalized]; return [...new Set(tokens)]; } /** * The role part of a slot label to match on: the bracketed content when present * ("[VOX 2] MIX 2" → "VOX 2, "), else the whole label ("STAGE MANAGER"). This * drops the "MIX n / MC n / HEADSET" mic-assignment noise so a mic number can't * be mistaken for a position number. */ function slotRoleText(label) { const brackets = String(label || '').match(/\[([^\]]*)\]/g); if (brackets && brackets.length) return brackets.map((b) => b.slice(1, -1)).join(', '); return String(label || ''); } /** The set of standalone numbers in a label ("vox 2" → {"2"}). */ function numbersIn(value) { return new Set(normalizeLabel(value).split(' ').filter((t) => /^\d+$/.test(t))); } /** 0–~140 similarity between a slot label and a PCO position name; higher is a better fit. */ function scoreMatch(slotLabel, positionLabel) { const slotNormalized = normalizeLabel(slotLabel); const positionNormalized = normalizeLabel(positionLabel); if (!slotNormalized || !positionNormalized) return 0; // A numbered role only matches the same number: "Vox 3" must not take "Vox 2". const slotNums = numbersIn(slotLabel); const posNums = numbersIn(positionLabel); if (slotNums.size && posNums.size && ![...posNums].some((n) => slotNums.has(n))) return 0; if (slotNormalized === positionNormalized) return 120; if (slotNormalized.includes(positionNormalized) || positionNormalized.includes(slotNormalized)) return 95; const slotTokens = labelTokens(slotLabel); const positionTokens = labelTokens(positionLabel); if (!slotTokens.length || !positionTokens.length) return 0; const positionSet = new Set(positionTokens); const slotSet = new Set(slotTokens); const overlap = slotTokens.filter((t) => positionSet.has(t)); if (!overlap.length) return 0; const overlapCount = overlap.length; const slotCoverage = overlapCount / slotSet.size; const positionCoverage = overlapCount / positionSet.size; const sharedPhrase = overlap.join(' '); let score = Math.round(slotCoverage * 70 + positionCoverage * 20 + overlapCount * 4); if (slotCoverage === 1) score += 18; if (positionCoverage === 1) score += 10; if (sharedPhrase && slotNormalized.includes(sharedPhrase) && positionNormalized.includes(sharedPhrase)) score += 8; return score; } /** * Assign scheduled members to the ordered slot list. Greedy by best score, each * member used once. Returns an array aligned to `slots`, each entry the chosen * member or null. `MIN_SCORE` keeps a weak coincidence from claiming a slot. */ const MIN_SCORE = 60; /** The mix numbers a label names ("[VOX 5] MIX 8" → {"8"}; "Mix 8, VOX 5" → {"8"}). */ function mixNumbers(value) { const out = new Set(); const re = /\bmix\s*(\d+)\b/gi; let m; while ((m = re.exec(String(value || '')))) out.add(m[1]); return out; } /** * How well a person's Planning Center note names a spot. The booth writes the mic/role into each * vocalist's and the leader's note every week ("Mix 8, VOX 5", "DP48, Drums"), because those * change week to week and the position name ("Vocalist") doesn't say which mic. A note that names a * different mix than the spot is never a match; the role part ("VOX 5") is scored like a position. * 0 means the note says nothing usable about this spot ("NA", blank, or no overlap). */ function noteScore(slotLabel, note) { const text = String(note || '').trim(); if (!text || /^n\/?a$/i.test(text)) return 0; const slotMix = mixNumbers(slotLabel); const noteMix = mixNumbers(text); let mixBonus = 0; if (noteMix.size && slotMix.size) { if (![...noteMix].some((n) => slotMix.has(n))) return 0; mixBonus = 100; } const role = text.replace(/\bmix\s*\d+\b/gi, ' '); const roleScore = scoreMatch(slotRoleText(slotLabel), role); if (!roleScore && !mixBonus) return 0; if (noteMix.size && slotMix.size && roleScore === 0 && role.replace(/[^a-z0-9]/gi, '')) { // The mix agrees but the role contradicts it (e.g. "Mix 8, Keys" on a VOX spot) — trust neither. const roleTokens = labelTokens(role); if (roleTokens.length) return 0; } return mixBonus + roleScore; } function assignMembersToSlots(slots, members) { const pool = members.slice(); const result = slots.map(() => null); const take = (chosen) => { // One spot per person: someone scheduled twice (Leader + Acoustic) must not fill a second spot. const key = chosen.personId || ''; for (let i = pool.length - 1; i >= 0; i -= 1) if (pool[i] === chosen || (key && pool[i].personId === key)) pool.splice(i, 1); }; // Pass 1 — people whose note names their spot go exactly there, best-fitting pairs first. const pairs = []; slots.forEach((slotLabel, s) => pool.forEach((m) => { const sc = noteScore(slotLabel, m.note); if (sc >= MIN_SCORE) pairs.push({ s, m, sc }); })); pairs.sort((a, b) => b.sc - a.sc); for (const { s, m } of pairs) { if (result[s] || !pool.includes(m)) continue; result[s] = m; take(m); } // Pass 2 — everyone else by Planning Center position, into the spots still open. return result.map((placed, s) => { if (placed) return placed; const slotLabel = slots[s]; const role = slotRoleText(slotLabel); let bestIndex = -1; let bestScore = MIN_SCORE - 1; for (let i = 0; i < pool.length; i += 1) { const score = scoreMatch(role, pool[i].label); if (score > bestScore) { bestScore = score; bestIndex = i; } } if (bestIndex < 0) return null; const chosen = pool[bestIndex]; take(chosen); return chosen; }); } /* ── 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 }]. `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, want) { const target = normalizeLabel(pcoName); if (!target) return null; const T = target.split(' ').filter(Boolean); const scoreOf = (name) => { const ln = normalizeLabel(name); if (!ln) return 0; if (ln === target) return 100; // exact full name const L = ln.split(' ').filter(Boolean); if (!L.length || !T.length) return 0; // Align the library name against a leading run of the person's name, token by token. // Every library token must equal the person's token in that position — except the LAST // library token, which may be an initial/prefix ("m" of "Moniz"). This makes "Brian M" // match "Brian Moniz" and "Alex" match "Alex Gaitan", but NOT "Alex" → "Alexander Zinni". let ok = true; let exact = 0; let usedPrefix = false; for (let k = 0; k < L.length; k += 1) { const lt = L[k]; const tt = T[k]; if (!tt) { ok = false; break; } if (lt === tt) { exact += 1; continue; } if (k === L.length - 1 && tt.startsWith(lt)) { usedPrefix = true; continue; } ok = false; break; } if (ok) { if (L.length >= 2) return usedPrefix ? 86 : 92; // "brian m" / "alex gaitan" if (exact === 1) return 90; // first name matches exactly ("alex") if (usedPrefix) return 45; // "alex" vs "alexander" — too ambiguous } // Otherwise: a library surname that appears anywhere in the person's name. const last = L[L.length - 1]; 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 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 (!best) return null; // no confident match → PCO photo return best; } /** * Could this library photo be of this Planning Center person? Used when someone drops a photo onto * a synced person's spot, to tell "this is their photo" (remember it) from "put someone else here". * A name match, the same first name ("DJ action" → "DJ McLean"), or first names sharing their * first three letters (a nickname: "Rose" → "Rosaliz"). */ function samePerson(pcoName, libName) { if (bestLibraryMatch(pcoName, [{ name: libName }])) return true; 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, normalizeLabel, normalizeToken, labelTokens, slotRoleText, numbersIn, scoreMatch, assignMembersToSlots, noteScore, mixNumbers, MIN_SCORE, };