Import worship setlists with confirmed song matches and keys

This commit is contained in:
Jonathan Sykes
2026-10-03 15:02:33 +08:00
parent de89508bf4
commit 172e8231f0
7 changed files with 68 additions and 0 deletions

View File

@@ -10954,6 +10954,7 @@ function wireUI() {
// ---- Empty-home playlist grid ----
$('homePlNewBtn').addEventListener('click', () => newPlaylist(null));
const importSetlist = document.createElement('button'); importSetlist.type = 'button'; importSetlist.className = 'icon-btn'; importSetlist.textContent = '⇥'; importSetlist.title = 'Import setlist'; importSetlist.setAttribute('aria-label', 'Import setlist'); importSetlist.onclick = () => SetlistImport.open(); els.newPlaylistBtn.after(importSetlist);
$('homePlGrid').addEventListener('scroll', updateHomeGridScrollState, { passive: true });
window.addEventListener('resize', updateHomeGridScrollState);
@@ -12454,6 +12455,7 @@ async function boot() {
if (WEB) bootShareFromUrl();
Party.boot();
PartyDJ.register(() => $('navPartyBtn').click());
SetlistImport.configure({ library: async () => { let uploads = []; try { const j = await webFetch('/api/uploads?limit=200'); uploads = j.uploads || []; } catch {} return [...data.history, ...data.playlists.flatMap(p => p.videos || []), ...uploads]; }, search: async q => (await API.search(q)).results || [], modal: showModal, close: closeModal, create: (name, videos) => { const pl = { id: uid(), name, videos: videos.map(v => ({ ...slim(v), tags: v.tags, key: v.key, chordPro: v.chordPro })) }; data.playlists.push(pl); persist(); view = { type: 'playlist', id: pl.id }; render(); toast(`Imported ${videos.length} songs`); } });
Remote.boot();
LowerThird.register(() => $('remoteChip').click());
Transition.renderButton();

View File

@@ -661,6 +661,7 @@
<script src="midi.js"></script>
<script src="lower-third.js"></script>
<script src="vocal-reducer.js"></script>
<script src="setlist-import.js"></script>
<script src="app.js"></script>
</body>
</html>

View File

@@ -0,0 +1,49 @@
/* Planning Center CSV, pasted setlists and ChordPro/SongSelect text imports. */
(function(root) {
'use strict';
function csv(text) { const rows = []; let row = [], field = '', quoted = false; for (let i = 0; i < text.length; i++) { const c = text[i]; if (c === '"') { if (quoted && text[i + 1] === '"') { field += '"'; i++; } else quoted = !quoted; } else if (!quoted && (c === ',' || c === '\n')) { row.push(field.trim()); field = ''; if (c === '\n') { rows.push(row); row = []; } } else if (c !== '\r') field += c; } row.push(field.trim()); if (row.some(Boolean)) rows.push(row); return rows; }
const normalize = value => String(value || '').normalize('NFKD').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
function score(title, candidate) { const a = normalize(title), b = normalize(candidate); if (a && a === b) return 1; const x = new Set(a.split(' ').filter(Boolean)), y = new Set(b.split(' ').filter(Boolean)); const overlap = [...x].filter(word => y.has(word)).length; return overlap / Math.max(1, new Set([...x, ...y]).size); }
function matches(title, library) { return [...new Map(library.filter(v => v?.id).map(v => [v.id, v])).values()].map(video => ({ video, score: score(title, video.title) })).filter(v => v.score >= .45).sort((a,b) => b.score - a.score).slice(0, 8).map(v => v.video); }
function parse(text) {
text = String(text || '').replace(/^\uFEFF/, '').trim(); if (!text) return [];
const rows = csv(text), header = rows[0].map(normalize), titleCol = header.findIndex(v => ['title','song','song title','name'].includes(v));
if (titleCol >= 0 && rows[0].length > 1) { const keyCol = header.indexOf('key'), orderCol = header.indexOf('order'); return rows.slice(1).filter(row => row[titleCol]).map((row,i) => ({ title: row[titleCol], key: row[keyCol] || '', order: Number(row[orderCol]) || i + 1 })).sort((a,b) => a.order - b.order); }
if (/\{(?:title|t):|^Title\s*:/im.test(text)) {
const blocks = text.split(/(?=^\s*(?:\{(?:title|t):|Title\s*:))/im).filter(v => v.trim());
return blocks.map((block,i) => { const title = block.match(/\{(?:title|t):\s*([^}]+)\}|^Title\s*:\s*(.+)$/im), key = block.match(/\{key:\s*([^}]+)\}|^Key\s*:\s*(.+)$/im); return { title: (title?.[1] || title?.[2] || '').trim(), key: (key?.[1] || key?.[2] || '').trim(), order: i + 1, chordPro: block.trim() }; }).filter(v => v.title);
}
return text.split(/\r?\n/).filter(v => v.trim()).map((line,i) => { const key = line.match(/\s*(?:\[([A-G][#b]?(?:m|maj|min)?)\]|\|\s*([A-G][#b]?(?:m|maj|min)?))\s*$/); return { title: line.replace(/^\s*\d+[.)]\s*/, '').replace(key?.[0] || /$^/, '').trim(), key: key?.[1] || key?.[2] || '', order: i + 1 }; }).filter(v => v.title);
}
let adapter;
function open() {
const doc = root.document, body = doc.createElement('div'); body.className = 'setlist-import';
const name = doc.createElement('input'); name.placeholder = 'Setlist name'; name.value = 'Imported setlist'; name.setAttribute('aria-label', 'Imported setlist name');
const text = doc.createElement('textarea'); text.rows = 8; text.placeholder = 'Paste title, key, order CSV; a song per line; or ChordPro / SongSelect text'; text.setAttribute('aria-label', 'Setlist text');
const file = doc.createElement('input'); file.type = 'file'; file.accept = '.txt,.csv,.cho,.chopro,.pro,.chordpro,text/plain,text/csv'; file.setAttribute('aria-label', 'Choose a setlist text file'); file.onchange = async () => { if (file.files[0]) { if (file.files[0].size > 1048576) { status.textContent = 'Choose a text file smaller than 1 MB.'; return; } text.value = await file.files[0].text(); } };
const status = doc.createElement('p'); status.setAttribute('role', 'status'); const results = doc.createElement('div'); let selections = [], busy = false;
body.append(name, text, file, status, results);
async function review() {
if (busy) return; const entries = parse(text.value); if (!entries.length) { status.textContent = 'No songs found.'; return; } if (entries.length > 100) { status.textContent = 'Import at most 100 songs at a time.'; return; }
busy = true; status.textContent = 'Finding matches…'; results.replaceChildren(); selections = [];
try {
const library = await adapter.library();
for (const entry of entries) {
if (!body.isConnected) return; const row = doc.createElement('div'); row.className = 'download-job'; const title = doc.createElement('b'); title.textContent = `${entry.order}. ${entry.title}${entry.key ? ` · Key ${entry.key}` : ''}`;
const select = doc.createElement('select'); select.setAttribute('aria-label', `Match ${entry.title}`); let candidates = matches(entry.title, library);
if (!candidates.length) { try { candidates = await adapter.search(entry.title); } catch {} }
const skip = doc.createElement('option'); skip.value = ''; skip.textContent = 'Skip this song'; select.append(skip);
function append(videos) { for (const video of videos.slice(0, 10)) if (!candidates.some(v => v.id === video.id) || ![...select.options].some(o => o.value === video.id)) { const option = doc.createElement('option'); option.value = video.id; option.textContent = `${video.title || video.id} · ${video.channel || ''}`; select.append(option); if (!candidates.some(v => v.id === video.id)) candidates.push(video); } }
append(candidates); if (candidates[0] && score(entry.title, candidates[0].title) === 1) select.value = candidates[0].id;
const searchInput = doc.createElement('input'); searchInput.value = entry.title; searchInput.setAttribute('aria-label', `Search match for ${entry.title}`); const search = doc.createElement('button'); search.type = 'button'; search.textContent = 'Search YouTube'; search.onclick = async () => { search.disabled = true; try { append(await adapter.search(searchInput.value.trim())); } catch { status.textContent = 'Search failed. Try again.'; } finally { search.disabled = false; } };
selections.push(() => { const video = candidates.find(v => v.id === select.value); return video ? { ...video, tags: [...new Set([...(video.tags || []), ...(entry.key ? [`Key ${entry.key}`] : [])])], key: entry.key, ...(entry.chordPro ? { chordPro: entry.chordPro } : {}) } : null; });
row.append(title, select, searchInput, search); results.append(row);
}
status.textContent = 'Review each match, then create the setlist. Unselected songs are skipped.';
} catch { status.textContent = 'Could not load the library.'; } finally { busy = false; }
}
adapter.modal('Import setlist', body, [{ label: 'Cancel', onClick: adapter.close }, { label: 'Find matches', onClick: review }, { label: 'Create setlist', primary: true, onClick: () => { if (busy) return; const videos = selections.map(select => select()).filter(Boolean); if (!videos.length) { status.textContent = 'Choose at least one song first.'; return; } adapter.create(name.value.trim() || 'Imported setlist', videos); adapter.close(); } }]);
}
function configure(options) { adapter = options; const anchor = root.document.getElementById('homePlNewBtn'); if (anchor) { const b = root.document.createElement('button'); b.type = 'button'; b.className = 'home-pl-new'; b.textContent = 'Import setlist'; b.onclick = open; anchor.after(b); } root.SettingsSections.register({ id: 'setlist-import', title: 'Setlist import', cluster: 'Library & storage', summary: () => 'Planning Center · SongSelect · ChordPro', render(container) { const b = container.ownerDocument.createElement('button'); b.type = 'button'; b.className = 'settings-back'; b.textContent = 'Import setlist'; b.onclick = open; container.append(b); } }); }
const api = { parse, csv, matches, score, configure, open }; if (typeof module !== 'undefined') module.exports = api; else root.SetlistImport = api;
})(typeof window !== 'undefined' ? window : globalThis);

View File

@@ -0,0 +1,4 @@
const { test } = require('node:test'), assert = require('node:assert/strict'); const { parse, matches, csv } = require('./setlist-import');
test('Planning Center CSV supports quoted titles and order', () => { assert.deepEqual(parse('Title,Key,Order\n"Grace, Amazing",E,2\nFirst,G,1').map(v => [v.title,v.key]), [['First','G'],['Grace, Amazing','E']]); assert.deepEqual(csv('Title,Key\n"A ""quoted"" song",D')[1], ['A "quoted" song','D']); });
test('ChordPro and SongSelect preserve chord text and key', () => { const [a,b] = parse('{title: Grace}\n{key: E}\n[E]Amazing [A]grace\n{title: Mercy}\n{key: D}\n[D]Mercy'); assert.equal(a.title,'Grace'); assert.equal(a.key,'E'); assert.match(a.chordPro, /\[A\]grace/); assert.equal(b.title,'Mercy'); assert.equal(parse('Title: Song\nKey: Bb\nWords')[0].key,'Bb'); });
test('pasted setlists strip numbering and attach key tags; matching deduplicates library', () => { assert.equal(parse('1. Song [E]\n2. Mercy | F#')[1].key,'F#'); assert.deepEqual(matches('Amazing Grace', [{id:'a',title:'Amazing Grace'}, {id:'a',title:'Amazing Grace'}, {id:'b',title:'Other'}]).map(v => v.id), ['a']); });

View File

@@ -36,3 +36,6 @@
.party-dj-row b, .party-dj-row small { display: block; overflow-wrap: anywhere; }
.party-dj-row small { color: var(--text-2); margin-block: 6px; }
.party-dj-row button, .party-dj-search button, .party-dj-search + div button { min-height: 44px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; padding: 8px 12px; margin: 4px; }
.setlist-import { display: grid; gap: 12px; }
.setlist-import input, .setlist-import textarea, .setlist-import select { width: 100%; min-width: 0; min-height: 44px; padding: 10px; background: var(--bg-2); color: var(--text); border: 1px solid var(--line); border-radius: 8px; }
.setlist-import .download-job > button { min-height: 44px; }

View File

@@ -89,6 +89,7 @@ const SHELL = [
'/midi.js',
'/lower-third.js',
'/vocal-reducer.js',
'/setlist-import.js',
'/lower-third.css',
'/settings-sections.css',
'/app.js',