diff --git a/app.js b/app.js index 32e2ac4..c4a434c 100644 --- a/app.js +++ b/app.js @@ -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 = '
Importing…
'; + try { + const text = await file.text(); + const rows = parseCSV(text); + if (!rows.length) { + resultsEl.innerHTML = 'No data rows found.
'; + return; + } + const results = await importCSVRows(rows); + await loadAll(); + let html = `✓ ${results.created} row${results.created !== 1 ? 's' : ''} imported.
`; + if (results.skipped) html += `${results.skipped} skipped (no name).
`; + if (results.errors.length) { + html += `${results.errors.length} error${results.errors.length !== 1 ? 's' : ''}:
Failed: ${esc(e.message)}
`; + $('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'); diff --git a/index.html b/index.html index 2243bfc..2e83d06 100644 --- a/index.html +++ b/index.html @@ -15,9 +15,9 @@ - + @@ -51,6 +51,7 @@