From 172e8231f0e6c00f8e2d8f76afb1fa85b88ab068 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Sat, 3 Oct 2026 15:02:33 +0800 Subject: [PATCH] Import worship setlists with confirmed song matches and keys --- frontend/app.js | 2 ++ frontend/index.html | 1 + frontend/setlist-import.js | 49 +++++++++++++++++++++++++++++++++ frontend/setlist-import.test.js | 4 +++ frontend/settings-sections.css | 3 ++ frontend/sw.js | 1 + tests/settings-sections.spec.js | 8 ++++++ 7 files changed, 68 insertions(+) create mode 100644 frontend/setlist-import.js create mode 100644 frontend/setlist-import.test.js diff --git a/frontend/app.js b/frontend/app.js index 129bf93..0c213a2 100755 --- a/frontend/app.js +++ b/frontend/app.js @@ -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(); diff --git a/frontend/index.html b/frontend/index.html index de15de2..3fded4c 100755 --- a/frontend/index.html +++ b/frontend/index.html @@ -661,6 +661,7 @@ + diff --git a/frontend/setlist-import.js b/frontend/setlist-import.js new file mode 100644 index 0000000..108e6cb --- /dev/null +++ b/frontend/setlist-import.js @@ -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); diff --git a/frontend/setlist-import.test.js b/frontend/setlist-import.test.js new file mode 100644 index 0000000..0c1738b --- /dev/null +++ b/frontend/setlist-import.test.js @@ -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']); }); diff --git a/frontend/settings-sections.css b/frontend/settings-sections.css index 85b677c..a87cb3f 100644 --- a/frontend/settings-sections.css +++ b/frontend/settings-sections.css @@ -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; } diff --git a/frontend/sw.js b/frontend/sw.js index 6a743f5..6d9f516 100644 --- a/frontend/sw.js +++ b/frontend/sw.js @@ -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', diff --git a/tests/settings-sections.spec.js b/tests/settings-sections.spec.js index 260e56b..32c0745 100644 --- a/tests/settings-sections.spec.js +++ b/tests/settings-sections.spec.js @@ -41,3 +41,11 @@ test('OBS lower third has a transparent background and renders two cues', async expect(await page.evaluate(() => getComputedStyle(document.body).backgroundColor)).toBe('rgba(0, 0, 0, 0)'); await expect(page.locator('#searchInput')).toBeHidden(); }); +test('setlist import confirms library matches and retains key tags', async ({ page }) => { + await openClassic(page, { populated: true }); await page.evaluate(() => SetlistImport.open()); + await page.getByLabel('Setlist text', { exact: true }).fill('1. A quiet morning — live studio session [E]'); + await page.getByRole('button', { name: 'Find matches', exact: true }).click(); + await expect(page.getByLabel('Match A quiet morning — live studio session', { exact: true })).toHaveValue('preview0000'); + await page.getByRole('button', { name: 'Create setlist', exact: true }).click(); + expect(await page.evaluate(() => data.playlists.at(-1).videos[0].tags)).toContain('Key E'); +});