'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; function assignMembersToSlots(slots, members) { const pool = members.slice(); return slots.map((slotLabel) => { 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; return pool.splice(bestIndex, 1)[0]; }); } /** * 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 there's no confident, UNAMBIGUOUS * match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used. * `entries` is [{ ...whatever, name }]. */ function bestLibraryMatch(pcoName, entries) { 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; }; let best = null; let bestScore = 0; let tie = false; for (const e of entries) { const s = scoreOf(e.name); if (s > bestScore) { bestScore = s; best = e; tie = false; } else if (s === bestScore && s > 0) tie = true; } if (bestScore < 60 || tie) return null; // no confident / unambiguous winner → PCO photo return best; } module.exports = { normalizeTeamName, isAllowedTeamName, bestLibraryMatch, normalizeLabel, normalizeToken, labelTokens, slotRoleText, numbersIn, scoreMatch, assignMembersToSlots, MIN_SCORE, };