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>
321 lines
14 KiB
JavaScript
321 lines
14 KiB
JavaScript
'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,
|
||
};
|