Band Lineup 1.13.0: a photo per instrument, your own photos win, spots stay in reach while dragging #53
@@ -521,14 +521,26 @@ below. Nothing is installed without someone clicking.
|
|||||||
A photo library fills it: point the module at a Google Drive folder shared
|
A photo library fills it: point the module at a Google Drive folder shared
|
||||||
"anyone with the link" (file name → person's name) or at a folder on the
|
"anyone with the link" (file name → person's name) or at a folder on the
|
||||||
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
ProdDash machine, and photos added or removed there follow. **Edit** in the
|
||||||
title bar opens that library — drag a photo onto a slot (mouse, or press and
|
title bar opens that library — the spots pinned on top, the photos (with a
|
||||||
|
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.
|
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
|
||||||
|
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:
|
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
|
**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
|
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
|
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
|
"Vocalist", land on the right mic — and everyone else goes to the slot
|
||||||
matching their position, one slot per person. Anything placed or moved by
|
matching their position, one slot per person. Photos come from the library
|
||||||
|
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
|
hand for that plan stays put through later syncs; last week's people are
|
||||||
replaced.
|
replaced.
|
||||||
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
Birthdays in the service week (Wednesday→Tuesday) bring a gold glow and
|
||||||
|
|||||||
@@ -45,9 +45,10 @@ export default function create({ root, moduleApi }) {
|
|||||||
moveDrag(p.x, p.y);
|
moveDrag(p.x, p.y);
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
if (!drag) return;
|
if (!drag) return;
|
||||||
const b = drag.body;
|
// Auto-scroll whichever pane (spots or photos) the pointer is near the edge of.
|
||||||
if (b && b.isConnected) {
|
const b = [...root.querySelectorAll('.bl-pane')].find((pane) => { const r = pane.getBoundingClientRect(); return drag.x >= r.left && drag.x <= r.right && drag.y >= r.top && drag.y <= r.bottom; });
|
||||||
const r = b.getBoundingClientRect(); const zone = 70; let dy = 0;
|
if (b && b.scrollHeight > b.clientHeight) {
|
||||||
|
const r = b.getBoundingClientRect(); const zone = Math.min(60, r.height / 4); let dy = 0;
|
||||||
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
|
if (drag.y < r.top + zone) dy = -Math.ceil((r.top + zone - drag.y) / 4) - 2;
|
||||||
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
|
else if (drag.y > r.bottom - zone) dy = Math.ceil((drag.y - (r.bottom - zone)) / 4) + 2;
|
||||||
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
|
if (dy) { b.scrollTop += dy; moveDrag(drag.x, drag.y); }
|
||||||
@@ -163,9 +164,9 @@ export default function create({ root, moduleApi }) {
|
|||||||
if (drag) { renderHeld = true; return; }
|
if (drag) { renderHeld = true; return; }
|
||||||
stopConfetti();
|
stopConfetti();
|
||||||
stopParallax();
|
stopParallax();
|
||||||
root.innerHTML = '';
|
|
||||||
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
if (choirBtn) choirBtn.classList.toggle('active', !!(state && state.choir));
|
||||||
if (editing) { renderEditor(); return; }
|
if (editing) { renderEditor(); return; } // replaces its own content, after noting scroll + focus
|
||||||
|
root.innerHTML = '';
|
||||||
|
|
||||||
const board = el('div', 'bl-board');
|
const board = el('div', 'bl-board');
|
||||||
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
if (!state) { board.classList.add('bl-message'); board.textContent = 'Connecting…'; root.appendChild(board); return; }
|
||||||
@@ -240,11 +241,16 @@ export default function create({ root, moduleApi }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- editor ---------- */
|
/* ---------- editor ---------- */
|
||||||
|
let libFilter = ''; // the photo search box, kept across re-renders
|
||||||
function renderEditor() {
|
function renderEditor() {
|
||||||
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is just the
|
// No top bar: the photo source is managed in Admin (Google Drive link). Edit mode is two panes
|
||||||
// board on top and the picture picker (library) below it.
|
// that scroll on their own: the spots pinned on top, the photos below — so a photo found far
|
||||||
|
// down the list never has to be carried back up to its spot. Both panes keep their scroll
|
||||||
|
// position (and the search box its focus) when a board update redraws the editor.
|
||||||
|
const prevBoard = root.querySelector('.bl-board-pane'); const prevLib = root.querySelector('.bl-lib-pane');
|
||||||
|
const keep = { board: prevBoard ? prevBoard.scrollTop : 0, lib: prevLib ? prevLib.scrollTop : 0, searchFocused: document.activeElement && document.activeElement.classList && document.activeElement.classList.contains('bl-lib-search') };
|
||||||
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
const wrap = el('div', 'bl-editor bl-editor-nobar');
|
||||||
const body = el('div', 'bl-editor-body');
|
const body = el('div', 'bl-editor-body bl-editor-split');
|
||||||
|
|
||||||
// Slots (drop targets)
|
// Slots (drop targets)
|
||||||
const boardEl = el('div', 'bl-edit-board');
|
const boardEl = el('div', 'bl-edit-board');
|
||||||
@@ -267,9 +273,15 @@ export default function create({ root, moduleApi }) {
|
|||||||
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
cell.addEventListener('click', (e) => { if (suppressClick) return; if (e.target.closest('.bl-edit-name') || e.target.closest('.bl-edit-clear')) return; if (selectedLibId) assign(slot.index, selectedLibId); });
|
||||||
boardEl.appendChild(cell);
|
boardEl.appendChild(cell);
|
||||||
}
|
}
|
||||||
body.appendChild(boardEl);
|
const boardPane = el('div', 'bl-pane bl-board-pane');
|
||||||
|
boardPane.appendChild(boardEl);
|
||||||
|
body.appendChild(boardPane);
|
||||||
|
|
||||||
// Library
|
// Library, with a name search
|
||||||
|
const libPane = el('div', 'bl-pane bl-lib-pane');
|
||||||
|
const search = el('input', 'bl-lib-search');
|
||||||
|
search.type = 'search'; search.placeholder = 'Find a photo…'; search.value = libFilter;
|
||||||
|
libPane.appendChild(search);
|
||||||
const lib = el('div', 'bl-library');
|
const lib = el('div', 'bl-library');
|
||||||
const list = state?.library || [];
|
const list = state?.library || [];
|
||||||
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
if (!list.length) lib.appendChild(el('div', 'bl-lib-empty', 'No photos yet — set a Google Drive folder link in Admin, or hit Sync. Then drag a photo onto a slot.'));
|
||||||
@@ -287,11 +299,23 @@ export default function create({ root, moduleApi }) {
|
|||||||
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
item.addEventListener('click', () => { if (suppressClick) return; selectedLibId = selectedLibId === person.id ? '' : person.id; renderEditor(); });
|
||||||
lib.appendChild(item);
|
lib.appendChild(item);
|
||||||
}
|
}
|
||||||
body.appendChild(lib);
|
const applyFilter = () => {
|
||||||
|
const q = libFilter.trim().toLowerCase();
|
||||||
|
for (const item of lib.querySelectorAll('.bl-lib-item')) {
|
||||||
|
const nm = item.querySelector('.bl-lib-name');
|
||||||
|
item.style.display = !q || (nm && nm.textContent.toLowerCase().includes(q)) ? '' : 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
search.addEventListener('input', () => { libFilter = search.value; applyFilter(); });
|
||||||
|
applyFilter();
|
||||||
|
libPane.appendChild(lib);
|
||||||
|
body.appendChild(libPane);
|
||||||
wrap.appendChild(body);
|
wrap.appendChild(body);
|
||||||
|
|
||||||
root.innerHTML = '';
|
root.innerHTML = '';
|
||||||
root.appendChild(wrap);
|
root.appendChild(wrap);
|
||||||
|
boardPane.scrollTop = keep.board; libPane.scrollTop = keep.lib;
|
||||||
|
if (keep.searchFocused) { search.focus(); try { search.setSelectionRange(search.value.length, search.value.length); } catch { /* type=search */ } }
|
||||||
}
|
}
|
||||||
|
|
||||||
function assign(slotIndex, personId) {
|
function assign(slotIndex, personId) {
|
||||||
@@ -320,7 +344,7 @@ export default function create({ root, moduleApi }) {
|
|||||||
title: 'Sync',
|
title: 'Sync',
|
||||||
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
onClick() { moduleApi.setStatus('connecting', 'Syncing with Planning Center…'); api('/sync', { method: 'POST' }); },
|
||||||
});
|
});
|
||||||
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; editBtn.classList.toggle('active', editing); render(); } });
|
const editBtn = moduleApi.header.addButton({ icon: EDIT_ICON, title: 'Edit board', onClick() { editing = !editing; if (editing) libFilter = ''; editBtn.classList.toggle('active', editing); render(); } });
|
||||||
// Columns −/+ (touch-friendly; replaces the gear number field).
|
// Columns −/+ (touch-friendly; replaces the gear number field).
|
||||||
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
const MINUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M5 12h14"/></svg>';
|
||||||
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
const PLUS_ICON = '<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>';
|
||||||
|
|||||||
@@ -186,14 +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
|
* 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,
|
* 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
|
* preferring the most specific. Returns the entry, or null when no library photo is a confident
|
||||||
* match (e.g. two people share a first name and the photo is first-name-only) — then PCO's photo is used.
|
* match — only then is PCO's headshot used. Several equally good photos ("Mike" and "mike") is
|
||||||
* `entries` is [{ ...whatever, name }].
|
* 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) {
|
function bestLibraryMatch(pcoName, entries, want) {
|
||||||
const target = normalizeLabel(pcoName);
|
const target = normalizeLabel(pcoName);
|
||||||
if (!target) return null;
|
if (!target) return null;
|
||||||
const T = target.split(' ').filter(Boolean);
|
const T = target.split(' ').filter(Boolean);
|
||||||
@@ -225,19 +271,39 @@ function bestLibraryMatch(pcoName, entries) {
|
|||||||
if (last && last.length >= 3 && T.includes(last)) return 78;
|
if (last && last.length >= 3 && T.includes(last)) return 78;
|
||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
const wanted = want && want.size ? want : null;
|
||||||
let best = null;
|
let best = null;
|
||||||
let bestScore = 0;
|
let bestScore = 0;
|
||||||
let tie = false;
|
|
||||||
for (const e of entries) {
|
for (const e of entries) {
|
||||||
const s = scoreOf(e.name);
|
const parts = photoParts(e.name);
|
||||||
if (s > bestScore) { bestScore = s; best = e; tie = false; }
|
let s = scoreOf(parts.roles.size ? parts.person : e.name);
|
||||||
else if (s === bestScore && s > 0) tie = true;
|
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 || tie) return null; // no confident / unambiguous winner → PCO photo
|
if (!best) return null; // no confident match → PCO photo
|
||||||
return best;
|
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 = {
|
module.exports = {
|
||||||
|
samePerson,
|
||||||
|
roleFamilies,
|
||||||
|
spotFamilies,
|
||||||
|
photoParts,
|
||||||
|
photoKey,
|
||||||
normalizeTeamName,
|
normalizeTeamName,
|
||||||
isAllowedTeamName,
|
isAllowedTeamName,
|
||||||
bestLibraryMatch,
|
bestLibraryMatch,
|
||||||
|
|||||||
@@ -61,8 +61,9 @@ console.log('assignment:', JSON.stringify(out));
|
|||||||
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
assert.strictEqual(pick('Alex Johnson'), 'Alex', 'first-name photo maps to any exact-first-name person');
|
||||||
// exact full name beats a first-name photo
|
// exact full name beats a first-name photo
|
||||||
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
assert.strictEqual(pick('Alex Gaitan', [{ id: 'x', name: 'Alex Gaitan' }, { id: 'a', name: 'Alex' }]), 'Alex Gaitan', 'exact full name wins');
|
||||||
// tie → no match (two identical-scoring photos)
|
// two equally good photos ("Mike" and "mike") → still one of the booth's photos, never PCO's
|
||||||
assert.strictEqual(bestLibraryMatch('Chris Smith', [{ id: '1', name: 'Chris' }, { id: '2', name: 'Chris' }]), null, 'ambiguous tie → null');
|
assert.strictEqual(bestLibraryMatch('Mike Powell', [{ id: '1', name: 'Mike' }, { id: '2', name: 'mike' }]).id, '1', 'tie → first library photo');
|
||||||
|
assert.strictEqual(bestLibraryMatch('Joram Lopez', lib), null, 'no photo of them → PCO headshot');
|
||||||
console.log('bestLibraryMatch: all assertions passed ✓');
|
console.log('bestLibraryMatch: all assertions passed ✓');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,3 +104,40 @@ console.log('assignment:', JSON.stringify(out));
|
|||||||
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
|
assert.strictEqual(noteScore('[VOX 1] MIX 10', 'Mix 1, VOX 1') , 0, 'Mix 1 must not match MIX 10');
|
||||||
console.log('notes: all assertions passed ✓');
|
console.log('notes: all assertions passed ✓');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- samePerson: is a dropped photo of the person already in the spot? ---
|
||||||
|
{
|
||||||
|
const { samePerson } = require('./matching');
|
||||||
|
assert.ok(samePerson('Rosaliz Gaitan', 'Rose'), 'nickname: Rose → Rosaliz');
|
||||||
|
assert.ok(samePerson('Mike Powell', 'mike'), 'first name, any case');
|
||||||
|
assert.ok(samePerson('Mike Powell', 'Mike Powell action'), 'full name plus words');
|
||||||
|
assert.ok(samePerson('DJ McLean', 'DJ action'), 'short first name plus words');
|
||||||
|
assert.ok(!samePerson('Mike Powell', 'Craig'), 'a different person is a replacement, not their photo');
|
||||||
|
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 ✓');
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"id": "band-lineup",
|
"id": "band-lineup",
|
||||||
"name": "Band Lineup",
|
"name": "Band Lineup",
|
||||||
"version": "1.12.0",
|
"version": "1.13.0",
|
||||||
"proddash": ">=1.4.0",
|
"proddash": ">=1.4.0",
|
||||||
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
"description": "Stage lineup photos with mic/pack labels — a drag-in photo library plus optional Planning Center auto-fill",
|
||||||
"client": "client.js",
|
"client": "client.js",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ const path = require('path');
|
|||||||
const os = require('os');
|
const os = require('os');
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch } = require('./matching');
|
const { isAllowedTeamName, assignMembersToSlots, bestLibraryMatch, samePerson, spotFamilies, photoParts, photoKey } = require('./matching');
|
||||||
|
|
||||||
const DEFAULT_API_BASE = 'https://api.planningcenteronline.com';
|
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(/\/+$/, '');
|
const API_BASE = String(process.env.BAND_LINEUP_API_BASE || process.env.PCO_PLAN_API_BASE || DEFAULT_API_BASE).replace(/\/+$/, '');
|
||||||
@@ -219,6 +219,27 @@ module.exports = {
|
|||||||
const settings = readJsonFile(settingsPath, {}) || {};
|
const settings = readJsonFile(settingsPath, {}) || {};
|
||||||
const photoFolder = String(config.photoFolder || settings.photoFolder || '').trim(); // admin field (Drive link or local path)
|
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
|
let choirOn = Boolean(settings.choir); // optional extra "CHOIR" slot, toggled from the tile
|
||||||
|
// 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(key, libId) {
|
||||||
|
if (!key || !libId || settings.photoFor[key] === libId) return;
|
||||||
|
settings.photoFor[key] = libId; saveSettings();
|
||||||
|
}
|
||||||
|
// 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}`); } }
|
function saveSettings() { try { fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2)); } catch (err) { log(`save settings failed: ${err?.message || err}`); } }
|
||||||
|
|
||||||
const CHOIR_LABEL = 'CHOIR';
|
const CHOIR_LABEL = 'CHOIR';
|
||||||
@@ -313,7 +334,7 @@ module.exports = {
|
|||||||
* left alone, so a folder and hand-uploads can coexist. Returns true if anything changed.
|
* 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 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 extOf(f) { const m = f.match(IMG_RE); const e = (m ? m[1] : 'jpg').toLowerCase(); return e === 'jpeg' ? 'jpg' : e; }
|
||||||
function scanFolder() {
|
function scanFolder() {
|
||||||
if (!photoFolder) return false;
|
if (!photoFolder) return false;
|
||||||
@@ -449,11 +470,13 @@ module.exports = {
|
|||||||
if (!e) return null;
|
if (!e) return null;
|
||||||
let name = '', file = '', birthday = '', manual = true, personId = '';
|
let name = '', file = '', birthday = '', manual = true, personId = '';
|
||||||
if (e.pco) {
|
if (e.pco) {
|
||||||
// The photo (folder photo, or PCO headshot) was chosen at Sync time — use it as-is.
|
// A synced person. Their library photo (if any) is read live, so replacing that photo
|
||||||
|
// updates the board; otherwise the photo chosen at Sync time (a PCO headshot).
|
||||||
manual = false; personId = e.pco.personId || '';
|
manual = false; personId = e.pco.personId || '';
|
||||||
name = String(e.pco.name || '').trim();
|
name = String(e.pco.name || '').trim();
|
||||||
file = e.pco.file || '';
|
const lib = e.pco.libId ? library[e.pco.libId] : null;
|
||||||
birthday = e.pco.birthday || '';
|
file = (lib && lib.file) || e.pco.file || '';
|
||||||
|
birthday = e.pco.birthday || (lib && lib.birthday) || '';
|
||||||
} else if (e.personId) {
|
} else if (e.personId) {
|
||||||
const lib = library[e.personId];
|
const lib = library[e.personId];
|
||||||
if (!lib) return null; // its library photo was deleted → slot is empty
|
if (!lib) return null; // its library photo was deleted → slot is empty
|
||||||
@@ -521,7 +544,18 @@ module.exports = {
|
|||||||
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
for (const id of seen) { try { const b = await pco.get(`${PEOPLE}/people/${encodeURIComponent(id)}`, { 'fields[Person]': 'birthdate' }); bdays.set(id, birthdayMmdd(b?.data?.attributes?.birthdate)); } catch { bdays.set(id, ''); } }
|
||||||
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
for (const m of members) m.birthday = bdays.get(m.personId) || '';
|
||||||
|
|
||||||
const libEntries = Object.entries(library).map(([id, v]) => ({ id, name: v.name, file: v.file }));
|
// A library photo dragged by hand onto a spot for this plan, of someone who IS scheduled
|
||||||
|
// ("Rose" for Rosaliz Gaitan): that's their photo. Remember it, and show them under their
|
||||||
|
// Planning Center name, kept in that spot.
|
||||||
|
for (const [i, e] of Object.entries(board)) {
|
||||||
|
if (!e || !e.personId || e.plan !== String(plan.id) || !library[e.personId]) continue;
|
||||||
|
const libName = library[e.personId].name;
|
||||||
|
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(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
|
// Slots a person was placed in by hand FOR THIS PLAN — don't fill them, and don't place that
|
||||||
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
|
// same person twice. A hand placement made for an earlier plan (last week's lineup, or one
|
||||||
@@ -542,9 +576,10 @@ module.exports = {
|
|||||||
for (let k = 0; k < openIdx.length; k += 1) {
|
for (let k = 0; k < openIdx.length; k += 1) {
|
||||||
const i = openIdx[k]; const m = assigned[k];
|
const i = openIdx[k]; const m = assigned[k];
|
||||||
if (m) {
|
if (m) {
|
||||||
const lib = bestLibraryMatch(m.name, libEntries); // folder photo wins over the PCO headshot
|
// The booth's own photos win over the PCO headshot — the one for the instrument they're on.
|
||||||
const file = lib && lib.file ? lib.file : await cachePcoPhoto(m.personId, m.photoUrl);
|
const lib = libraryPhotoFor(m.personId, m.name, spotFamilies(eff[i], m.label));
|
||||||
board[String(i)] = { pco: { name: m.name, file, birthday: m.birthday || '', personId: m.personId } };
|
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, position: m.label } };
|
||||||
} else if (board[String(i)]) {
|
} else if (board[String(i)]) {
|
||||||
delete board[String(i)]; // synced or stale, and no longer on the plan
|
delete board[String(i)]; // synced or stale, and no longer on the plan
|
||||||
}
|
}
|
||||||
@@ -586,6 +621,8 @@ module.exports = {
|
|||||||
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
streams, getLatest: () => latest, pco, listPlans, photoDir,
|
||||||
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
library, board, saveLibrary, saveBoard, rebuildLocal, refresh: () => syncNow(), slots,
|
||||||
planId: () => (latest.plan && latest.plan.id) || '',
|
planId: () => (latest.plan && latest.plan.id) || '',
|
||||||
|
rememberPhoto,
|
||||||
|
slotLabel: (i) => effectiveSlots()[Number(i)] || '',
|
||||||
photoUrlFor, libraryList,
|
photoUrlFor, libraryList,
|
||||||
setChoir, choirOn: () => choirOn,
|
setChoir, choirOn: () => choirOn,
|
||||||
};
|
};
|
||||||
@@ -694,7 +731,14 @@ module.exports = {
|
|||||||
const personId = String(q.get('personId') || '').trim();
|
const personId = String(q.get('personId') || '').trim();
|
||||||
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
if (!/^\d+$/.test(slot)) return json(res, 400, { error: 'Bad slot.' });
|
||||||
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
if (personId && !current.library[personId]) return json(res, 404, { error: 'No such library photo.' });
|
||||||
if (personId) current.board[slot] = { personId, name: '', plan: current.planId() };
|
const here = current.board[slot];
|
||||||
|
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.
|
||||||
|
// 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];
|
else delete current.board[slot];
|
||||||
current.saveBoard(); current.rebuildLocal();
|
current.saveBoard(); current.rebuildLocal();
|
||||||
json(res, 200, { ok: true });
|
json(res, 200, { ok: true });
|
||||||
|
|||||||
@@ -247,3 +247,17 @@
|
|||||||
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
|
[data-module="band-lineup"] .bl-dragging { opacity: 0.35; }
|
||||||
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
|
[data-module="band-lineup"].bl-is-dragging, [data-module="band-lineup"] .bl-is-dragging { cursor: grabbing; }
|
||||||
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
|
[data-module="band-lineup"] .bl-is-dragging .bl-edit-slot { transition: box-shadow 0.08s; }
|
||||||
|
|
||||||
|
/* Editor as two panes that scroll on their own: spots pinned on top, photos below. */
|
||||||
|
[data-module="band-lineup"] .bl-editor-split { display: flex; flex-direction: column; overflow: hidden; padding: 0; }
|
||||||
|
[data-module="band-lineup"] .bl-pane { overflow: auto; overscroll-behavior: contain; padding: 8px 10px; }
|
||||||
|
[data-module="band-lineup"] .bl-board-pane { flex: 0 0 auto; max-height: 55%; border-bottom: 2px solid var(--border); }
|
||||||
|
[data-module="band-lineup"] .bl-lib-pane { flex: 1 1 0; min-height: 30%; }
|
||||||
|
[data-module="band-lineup"] .bl-lib-search {
|
||||||
|
display: block; width: 100%; box-sizing: border-box; margin: 0 0 8px; padding: 6px 10px;
|
||||||
|
border: 1px solid var(--border); border-radius: 6px; background: var(--panel); color: var(--text); font: inherit; font-size: 0.85em;
|
||||||
|
}
|
||||||
|
[data-module="band-lineup"] .bl-lib-search:focus { outline: none; border-color: var(--accent); }
|
||||||
|
[data-module="band-lineup"] .bl-board-pane .bl-edit-board { margin: 0; }
|
||||||
|
[data-module="band-lineup"] .bl-lib-pane .bl-library { border-top: 0; padding-top: 0; }
|
||||||
|
[data-module="band-lineup"] .bl-board-pane .bl-edit-photo { aspect-ratio: 1 / 1; }
|
||||||
|
|||||||
Reference in New Issue
Block a user