/* ───────────────────────────────────────────────────────────── Repertory – frontend app Plain JS, no build step, no framework. ───────────────────────────────────────────────────────────── */ // ── State ──────────────────────────────────────────────────── let allTunes = []; let allSources = []; let allMusicians = []; let allInstruments = []; let allTunings = []; let allReferenceSites = []; let sortCol = 'name'; let sortDir = 'asc'; // editing state let editingTuneId = null; // null = add mode let editingEntryId = null; // null = no existing instrument entry let editingOriginalRefIds = []; // ref IDs present when edit modal opened // notes modal state let notesTuneId = null; let notesEntryId = null; let noteEditorState = null; // { type: 'tune'|'inst', noteId: null|number } let unlocked = false; // ── DOM refs ───────────────────────────────────────────────── const $ = id => document.getElementById(id); const $$ = sel => document.querySelectorAll(sel); const statusEl = $('status'); const tbody = $('tune-tbody'); const emptyMsg = $('empty-msg'); const tuneModal = $('tune-modal'); const notesModal = $('notes-modal'); const tuneForm = $('tune-form'); const refsList = $('refs-list'); const btnUnlock = $('btn-unlock'); const btnAddTune = $('btn-add-tune'); const sourceInput = $('source-input'); const sourceDatalist = $('source-list'); const musicianDatalist = $('musician-list'); const instrumentSelect = $('instrument-select'); const tuningDatalist = $('tuning-list'); const btnDeleteTune = $('btn-delete-tune'); const btnEditNotes = $('btn-edit-notes'); const btnBulkEdit = $('btn-bulk-edit'); const bulkEditPopover = $('bulk-edit-popover'); const bulkRefsModal = $('bulk-refs-modal'); const bulkRefsList = $('bulk-refs-list'); const refsModal = $('refs-modal'); const refsModalList = $('refs-modal-list'); const refSiteDatalist = $('ref-site-list'); // ── Config ─────────────────────────────────────────────────── const _cfg = window.REPERTORY_CONFIG || {}; function getApiKey() { return _cfg.apiKey || sessionStorage.getItem('apiKey') || ''; } // ── Unlock ─────────────────────────────────────────────────── if (getApiKey()) setUnlocked(true); btnUnlock.addEventListener('click', () => { const key = prompt('API key:'); if (!key) return; sessionStorage.setItem('apiKey', key); if (!_cfg.apiUrl) { const url = prompt('API URL (e.g. https://your-server:5000):'); if (url) sessionStorage.setItem('apiUrl', url.replace(/\/$/, '')); } setUnlocked(true); }); function setUnlocked(val) { unlocked = val; btnUnlock.hidden = val; btnAddTune.hidden = !val; btnBulkEdit.hidden = !val; // Show/hide edit column header and edit buttons const editHeader = document.querySelector('.edit-col'); if (editHeader) editHeader.hidden = !val; $$('.btn-edit-row').forEach(b => { b.hidden = !val; }); $$('.note-edit-controls').forEach(el => { el.hidden = !val; }); $('btn-add-tune-note').hidden = !val; $('btn-add-inst-note').hidden = !val; } // ── API helpers ─────────────────────────────────────────────── function resolvedApiUrl() { return (_cfg.apiUrl || sessionStorage.getItem('apiUrl') || '').replace(/\/$/, ''); } async function apiFetch(path, options = {}) { const url = resolvedApiUrl() + path; const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; if (options.method && options.method !== 'GET') { headers['X-API-Key'] = getApiKey(); } const res = await fetch(url, { ...options, headers }); if (!res.ok) { const text = await res.text(); throw new Error(`${res.status} ${res.statusText}: ${text}`); } return res.json(); } function setStatus(msg, isError = false) { statusEl.textContent = msg; statusEl.style.color = isError ? '#eb5757' : '#6fcf97'; } // ── Load ────────────────────────────────────────────────────── async function loadAll() { setStatus('Loading…'); try { [allTunes, allSources, allMusicians, allInstruments, allTunings, allReferenceSites] = await Promise.all([ apiFetch('/tunes/'), apiFetch('/sources/'), apiFetch('/musicians/'), apiFetch('/instruments'), apiFetch('/tunings/'), apiFetch('/reference_sites/'), ]); populateFilterOptions(); populateDatalist(sourceDatalist, allSources); populateDatalist(musicianDatalist, allMusicians); populateDatalist(tuningDatalist, allTunings); populateDatalist(refSiteDatalist, allReferenceSites); populateInstrumentSelect(); renderTable(); setStatus(`${allTunes.length} tunes loaded`); } catch (e) { setStatus(e.message, true); } } function populateDatalist(datalist, items) { datalist.innerHTML = ''; items.forEach(item => { const opt = document.createElement('option'); opt.value = item.name; datalist.appendChild(opt); }); } function populateInstrumentSelect(selectedId = null) { instrumentSelect.innerHTML = ''; allInstruments.forEach(inst => { const opt = document.createElement('option'); opt.value = inst.id; opt.textContent = inst.name; if (selectedId && inst.id === selectedId) opt.selected = true; instrumentSelect.appendChild(opt); }); } function populateFilterOptions() { // Nothing to populate in DOM — popover dropdowns are built on demand // from allSources, allInstruments, allTunings in buildDropdownOptions() } // ── Quick-add ───────────────────────────────────────────────── async function quickAdd(endpoint, promptText, list, datalist, onDone) { const name = prompt(promptText); if (!name) return null; const existing = list.find(x => x.name.toLowerCase() === name.trim().toLowerCase()); if (existing) { if (onDone) onDone(existing); return existing; } try { const item = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ name: name.trim() }) }); list.push(item); list.sort((a, b) => a.name.localeCompare(b.name)); if (datalist) populateDatalist(datalist, list); if (onDone) onDone(item); return item; } catch (e) { setStatus(e.message, true); return null; } } $('btn-quick-add-source').addEventListener('click', async () => { await quickAdd('/sources/', 'New source name:', allSources, sourceDatalist, item => { sourceInput.value = item.name; populateFilterOptions(); }); }); $('btn-quick-add-musician').addEventListener('click', async () => { await quickAdd('/musicians/', 'New musician name:', allMusicians, musicianDatalist, item => { tuneForm.elements.inst_learned_from.value = item.name; }); }); $('btn-quick-add-tuning').addEventListener('click', async () => { await quickAdd('/tunings/', 'New tuning name:', allTunings, tuningDatalist, item => { tuneForm.elements.inst_tuning.value = item.name; populateFilterOptions(); }); }); $('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; return allSources.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null; } function resolveMusicianId(name) { if (!name) return null; return allMusicians.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null; } function resolveTuningId(name) { if (!name) return null; return allTunings.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null; } // ── Rows: one per instrument entry ──────────────────────────── // For tunes with no instrument entries, emit one row with empty instrument cols function buildRows(tunes) { const rows = []; for (const tune of tunes) { if (tune.instruments.length === 0) { rows.push({ tune, entry: null }); } else { for (const entry of tune.instruments) { rows.push({ tune, entry }); } } } return rows; } // ── Filters (popover per column) ───────────────────────────── const filters = { key: null, source: null, modal: null, instrument: null, tuning: null, status: null, difficulty: null, }; const popover = $('filter-popover'); const popoverInner = $('filter-popover-inner'); let activeFilterKey = null; const BOOL_FILTERS = new Set([]); // modal merged into key display; filter still works programmatically const DROPDOWN_FILTERS = new Set(['source', 'instrument', 'tuning', 'difficulty', 'status']); function openFilterPopover(key, thEl) { if (activeFilterKey === key) { closeFilterPopover(); return; } activeFilterKey = key; popoverInner.innerHTML = ''; if (BOOL_FILTERS.has(key)) { const label = document.createElement('label'); label.className = 'popover-checkbox-row'; const cb = document.createElement('input'); cb.type = 'checkbox'; if (filters[key] === true) { cb.checked = true; cb.indeterminate = false; } else if (filters[key] === false) { cb.checked = false; cb.indeterminate = true; } else { cb.checked = false; cb.indeterminate = false; } const names = { modal: 'Modal' }; label.appendChild(cb); label.appendChild(document.createTextNode(names[key] ?? key)); popoverInner.appendChild(label); cb.addEventListener('change', () => { if (filters[key] === null) { filters[key] = true; cb.checked = true; cb.indeterminate = false; } else if (filters[key] === true) { filters[key] = false; cb.checked = false; cb.indeterminate = true; } else { filters[key] = null; cb.checked = false; cb.indeterminate = false; } updateFilterIndicators(); renderTable(); }); } else if (DROPDOWN_FILTERS.has(key)) { const sel = document.createElement('select'); buildDropdownOptions(key).forEach(([val, lbl]) => { const opt = document.createElement('option'); opt.value = val; opt.textContent = lbl; if (val === (filters[key] ?? '')) opt.selected = true; sel.appendChild(opt); }); popoverInner.appendChild(sel); sel.addEventListener('change', () => { filters[key] = sel.value || null; updateFilterIndicators(); renderTable(); }); } else { // key — plain text const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = 'Filter…'; inp.value = filters[key] ?? ''; popoverInner.appendChild(inp); inp.addEventListener('input', () => { filters[key] = inp.value || null; updateFilterIndicators(); renderTable(); }); requestAnimationFrame(() => inp.focus()); } const clr = document.createElement('button'); clr.className = 'popover-clear'; clr.textContent = 'Clear'; clr.addEventListener('click', () => { filters[key] = null; updateFilterIndicators(); renderTable(); closeFilterPopover(); }); popoverInner.appendChild(clr); const rect = thEl.getBoundingClientRect(); popover.hidden = false; popover.style.top = `${rect.bottom + 4}px`; popover.style.left = `${Math.min(rect.left, window.innerWidth - 200)}px`; } function closeFilterPopover() { popover.hidden = true; activeFilterKey = null; } function buildDropdownOptions(key) { const none = [['', '— all —']]; if (key === 'source') return [...none, ...allSources.map(s => [String(s.id), s.name])]; if (key === 'instrument') return [...none, ...allInstruments.map(i => [String(i.id), i.name])]; if (key === 'tuning') return [...none, ...allTunings.map(t => [String(t.id), t.name])]; if (key === 'difficulty') return [...none, ['easy', 'Easy'], ['hard', 'Hard']]; if (key === 'status') return [...none, ['callable', 'Callable'], ['review', 'Needs Review'], ['to_learn', 'To Learn']]; return none; } function updateFilterIndicators() { $$('th[data-filter]').forEach(th => { th.classList.toggle('filter-active', filters[th.dataset.filter] !== null); }); const anyActive = Object.values(filters).some(v => v !== null) || $('filter-search').value; $('btn-clear-filters').hidden = !anyActive; } $$('th[data-filter]').forEach(th => { th.addEventListener('click', e => { e.stopPropagation(); openFilterPopover(th.dataset.filter, th); }); }); document.addEventListener('click', e => { if (!popover.hidden && !popover.contains(e.target)) closeFilterPopover(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeFilterPopover(); }); $('filter-search').addEventListener('input', () => { updateFilterIndicators(); renderTable(); }); $('btn-clear-filters').addEventListener('click', () => { Object.keys(filters).forEach(k => { filters[k] = null; }); $('filter-search').value = ''; updateFilterIndicators(); renderTable(); closeFilterPopover(); }); // ── Match function ──────────────────────────────────────────── function rowMatchesFilters(tune, entry) { const search = $('filter-search').value.toLowerCase(); if (search && !tune.name?.toLowerCase().includes(search)) return false; if (filters.key && tune.key?.toLowerCase() !== filters.key.toLowerCase()) return false; if (filters.source && String(tune.source_id) !== filters.source) return false; if (filters.modal !== null && tune.modal !== filters.modal) return false; if (filters.instrument && (!entry || String(entry.instrument_id) !== filters.instrument)) return false; if (entry) { if (filters.tuning && String(entry.tuning_id) !== filters.tuning) return false; if (filters.status && entry.status !== filters.status) return false; if (filters.difficulty && entry.difficulty?.toLowerCase() !== filters.difficulty) return false; } return true; } // ── Sorting ─────────────────────────────────────────────────── function sortTunes(tunes) { return [...tunes].sort((a, b) => { let av = a[sortCol] ?? ''; let bv = b[sortCol] ?? ''; if (typeof av === 'string') av = av.toLowerCase(); if (typeof bv === 'string') bv = bv.toLowerCase(); if (av < bv) return sortDir === 'asc' ? -1 : 1; if (av > bv) return sortDir === 'asc' ? 1 : -1; return 0; }); } $$('#tune-table thead th[data-col]').forEach(th => { th.addEventListener('click', () => { const col = th.dataset.col; sortDir = sortCol === col && sortDir === 'asc' ? 'desc' : 'asc'; sortCol = col; $$('#tune-table thead th').forEach(t => t.classList.remove('sort-asc', 'sort-desc')); th.classList.add(sortDir === 'asc' ? 'sort-asc' : 'sort-desc'); renderTable(); }); }); // ── Render ──────────────────────────────────────────────────── function renderTable() { const sorted = sortTunes(allTunes); const rows = buildRows(sorted).filter(({ tune, entry }) => rowMatchesFilters(tune, entry)); tbody.innerHTML = ''; emptyMsg.hidden = rows.length > 0; rows.forEach(({ tune, entry }) => { const hasNotes = tune.has_notes ?? false; // ── Mobile 2-line card data ────────────────────────────────── const nameRaw = tune.name ?? ''; const sourceRaw = tune.source?.name ?? ''; const instRaw = entry?.instrument_name ?? ''; const tuningRaw = entry?.tuning ?? ''; const instStr = instRaw ? (tuningRaw ? `${instRaw} (${tuningRaw})` : instRaw) : ''; const keyStr = tune.key ? (tune.modal ? `${tune.key} Modal` : tune.key) : (tune.modal ? 'Modal' : ''); const metaRaw = [instStr, keyStr].filter(Boolean).join(' · '); const mobPills = entry ? pillStatus(entry.status, 'mob-pill') : ''; const mobActions = [ hasNotes ? `` : '', (tune.reference_count ?? 0) > 0 ? `` : '', ``, ].join(''); const tr = document.createElement('tr'); tr.innerHTML = `
Loading…
'; refsModal.showModal(); try { const refs = await apiFetch(`/tunes/${tuneId}/references`); refsModalList.innerHTML = ''; if (refs.length === 0) { refsModalList.innerHTML = 'No references.
'; } else { refs.forEach(r => { const item = document.createElement('div'); item.className = 'refs-modal-item'; const musicians = r.musicians?.map(m => m.name).join(', ') ?? ''; const siteName = r.site?.name ?? ''; const meta = [siteName, musicians].filter(Boolean).join(' · '); // Use meta as link text; fall back to hostname so raw URL is never shown let displayText = meta; if (!displayText && r.link) { try { displayText = new URL(r.link).hostname.replace(/^www\./, ''); } catch { displayText = 'link'; } } if (!displayText) displayText = 'link'; item.innerHTML = ` ${esc(displayText)}`; refsModalList.appendChild(item); }); } } catch (e) { refsModalList.innerHTML = `${esc(e.message)}
`; } } // ── Call / Learn mode ──────────────────────────────────────────── const callModal = $('call-modal'); let callMode = 'callable'; // 'callable' | 'to_learn' let callStep = 0; let callInstrumentId = null; let callTuningId = null; function callGetEntries() { const result = []; for (const tune of allTunes) for (const entry of tune.instruments) if (entry.status === callMode) result.push({ tune, entry }); return result; } function callGetInstruments() { const map = new Map(); callGetEntries().forEach(({ entry }) => { if (!map.has(entry.instrument_id)) map.set(entry.instrument_id, entry.instrument_name); }); return [...map.entries()].map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)); } function callGetTunings() { const map = new Map(); callGetEntries() .filter(({ entry }) => callInstrumentId === null || entry.instrument_id === callInstrumentId) .forEach(({ entry }) => { if (entry.tuning_id != null && !map.has(entry.tuning_id)) map.set(entry.tuning_id, entry.tuning); }); return [...map.entries()].map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)); } function callGetKeys() { const map = new Map(); callGetEntries() .filter(({ entry }) => (callInstrumentId === null || entry.instrument_id === callInstrumentId) && (callTuningId === null || entry.tuning_id === callTuningId) ) .forEach(({ tune }) => { if (tune.key) { const k = tune.key + '|' + (tune.modal ? '1' : '0'); if (!map.has(k)) map.set(k, { key: tune.key, modal: !!tune.modal }); } }); return [...map.values()] .sort((a, b) => a.key.localeCompare(b.key) || (a.modal ? 1 : -1)); } function openCallModal(mode) { callMode = mode; callStep = 0; callInstrumentId = null; callTuningId = null; renderCallStep(); // showModal() is called inside renderCallStep() only when there are // multiple options to show — if everything auto-skipped the modal // never opens and the filter is applied instantly. } function renderCallStep() { const items = callStep === 0 ? callGetInstruments() : callStep === 1 ? callGetTunings() : callGetKeys(); // Auto-advance if only one option if (items.length === 1) { callSelect(callStep === 2 ? items[0].key : items[0].id, callStep === 2 ? items[0].modal : null); return; } // Multiple options — open the modal now if not already open if (!callModal.open) callModal.showModal(); $('call-title').textContent = ['Instrument', 'Tuning', 'Key'][callStep]; $('call-back').hidden = callStep === 0; const opts = $('call-options'); opts.innerHTML = ''; const allBtn = document.createElement('button'); allBtn.className = 'call-option-btn call-all-btn'; allBtn.textContent = 'All'; allBtn.addEventListener('click', () => callSelect(null, null)); opts.appendChild(allBtn); items.forEach(item => { const btn = document.createElement('button'); btn.className = 'call-option-btn'; btn.textContent = callStep === 2 ? (item.modal ? item.key + ' modal' : item.key) : item.name; btn.addEventListener('click', () => callSelect(callStep === 2 ? item.key : item.id, callStep === 2 ? item.modal : null)); opts.appendChild(btn); }); } function callSelect(value, modal) { if (callStep === 0) { callInstrumentId = value; callStep = 1; renderCallStep(); } else if (callStep === 1) { callTuningId = value; callStep = 2; renderCallStep(); } else { filters.status = callMode; filters.instrument = callInstrumentId !== null ? String(callInstrumentId) : null; filters.tuning = callTuningId !== null ? String(callTuningId) : null; filters.key = value; filters.modal = value !== null ? modal : null; updateFilterIndicators(); renderTable(); callModal.close(); } } $('btn-call').addEventListener('click', () => openCallModal('callable')); $('btn-learn').addEventListener('click', () => openCallModal('to_learn')); $('call-close').addEventListener('click', () => callModal.close()); $('call-back').addEventListener('click', () => { callStep--; if (callStep === 0) callInstrumentId = null; else if (callStep === 1) callTuningId = null; renderCallStep(); }); // ── Bulk Edit ────────────────────────────────────────────── btnBulkEdit.addEventListener('click', e => { e.stopPropagation(); if (!bulkEditPopover.hidden) { bulkEditPopover.hidden = true; return; } const rect = btnBulkEdit.getBoundingClientRect(); bulkEditPopover.style.top = (rect.bottom + 4) + 'px'; bulkEditPopover.style.right = (window.innerWidth - rect.right) + 'px'; bulkEditPopover.hidden = false; }); document.addEventListener('click', e => { if (!bulkEditPopover.hidden && !bulkEditPopover.contains(e.target) && e.target !== btnBulkEdit) { bulkEditPopover.hidden = true; } }); $('btn-open-bulk-refs').addEventListener('click', () => { bulkEditPopover.hidden = true; 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', 'bulk-refs-has-refs', 'bulk-refs-sort'].forEach(id => { const el = $(id); el.addEventListener('input', renderBulkRefsList); el.addEventListener('change', renderBulkRefsList); }); function openBulkRefsModal() { const instSel = $('bulk-refs-instrument'); instSel.innerHTML = ''; allInstruments.forEach(i => { const opt = document.createElement('option'); opt.value = i.id; opt.textContent = i.name; instSel.appendChild(opt); }); const srcSel = $('bulk-refs-source'); srcSel.innerHTML = ''; allSources.forEach(s => { const opt = document.createElement('option'); opt.value = s.id; opt.textContent = s.name; srcSel.appendChild(opt); }); renderBulkRefsList(); bulkRefsModal.showModal(); } function getBulkRefsFiltered() { const search = $('bulk-refs-search').value.toLowerCase(); const instId = $('bulk-refs-instrument').value; const srcId = $('bulk-refs-source').value; const hasRefs = $('bulk-refs-has-refs').value; const sortKey = $('bulk-refs-sort').value; let tunes = allTunes.filter(t => { if (search && !(t.name ?? '').toLowerCase().includes(search)) return false; if (srcId && t.source_id !== parseInt(srcId)) return false; if (instId && !t.instruments.some(e => e.instrument_id === parseInt(instId))) return false; if (hasRefs === 'no' && (t.reference_count ?? 0) > 0) return false; if (hasRefs === 'yes' && (t.reference_count ?? 0) === 0) return false; return true; }); tunes = [...tunes].sort((a, b) => { if (sortKey === 'newest') { const diff = (b.created_at || '') > (a.created_at || '') ? 1 : (b.created_at || '') < (a.created_at || '') ? -1 : 0; return diff !== 0 ? diff : b.id - a.id; } if (sortKey === 'oldest') { const diff = (a.created_at || '') > (b.created_at || '') ? 1 : (a.created_at || '') < (b.created_at || '') ? -1 : 0; return diff !== 0 ? diff : a.id - b.id; } if (sortKey === 'name_desc') return (b.name ?? '').localeCompare(a.name ?? ''); return (a.name ?? '').localeCompare(b.name ?? ''); }); return tunes; } function renderBulkRefsList() { const tunes = getBulkRefsFiltered(); bulkRefsList.innerHTML = ''; if (tunes.length === 0) { bulkRefsList.innerHTML = 'No tunes match the current filters.
'; return; } tunes.forEach(tune => { const row = document.createElement('div'); row.className = 'bulk-tune-row'; row.dataset.tuneId = tune.id; const instruments = tune.instruments.map(e => e.instrument_name).filter(Boolean).join(', '); const refCount = tune.reference_count ?? 0; const refPill = refCount > 0 ? `${refCount} ref${refCount !== 1 ? 's' : ''}` : `0`; row.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'); let timer = null; tbody.addEventListener('click', e => { const el = e.target.closest('.mob-truncatable'); if (!el) return; if (el.scrollWidth <= el.offsetWidth) return; // not truncated clearTimeout(timer); tip.textContent = el.dataset.full; const rect = el.getBoundingClientRect(); tip.style.top = (rect.bottom + 4) + 'px'; tip.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - 220)) + 'px'; tip.hidden = false; timer = setTimeout(() => { tip.hidden = true; }, 2500); e.stopPropagation(); }); document.addEventListener('click', () => { clearTimeout(timer); tip.hidden = true; }); } // Pressing Tab in a datalist-backed input fills the top matching suggestion. function attachTabAutocomplete(input, datalist) { input.addEventListener('keydown', e => { if (e.key !== 'Tab' || !input.value) return; const val = input.value.toLowerCase(); const opts = [...datalist.options].map(o => o.value); const match = opts.find(o => o.toLowerCase().startsWith(val)) ?? opts.find(o => o.toLowerCase().includes(val)); if (match) input.value = match; // Don't preventDefault — Tab still moves focus normally }); } attachTabAutocomplete(sourceInput, sourceDatalist); attachTabAutocomplete(tuneForm.elements.inst_tuning, tuningDatalist); attachTabAutocomplete(tuneForm.elements.inst_learned_from, musicianDatalist); // ── Boot ───────────────────────────────────────────────────── loadAll();