Add csv upload, make instruments table expandable
This commit is contained in:
parent
c439c2a6ba
commit
e85a4ca987
3 changed files with 265 additions and 6 deletions
174
app.js
174
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 = '<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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue