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 ─────────────────────────────────────────
|
// ── Resolve name → id ─────────────────────────────────────────
|
||||||
function resolveSourceId(name) {
|
function resolveSourceId(name) {
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
|
@ -1028,6 +1039,11 @@ $('btn-open-bulk-refs').addEventListener('click', () => {
|
||||||
openBulkRefsModal();
|
openBulkRefsModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
$('btn-upload-csv').addEventListener('click', () => {
|
||||||
|
bulkEditPopover.hidden = true;
|
||||||
|
openUploadCsvModal();
|
||||||
|
});
|
||||||
|
|
||||||
$('btn-bulk-refs-close').addEventListener('click', () => bulkRefsModal.close());
|
$('btn-bulk-refs-close').addEventListener('click', () => bulkRefsModal.close());
|
||||||
|
|
||||||
['bulk-refs-search', 'bulk-refs-instrument', 'bulk-refs-source',
|
['bulk-refs-search', 'bulk-refs-instrument', 'bulk-refs-source',
|
||||||
|
|
@ -1208,6 +1224,164 @@ function esc(str) {
|
||||||
.replace(/"/g, '"');
|
.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 ───────────────────────────────────
|
// ── Mobile truncation tooltip ───────────────────────────────────
|
||||||
{
|
{
|
||||||
const tip = $('mob-tooltip');
|
const tip = $('mob-tooltip');
|
||||||
|
|
|
||||||
39
index.html
39
index.html
|
|
@ -15,9 +15,9 @@
|
||||||
<button id="btn-clear-filters" hidden>✕ Clear filters</button>
|
<button id="btn-clear-filters" hidden>✕ Clear filters</button>
|
||||||
<button id="btn-call">Call</button>
|
<button id="btn-call">Call</button>
|
||||||
<button id="btn-learn">Learn</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-add-tune" hidden>+ Add tune</button>
|
||||||
<button id="btn-unlock">🔒 Unlock</button>
|
<button id="btn-unlock">🔒 Unlock</button>
|
||||||
|
<button id="btn-bulk-edit" hidden>☰</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|
@ -51,6 +51,7 @@
|
||||||
<!-- ── 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- ── Bulk edit references modal ───────────────────────────── -->
|
<!-- ── Bulk edit references modal ───────────────────────────── -->
|
||||||
|
|
@ -105,9 +106,12 @@
|
||||||
<fieldset id="fs-instrument">
|
<fieldset id="fs-instrument">
|
||||||
<legend>Instrument entry</legend>
|
<legend>Instrument entry</legend>
|
||||||
<label>Instrument
|
<label>Instrument
|
||||||
|
<span class="autocomplete-row">
|
||||||
<select name="instrument_id" id="instrument-select">
|
<select name="instrument_id" id="instrument-select">
|
||||||
<option value="">— none —</option>
|
<option value="">— none —</option>
|
||||||
</select>
|
</select>
|
||||||
|
<button type="button" class="quick-add-btn" id="btn-quick-add-instrument" title="Add new instrument">+</button>
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<label>Tuning
|
<label>Tuning
|
||||||
<span class="autocomplete-row">
|
<span class="autocomplete-row">
|
||||||
|
|
@ -196,6 +200,39 @@
|
||||||
<div id="call-options"></div>
|
<div id="call-options"></div>
|
||||||
</dialog>
|
</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 don’t 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 -->
|
<!-- Tooltip for truncated mobile text -->
|
||||||
<div id="mob-tooltip" hidden></div>
|
<div id="mob-tooltip" hidden></div>
|
||||||
|
|
||||||
|
|
|
||||||
52
style.css
52
style.css
|
|
@ -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-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; }
|
#btn-clear-filters { background: transparent; border: 1px solid var(--border); color: var(--muted); font-size: .8rem; }
|
||||||
|
|
||||||
#app-footer {
|
#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; }
|
#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-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; }
|
#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; }
|
.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 ──────────────────────────────────────────── */
|
/* ── Musician chips ──────────────────────────────────────────── */
|
||||||
.ref-musicians-row {
|
.ref-musicians-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue