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');

View file

@ -15,9 +15,9 @@
<button id="btn-clear-filters" hidden>✕ Clear filters</button>
<button id="btn-call">Call</button>
<button id="btn-learn">Learn</button>
<button id="btn-bulk-edit" hidden>⚡ Bulk Edit</button>
<button id="btn-add-tune" hidden>+ Add tune</button>
<button id="btn-unlock">🔒 Unlock</button>
<button id="btn-bulk-edit" hidden></button>
</div>
</header>
@ -51,6 +51,7 @@
<!-- ── Bulk edit popover ─────────────────────────────────────── -->
<div id="bulk-edit-popover" hidden>
<button id="btn-open-bulk-refs">📎 Bulk Edit References</button>
<button id="btn-upload-csv">📂 Upload from CSV</button>
</div>
<!-- ── Bulk edit references modal ───────────────────────────── -->
@ -105,9 +106,12 @@
<fieldset id="fs-instrument">
<legend>Instrument entry</legend>
<label>Instrument
<select name="instrument_id" id="instrument-select">
<option value="">— none —</option>
</select>
<span class="autocomplete-row">
<select name="instrument_id" id="instrument-select">
<option value="">— none —</option>
</select>
<button type="button" class="quick-add-btn" id="btn-quick-add-instrument" title="Add new instrument">+</button>
</span>
</label>
<label>Tuning
<span class="autocomplete-row">
@ -196,6 +200,39 @@
<div id="call-options"></div>
</dialog>
<!-- ── CSV upload modal ─────────────────────────────────────── -->
<dialog id="upload-csv-modal">
<h2>Upload from CSV</h2>
<details id="csv-format-details" open>
<summary>Expected format</summary>
<p style="font-size:.85rem;margin:.5rem 0 .4rem">One tune per row. Headers (case-insensitive):</p>
<table class="csv-format-table">
<thead><tr><th>Column</th><th>Required</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>name</td><td>Yes</td><td>Tune name</td></tr>
<tr><td>key</td><td></td><td>e.g. D, G, Am</td></tr>
<tr><td>modal</td><td></td><td>true / false</td></tr>
<tr><td>source</td><td></td><td>Created if new</td></tr>
<tr><td>instrument</td><td></td><td>Created if new</td></tr>
<tr><td>tuning</td><td></td><td>Created if new</td></tr>
<tr><td>learned_from</td><td></td><td>Musician name — created if new</td></tr>
<tr><td>date_learned</td><td></td><td>YYYY-MM-DD</td></tr>
<tr><td>status</td><td></td><td>callable / review / to_learn</td></tr>
<tr><td>difficulty</td><td></td><td>easy / hard</td></tr>
</tbody>
</table>
<p class="csv-format-note">If a tune with the same name already exists a new instrument entry is added to it. Sources, tunings, and musicians are created automatically if they dont exist.</p>
</details>
<div id="csv-upload-area">
<input type="file" id="csv-file-input" accept=".csv,.tsv,text/csv" />
</div>
<div id="csv-results" hidden></div>
<div class="modal-actions">
<button id="btn-csv-import" disabled>Import</button>
<button id="btn-csv-cancel">Cancel</button>
</div>
</dialog>
<!-- Tooltip for truncated mobile text -->
<div id="mob-tooltip" hidden></div>

View file

@ -53,7 +53,7 @@ header h1 { font-size: 1.4rem; white-space: nowrap; }
}
#btn-unlock { background: transparent; border: 1px solid var(--border); color: var(--muted); }
#btn-add-tune { background: var(--accent); }
#btn-add-tune { background: #166534; color: #86efac; border: 1px solid #15803d; }
#btn-clear-filters { background: transparent; border: 1px solid var(--border); color: var(--muted); font-size: .8rem; }
#app-footer {
@ -517,7 +517,7 @@ fieldset.active .fs-body { display: contents; }
#bulk-edit-popover button:hover { background: rgba(255,255,255,.08); opacity: 1; }
#btn-bulk-edit { background: transparent; border: 1px solid var(--border); color: var(--muted); }
#btn-bulk-edit { background: transparent; border: 1px solid var(--border); color: var(--text); font-size: 1rem; padding: .3rem .65rem; }
#btn-call { background: #1b4332; color: #74c69d; border: 1px solid #2d6a4f; font-weight: 600; }
#btn-learn { background: #3d2e10; color: #f2c94c; border: 1px solid #4d3a1e; font-weight: 600; }
@ -676,6 +676,54 @@ fieldset.active .fs-body { display: contents; }
.bulk-refs-empty { color: var(--muted); text-align: center; padding: 1.5rem; font-size: .875rem; }
/* ── CSV upload modal ────────────────────────────────────────── */
#csv-format-details summary {
cursor: pointer;
font-size: .875rem;
color: var(--muted);
margin-bottom: .4rem;
user-select: none;
}
#csv-format-details summary:hover { color: var(--text); }
.csv-format-table {
width: 100%;
border-collapse: collapse;
font-size: .8rem;
margin-bottom: .6rem;
}
.csv-format-table th,
.csv-format-table td {
padding: .3rem .5rem;
border: 1px solid var(--border);
text-align: left;
}
.csv-format-table th { background: var(--bg); color: var(--muted); font-weight: 600; }
.csv-format-table td:first-child { font-family: monospace; color: #74c69d; }
.csv-format-table td:nth-child(2) { text-align: center; color: var(--accent); font-weight: 600; }
.csv-format-note {
font-size: .78rem;
color: var(--muted);
margin: 0;
}
#csv-upload-area {
margin: 1rem 0 .75rem;
}
#csv-results {
margin-top: .75rem;
font-size: .875rem;
}
.csv-ok { color: #6fcf97; margin: .3rem 0; }
.csv-error { color: #eb5757; margin: .3rem 0; }
.csv-status { color: var(--muted); margin: .3rem 0; }
.csv-errors { color: var(--muted); margin: .3rem 0 .3rem 1.25rem; }
.csv-errors li { margin-bottom: .2rem; }
#btn-csv-import { background: var(--accent); }
/* ── Musician chips ──────────────────────────────────────────── */
.ref-musicians-row {
display: flex;