Add CSV upload for references

This commit is contained in:
Ian Keane 2026-06-12 10:15:43 -04:00
parent b426020a76
commit 46f2a303dc
2 changed files with 143 additions and 1 deletions

111
app.js
View file

@ -1557,6 +1557,117 @@ async function importCSVRows(rows) {
return results; return results;
} }
// ── References CSV upload ──────────────────────────────────
const refsCsvFileInput = $('refs-csv-file-input');
$('btn-upload-refs-csv').addEventListener('click', () => {
bulkEditPopover.hidden = true;
openUploadRefsCsvModal();
});
refsCsvFileInput.addEventListener('change', () => {
$('btn-refs-csv-import').disabled = !refsCsvFileInput.files.length;
$('refs-csv-results').hidden = true;
$('refs-csv-results').innerHTML = '';
});
$('btn-refs-csv-cancel').addEventListener('click', () => $('upload-refs-csv-modal').close());
$('btn-refs-csv-import').addEventListener('click', async () => {
const file = refsCsvFileInput.files[0];
if (!file) return;
$('btn-refs-csv-import').disabled = true;
$('btn-refs-csv-cancel').textContent = 'Close';
const resultsEl = $('refs-csv-results');
resultsEl.hidden = false;
resultsEl.innerHTML = '<p class="csv-status">Importing…</p>';
try {
const text = await file.text();
const rows = parseCSV(text);
if (!rows.length) { resultsEl.innerHTML = '<p class="csv-error">No data rows found.</p>'; return; }
const results = await importRefsCsvRows(rows);
await loadAll();
let html = `<p class="csv-ok">✓ ${results.created} reference${results.created !== 1 ? 's' : ''} imported.</p>`;
if (results.warnings.length) {
html += `<p class="csv-error">${results.warnings.length} skipped — tune not found:</p><ul class="csv-errors">`;
results.warnings.forEach(w => { html += `<li>${esc(w)}</li>`; });
html += '</ul>';
}
if (results.errors.length) {
html += `<p class="csv-error">${results.errors.length} error${results.errors.length !== 1 ? 's' : ''}:</p><ul class="csv-errors">`;
results.errors.forEach(e => { html += `<li>${esc(e.name)}: ${esc(e.error)}</li>`; });
html += '</ul>';
}
resultsEl.innerHTML = html;
} catch (e) {
resultsEl.innerHTML = `<p class="csv-error">Failed: ${esc(e.message)}</p>`;
$('btn-refs-csv-import').disabled = false;
}
});
function openUploadRefsCsvModal() {
refsCsvFileInput.value = '';
$('btn-refs-csv-import').disabled = true;
$('btn-refs-csv-cancel').textContent = 'Cancel';
$('refs-csv-results').hidden = true;
$('refs-csv-results').innerHTML = '';
$('refs-csv-format-details').open = true;
$('upload-refs-csv-modal').showModal();
}
async function importRefsCsvRows(rows) {
const siteCache = new Map();
const musCache = new Map();
const results = { created: 0, warnings: [], errors: [] };
async function resolve(endpoint, list, cache, name) {
if (!name) return null;
const lower = name.toLowerCase();
if (cache.has(lower)) return cache.get(lower);
const existing = list.find(x => x.name.toLowerCase() === lower);
if (existing) { cache.set(lower, existing.id); return existing.id; }
const item = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ name }) });
list.push(item);
cache.set(lower, item.id);
return item.id;
}
for (const row of rows) {
const tuneName = row.tune_name?.trim();
if (!tuneName) continue;
const tune = allTunes.find(t => (t.name ?? '').toLowerCase() === tuneName.toLowerCase());
if (!tune) {
results.warnings.push(tuneName);
continue;
}
try {
const link = row.link?.trim() || null;
const siteId = await resolve('/reference_sites/', allReferenceSites, siteCache, row.site?.trim() || null);
const musicians = [];
for (const key of ['musician1', 'musician2', 'musician3', 'musician4']) {
const name = row[key]?.trim();
if (!name) continue;
const id = await resolve('/musicians/', allMusicians, musCache, name);
if (id) musicians.push({ id });
}
if (!link && !siteId && musicians.length === 0) continue;
await apiFetch(`/tunes/${tune.id}/references`, {
method: 'POST',
body: JSON.stringify({ link, site_id: siteId, musicians }),
});
results.created++;
} catch (e) {
results.errors.push({ name: tuneName, error: e.message });
}
}
return results;
}
// ── Mobile truncation tooltip ─────────────────────────────────── // ── Mobile truncation tooltip ───────────────────────────────────
{ {
const tip = $('mob-tooltip'); const tip = $('mob-tooltip');

View file

@ -51,7 +51,8 @@
<!-- ── Bulk edit popover ─────────────────────────────────────── --> <!-- ── Bulk edit popover ─────────────────────────────────────── -->
<div id="bulk-edit-popover" hidden> <div id="bulk-edit-popover" hidden>
<button id="btn-open-bulk-refs">📎 Bulk Edit References</button> <button id="btn-open-bulk-refs">📎 Bulk Edit References</button>
<button id="btn-upload-csv">📂 Upload from CSV</button> <button id="btn-upload-csv">📂 Upload Tunes from CSV</button>
<button id="btn-upload-refs-csv">🔗 Upload References from CSV</button>
</div> </div>
<!-- ── Bulk edit references modal ───────────────────────────── --> <!-- ── Bulk edit references modal ───────────────────────────── -->
@ -255,6 +256,36 @@
</div> </div>
</dialog> </dialog>
<!-- ── References CSV upload modal ───────────────────────────── -->
<dialog id="upload-refs-csv-modal">
<h2>Upload References from CSV</h2>
<details id="refs-csv-format-details" open>
<summary>Expected format</summary>
<p style="font-size:.85rem;margin:.5rem 0 .4rem">One reference per row. Tune matched by name — must already exist.</p>
<table class="csv-format-table">
<thead><tr><th>Column</th><th>Required</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>tune_name</td><td>Yes</td><td>Must match an existing tune (case-insensitive)</td></tr>
<tr><td>link</td><td></td><td>URL</td></tr>
<tr><td>site</td><td></td><td>Site name — created if new</td></tr>
<tr><td>musician1</td><td></td><td>Musician name — created if new</td></tr>
<tr><td>musician2</td><td></td><td></td></tr>
<tr><td>musician3</td><td></td><td></td></tr>
<tr><td>musician4</td><td></td><td></td></tr>
</tbody>
</table>
<p class="csv-format-note">Rows where the tune name is not found are skipped with a warning in the results.</p>
</details>
<div id="refs-csv-upload-area">
<input type="file" id="refs-csv-file-input" accept=".csv,.tsv,text/csv" />
</div>
<div id="refs-csv-results" hidden></div>
<div class="modal-actions">
<button id="btn-refs-csv-import" disabled>Import</button>
<button id="btn-refs-csv-cancel">Cancel</button>
</div>
</dialog>
<!-- Tooltip for truncated mobile text --> <!-- Tooltip for truncated mobile text -->
<div id="mob-tooltip" hidden></div> <div id="mob-tooltip" hidden></div>