Add csv upload, make instruments table expandable

This commit is contained in:
Ian Keane 2026-06-11 14:12:46 -04:00
parent c439c2a6ba
commit e85a4ca987
3 changed files with 265 additions and 6 deletions

174
app.js
View file

@ -202,6 +202,17 @@ $('btn-quick-add-tuning').addEventListener('click', async () => {
});
});
$('btn-quick-add-instrument').addEventListener('click', async () => {
const item = await quickAdd('/instruments', 'New instrument name:', allInstruments, null);
if (item) {
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name;
instrumentSelect.appendChild(opt);
instrumentSelect.value = item.id;
}
});
// ── Resolve name → id ─────────────────────────────────────────
function resolveSourceId(name) {
if (!name) return null;
@ -1028,6 +1039,11 @@ $('btn-open-bulk-refs').addEventListener('click', () => {
openBulkRefsModal();
});
$('btn-upload-csv').addEventListener('click', () => {
bulkEditPopover.hidden = true;
openUploadCsvModal();
});
$('btn-bulk-refs-close').addEventListener('click', () => bulkRefsModal.close());
['bulk-refs-search', 'bulk-refs-instrument', 'bulk-refs-source',
@ -1208,6 +1224,164 @@ function esc(str) {
.replace(/"/g, '"');
}
// ── CSV upload ────────────────────────────────────────────────
const csvFileInput = $('csv-file-input');
csvFileInput.addEventListener('change', () => {
$('btn-csv-import').disabled = !csvFileInput.files.length;
$('csv-results').hidden = true;
$('csv-results').innerHTML = '';
});
$('btn-csv-cancel').addEventListener('click', () => $('upload-csv-modal').close());
$('btn-csv-import').addEventListener('click', async () => {
const file = csvFileInput.files[0];
if (!file) return;
$('btn-csv-import').disabled = true;
$('btn-csv-cancel').textContent = 'Close';
const resultsEl = $('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 importCSVRows(rows);
await loadAll();
let html = `<p class="csv-ok">✓ ${results.created} row${results.created !== 1 ? 's' : ''} imported.</p>`;
if (results.skipped) html += `<p class="csv-status">${results.skipped} skipped (no name).</p>`;
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-csv-import').disabled = false;
}
});
function openUploadCsvModal() {
csvFileInput.value = '';
$('btn-csv-import').disabled = true;
$('btn-csv-cancel').textContent = 'Cancel';
$('csv-results').hidden = true;
$('csv-results').innerHTML = '';
$('csv-format-details').open = true;
$('upload-csv-modal').showModal();
}
function parseCSVLine(line) {
const result = [];
let current = '', inQuotes = false;
for (let i = 0; i < line.length; i++) {
const ch = line[i];
if (ch === '"') {
if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
else inQuotes = !inQuotes;
} else if (ch === ',' && !inQuotes) {
result.push(current); current = '';
} else {
current += ch;
}
}
result.push(current);
return result;
}
function parseCSV(text) {
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());
if (lines.length < 2) return [];
const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/\s+/g, '_'));
return lines.slice(1)
.map(line => {
const vals = parseCSVLine(line);
const row = {};
headers.forEach((h, i) => { row[h] = (vals[i] ?? '').trim(); });
return row;
})
.filter(row => Object.values(row).some(v => v));
}
async function importCSVRows(rows) {
// Per-import caches so the same name doesn't trigger multiple API calls
const srcCache = new Map(); // name.lower → id
const tunCache = new Map(); // name.lower → id (tunings)
const musCache = new Map(); // name.lower → id (musicians)
const instCache = new Map(); // name.lower → id (instruments)
const tuneCache = new Map(); // name.lower → id (tunes created this run)
const results = { created: 0, skipped: 0, errors: [] };
async function resolveOrCreate(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.name?.trim();
if (!tuneName) { results.skipped++; continue; }
try {
const sourceId = await resolveOrCreate('/sources/', allSources, srcCache, row.source?.trim() || null);
const tuningId = await resolveOrCreate('/tunings/', allTunings, tunCache, row.tuning?.trim() || null);
const learnedFromId = await resolveOrCreate('/musicians/', allMusicians, musCache, row.learned_from?.trim() || null);
const instId = await resolveOrCreate('/instruments', allInstruments, instCache, row.instrument?.trim() || null);
// Find existing tune or create new
const lower = tuneName.toLowerCase();
let tuneId = tuneCache.get(lower)
?? allTunes.find(t => (t.name ?? '').toLowerCase() === lower)?.id;
if (!tuneId) {
const modal = row.modal?.toLowerCase() === 'true' || row.modal === '1';
const created = await apiFetch('/tunes/', {
method: 'POST',
body: JSON.stringify({ name: tuneName, key: row.key?.trim() || null, modal, source_id: sourceId }),
});
tuneId = created.id;
tuneCache.set(lower, tuneId);
}
// Add instrument entry (ignore 409 = already exists for that instrument)
if (instId) {
const status = ['callable', 'review', 'to_learn'].includes(row.status?.trim()) ? row.status.trim() : null;
const difficulty = ['easy', 'hard'].includes(row.difficulty?.trim()?.toLowerCase()) ? row.difficulty.trim().toLowerCase() : null;
try {
await apiFetch(`/tunes/${tuneId}/instruments`, {
method: 'POST',
body: JSON.stringify({
instrument_id: instId,
tuning_id: tuningId,
learned_from_id: learnedFromId,
date_learned: row.date_learned?.trim() || null,
status,
difficulty,
}),
});
} catch (e) {
if (!e.message.startsWith('409')) throw e;
}
}
results.created++;
} catch (e) {
results.errors.push({ name: tuneName, error: e.message });
}
}
return results;
}
// ── Mobile truncation tooltip ───────────────────────────────────
{
const tip = $('mob-tooltip');