50 lines
7.8 KiB
JavaScript
50 lines
7.8 KiB
JavaScript
/* 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);
|