repertory/app.js

1454 lines
56 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ─────────────────────────────────────────────────────────────
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
// notes modal state
let notesTuneId = null;
let notesEntryId = null;
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 = '<option value="">— none —</option>';
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 ? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>` : '',
(tune.reference_count ?? 0) > 0 ? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}">🔗</button>` : '',
`<button class="action-btn btn-edit-row" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}" ${unlocked ? '' : 'hidden'}>✏️</button>`,
].join('');
const tr = document.createElement('tr');
tr.innerHTML = `
<td><span class="cell-trunc cell-name-content mob-truncatable" data-full="${esc(nameRaw)}" title="${esc(nameRaw)}">${esc(nameRaw)}</span></td>
<td><span class="cell-trunc cell-source-content mob-truncatable" data-full="${esc(sourceRaw)}" title="${esc(sourceRaw)}">${esc(sourceRaw)}</span></td>
<td>${esc(keyStr)}</td>
<td>${esc(entry?.instrument_name ?? '')}</td>
<td>${esc(entry?.tuning ?? '')}</td>
<td>${pillStatus(entry?.status)}</td>
<td>${hasNotes
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
: ''}</td>
<td>${(tune.reference_count ?? 0) > 0
? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}" title="References">🔗</button>`
: ''}</td>
<td><button class="action-btn btn-edit-row"
data-tune-id="${tune.id}"
data-entry-id="${entry?.id ?? ''}"
title="Edit"
${unlocked ? '' : 'hidden'}>✏️</button></td>
<td class="mobile-row" colspan="99">
<div class="mob-line1">
<div class="mob-name-wrap">
<span class="mob-name mob-truncatable" data-full="${esc(nameRaw)}" title="${esc(nameRaw)}">${esc(nameRaw)}</span>
${sourceRaw ? `<span class="mob-source mob-truncatable" data-full="${esc(sourceRaw)}" title="${esc(sourceRaw)}">(${esc(sourceRaw)})</span>` : ''}
</div>
<span class="mob-status">${mobPills}</span>
</div>
<div class="mob-line2">
<span class="mob-meta mob-truncatable" data-full="${esc(metaRaw)}" title="${esc(metaRaw)}">${esc(metaRaw)}</span>
<span class="mob-actions">${mobActions}</span>
</div>
</td>`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-notes').forEach(b =>
b.addEventListener('click', () => openNotesModal(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null)));
tbody.querySelectorAll('.btn-edit-row').forEach(b =>
b.addEventListener('click', () => openEdit(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null)));
tbody.querySelectorAll('.btn-show-refs').forEach(b =>
b.addEventListener('click', () => openRefsModal(+b.dataset.tuneId)));
}
function pillBool(val, warnIfTrue = false, neutralIfTrue = false) {
if (val === null || val === undefined) return '<span class="pill" style="background:#222">—</span>';
if (val === true) {
if (neutralIfTrue || warnIfTrue) return '<span class="pill pill-warn">Yes</span>';
return '<span class="pill pill-yes">Yes</span>';
}
return '<span class="pill pill-no">No</span>';
}
function pillStatus(status, extraClass = '') {
const c = extraClass ? ' ' + extraClass : '';
if (status === 'callable') return `<span class="pill pill-yes${c}">Callable</span>`;
if (status === 'review') return `<span class="pill pill-warn${c}">Needs Review</span>`;
if (status === 'to_learn') return `<span class="pill pill-to-learn${c}">To Learn</span>`;
return `<span class="pill pill-dim${c}">—</span>`;
}
// ── Add / Edit modal ──────────────────────────────────────────
btnAddTune.addEventListener('click', openAdd);
function openAdd() {
editingTuneId = null;
editingEntryId = null;
$('modal-title').textContent = 'Add tune';
tuneForm.reset();
refsList.innerHTML = '';
sourceInput.value = '';
populateInstrumentSelect();
instrumentSelect.value = '';
btnDeleteTune.hidden = true;
btnEditNotes.hidden = true;
tuneModal.showModal();
}
async function openEdit(tuneId, entryId) {
editingTuneId = tuneId;
editingEntryId = entryId;
// Open modal immediately — name from slim data gives instant feedback
const slimTune = allTunes.find(t => t.id === tuneId);
$('modal-title').textContent = `Edit: ${slimTune?.name ?? ''}`;
tuneForm.reset();
refsList.innerHTML = '';
btnDeleteTune.hidden = false;
btnEditNotes.hidden = false;
tuneModal.showModal();
// Fetch full tune for references and confirmed field values
let fullTune;
try {
fullTune = await apiFetch(`/tunes/${tuneId}`);
} catch (e) {
setStatus(e.message, true);
tuneModal.close();
return;
}
const entry = entryId ? fullTune.instruments.find(e => e.id === entryId) : null;
$('modal-title').textContent = `Edit: ${fullTune.name ?? ''}`;
tuneForm.elements.name.value = fullTune.name ?? '';
tuneForm.elements.key.value = fullTune.key ?? '';
tuneForm.elements.modal.checked = !!fullTune.modal;
sourceInput.value = fullTune.source?.name ?? '';
populateInstrumentSelect(entry?.instrument_id ?? null);
if (entry) {
tuneForm.elements.inst_tuning.value = entry.tuning ?? '';
tuneForm.elements.inst_learned_from.value = entry.learned_from ?? '';
tuneForm.elements.inst_date_learned.value = entry.date_learned ?? '';
tuneForm.elements.inst_status.value = entry.status ?? '';
tuneForm.elements.inst_difficulty.value = entry.difficulty ?? '';
}
(fullTune.references ?? []).forEach(r => addRefRow(r.link, r.site?.name ?? '', r.id, r.site_id, r.musicians ?? []));
}
// ── Musician chip picker (shared by ref rows + bulk form) ──────
function makeMusicianPicker(initialMusicians = []) {
let musicians = initialMusicians.map(m => ({ id: m.id, name: m.name }));
const el = document.createElement('div');
el.className = 'ref-musicians-row';
el.innerHTML = `<span class="ref-musician-chips"></span>
<span class="autocomplete-row">
<input type="text" placeholder="Musician" class="ref-musician-input"
list="musician-list" autocomplete="off" />
<button type="button" class="quick-add-btn btn-add-ref-musician" title="Add musician">+</button>
</span>`;
const chipsEl = el.querySelector('.ref-musician-chips');
const input = el.querySelector('.ref-musician-input');
const btnPlus = el.querySelector('.btn-add-ref-musician');
function refresh() {
chipsEl.innerHTML = '';
musicians.forEach(m => {
const chip = document.createElement('span');
chip.className = 'musician-chip';
chip.innerHTML = `${esc(m.name)}<button type="button" class="chip-remove" title="Remove">✕</button>`;
chip.querySelector('.chip-remove').addEventListener('click', () => {
musicians = musicians.filter(x => x.id !== m.id);
refresh();
});
chipsEl.appendChild(chip);
});
}
function addByName(name) {
name = name.trim();
if (!name) return false;
const found = allMusicians.find(m => m.name.toLowerCase() === name.toLowerCase());
if (!found || musicians.some(m => m.id === found.id)) return false;
musicians.push({ id: found.id, name: found.name });
input.value = '';
refresh();
return true;
}
attachTabAutocomplete(input, musicianDatalist);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); addByName(input.value); }
});
btnPlus.addEventListener('click', async () => {
if (addByName(input.value)) return;
const item = await quickAdd('/musicians/', 'New musician name:', allMusicians, musicianDatalist);
if (item) { musicians.push({ id: item.id, name: item.name }); refresh(); }
});
refresh();
return {
el,
getMusicians: () => musicians.map(m => ({ id: m.id })),
reset: () => { musicians = []; input.value = ''; refresh(); },
};
}
// ── Reference rows ────────────────────────────────────────────
$('btn-add-ref').addEventListener('click', () => addRefRow());
function addRefRow(link = '', site = '', refId = null, siteId = null, initialMusicians = []) {
const row = document.createElement('div');
row.className = 'ref-row';
row.dataset.refId = refId ?? '';
row.dataset.siteId = siteId ?? '';
const siteOptions = allReferenceSites
.map(s => `<option value="${s.id}"${s.id === siteId ? ' selected' : ''}>${esc(s.name)}</option>`)
.join('');
row.innerHTML = `<input type="text" placeholder="URL" value="${esc(link)}" class="ref-link" />
<span class="autocomplete-row ref-site-wrap">
<select class="ref-site"><option value="">— site —</option>${siteOptions}</select>
<button type="button" class="quick-add-btn btn-quick-add-ref-site" title="Add new site">+</button>
</span>
<button type="button" class="remove-btn">✕</button>`;
row.querySelector('.remove-btn').addEventListener('click', () => row.remove());
row.querySelector('.btn-quick-add-ref-site').addEventListener('click', async () => {
const item = await quickAdd('/reference_sites/', 'New site name:', allReferenceSites, null);
if (item) {
const sel = row.querySelector('.ref-site');
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name;
sel.appendChild(opt);
sel.value = item.id;
}
});
const picker = makeMusicianPicker(initialMusicians);
row.appendChild(picker.el);
row._getMusicians = picker.getMusicians;
refsList.appendChild(row);
}
// ── Save ──────────────────────────────────────────────────────
$('btn-save-tune').addEventListener('click', saveTune);
$('btn-cancel').addEventListener('click', () => tuneModal.close());
async function saveTune() {
const f = tuneForm.elements;
const tuneBody = {
name: f.name.value || null,
key: f.key.value || null,
modal: f.modal.checked,
source_id: resolveSourceId(sourceInput.value),
};
const instId = instrumentSelect.value ? parseInt(instrumentSelect.value) : null;
const instBody = instId ? {
instrument_id: instId,
tuning_id: resolveTuningId(f.inst_tuning.value),
learned_from_id: resolveMusicianId(f.inst_learned_from.value),
date_learned: f.inst_date_learned.value || null,
status: f.inst_status.value || null,
difficulty: f.inst_difficulty.value || null,
} : null;
const references = [...refsList.querySelectorAll('.ref-row')]
.map(row => ({
link: row.querySelector('.ref-link').value.trim() || null,
site_id: row.querySelector('.ref-site').value ? parseInt(row.querySelector('.ref-site').value) : null,
musicians: row._getMusicians ? row._getMusicians() : [],
}))
.filter(r => r.link || r.site_id);
try {
let tuneId = editingTuneId;
if (tuneId === null) {
// Create tune
const created = await apiFetch('/tunes/', {
method: 'POST',
body: JSON.stringify({ ...tuneBody, references }),
});
tuneId = created.id;
// Add instrument entry if selected
if (instBody) {
await apiFetch(`/tunes/${tuneId}/instruments`, {
method: 'POST', body: JSON.stringify(instBody),
});
}
} else {
// Update tune core
await apiFetch(`/tunes/${tuneId}`, { method: 'PATCH', body: JSON.stringify(tuneBody) });
// Update or create instrument entry
if (instBody) {
if (editingEntryId) {
await apiFetch(`/tunes/${tuneId}/instruments/${editingEntryId}`, {
method: 'PATCH', body: JSON.stringify(instBody),
});
} else {
await apiFetch(`/tunes/${tuneId}/instruments`, {
method: 'POST', body: JSON.stringify(instBody),
});
}
}
}
tuneModal.close();
await loadAll();
} catch (e) {
setStatus(e.message, true);
}
}
// ── Delete ────────────────────────────────────────────────────
btnEditNotes.addEventListener('click', () => {
tuneModal.close();
openNotesModal(editingTuneId, editingEntryId);
});
btnDeleteTune.addEventListener('click', async () => {
const tune = allTunes.find(t => t.id === editingTuneId);
if (!confirm(`Delete "${tune?.name ?? editingTuneId}" and all its data?`)) return;
try {
await apiFetch(`/tunes/${editingTuneId}`, { method: 'DELETE' });
tuneModal.close();
await loadAll();
} catch (e) {
setStatus(e.message, true);
}
});
// ── Notes modal ───────────────────────────────────────────────
async function openNotesModal(tuneId, entryId) {
notesTuneId = tuneId;
notesEntryId = entryId;
// Open immediately with name from slim data
const slimTune = allTunes.find(t => t.id === tuneId);
$('notes-modal-title').textContent = `Notes — ${slimTune?.name ?? ''}`;
$('tune-notes-list').innerHTML = '';
$('inst-notes-list').innerHTML = '';
$('inst-notes-section').hidden = !entryId;
$('btn-add-tune-note').hidden = !unlocked;
$('btn-add-inst-note').hidden = !unlocked || !entryId;
notesModal.showModal();
// Fetch full tune for note data
try {
const fullTune = await apiFetch(`/tunes/${tuneId}`);
const entry = entryId ? fullTune.instruments.find(e => e.id === entryId) : null;
$('inst-notes-heading').textContent = entry ? `${entry.instrument_name} notes` : 'Instrument notes';
$('inst-notes-section').hidden = !entry;
$('btn-add-inst-note').hidden = !unlocked || !entry;
renderTuneNotes(fullTune.notes ?? []);
renderInstNotes(entry?.notes ?? []);
} catch (e) {
setStatus(e.message, true);
}
}
function renderTuneNotes(notes) {
const list = $('tune-notes-list');
list.innerHTML = '';
notes.forEach(n => {
const row = document.createElement('div');
row.className = 'note-row';
row.innerHTML = `<span class="note-text">${esc(n.note)}</span>
<span class="note-edit-controls" ${unlocked ? '' : 'hidden'}>
<button class="action-btn btn-edit-note" data-id="${n.id}">✏️</button>
<button class="action-btn btn-delete-note remove-btn" data-id="${n.id}">✕</button>
</span>`;
row.querySelector('.btn-edit-note')?.addEventListener('click', () => editTuneNote(n));
row.querySelector('.btn-delete-note')?.addEventListener('click', () => deleteTuneNote(n.id));
list.appendChild(row);
});
}
function renderInstNotes(notes) {
const list = $('inst-notes-list');
list.innerHTML = '';
notes.forEach(n => {
const row = document.createElement('div');
row.className = 'note-row';
row.innerHTML = `<span class="note-text">${esc(n.note)}</span>
<span class="note-edit-controls" ${unlocked ? '' : 'hidden'}>
<button class="action-btn btn-edit-note" data-id="${n.id}">✏️</button>
<button class="action-btn btn-delete-note remove-btn" data-id="${n.id}">✕</button>
</span>`;
row.querySelector('.btn-edit-note')?.addEventListener('click', () => editInstNote(n));
row.querySelector('.btn-delete-note')?.addEventListener('click', () => deleteInstNote(n.id));
list.appendChild(row);
});
}
$('btn-add-tune-note').addEventListener('click', async () => {
const text = prompt('Note:');
if (!text) return;
try {
await apiFetch(`/tunes/${notesTuneId}/notes`, { method: 'POST', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
});
$('btn-add-inst-note').addEventListener('click', async () => {
const text = prompt('Note:');
if (!text) return;
try {
await apiFetch(`/tunes/${notesTuneId}/instruments/${notesEntryId}/notes`,
{ method: 'POST', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
});
async function editTuneNote(n) {
const text = prompt('Edit note:', n.note);
if (text === null) return;
try {
await apiFetch(`/tunes/${notesTuneId}/notes/${n.id}`,
{ method: 'PATCH', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function deleteTuneNote(noteId) {
if (!confirm('Delete this note?')) return;
try {
await apiFetch(`/tunes/${notesTuneId}/notes/${noteId}`, { method: 'DELETE' });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function editInstNote(n) {
const text = prompt('Edit note:', n.note);
if (text === null) return;
try {
await apiFetch(`/tunes/${notesTuneId}/instruments/${notesEntryId}/notes/${n.id}`,
{ method: 'PATCH', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function deleteInstNote(noteId) {
if (!confirm('Delete this note?')) return;
try {
await apiFetch(`/tunes/${notesTuneId}/instruments/${notesEntryId}/notes/${noteId}`,
{ method: 'DELETE' });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function reloadNotesModal() {
try {
const fullTune = await apiFetch(`/tunes/${notesTuneId}`);
const entry = notesEntryId ? fullTune.instruments.find(e => e.id === notesEntryId) : null;
renderTuneNotes(fullTune.notes ?? []);
renderInstNotes(entry?.notes ?? []);
// Keep has_notes in sync so the 📝 button stays accurate
const idx = allTunes.findIndex(t => t.id === notesTuneId);
if (idx >= 0) {
allTunes[idx].has_notes = !!fullTune.notes?.length
|| fullTune.instruments.some(e => !!e.notes?.length);
}
} catch (e) {
setStatus(e.message, true);
}
}
$('btn-notes-close').addEventListener('click', () => notesModal.close());
// ── References display modal ────────────────────────────────────
$('btn-refs-modal-close').addEventListener('click', () => refsModal.close());
async function openRefsModal(tuneId) {
const tune = allTunes.find(t => t.id === tuneId);
$('refs-modal-title').textContent = `References — ${tune?.name ?? ''}`;
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">Loading…</p>';
refsModal.showModal();
try {
const refs = await apiFetch(`/tunes/${tuneId}/references`);
refsModalList.innerHTML = '';
if (refs.length === 0) {
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">No references.</p>';
} 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(' · ');
item.innerHTML = `
<a href="${esc(r.link ?? '')}" target="_blank" rel="noopener" class="ref-link-anchor">${esc(r.link ?? r.site?.name ?? 'link')}</a>
${meta ? `<span class="ref-meta">${esc(meta)}</span>` : ''}`;
refsModalList.appendChild(item);
});
}
} catch (e) {
refsModalList.innerHTML = `<p style="color:#eb5757;font-size:.875rem">${esc(e.message)}</p>`;
}
}
// ── 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 = '<option value="">All instruments</option>';
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 = '<option value="">All sources</option>';
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 = '<p class="bulk-refs-empty">No tunes match the current filters.</p>';
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
? `<span class="pill pill-yes">${refCount} ref${refCount !== 1 ? 's' : ''}</span>`
: `<span class="pill" style="background:#222">0</span>`;
row.innerHTML = `
<div class="bulk-tune-main">
<span class="bulk-tune-name">${esc(tune.name ?? '')}</span>
<span class="bulk-tune-meta">${[esc(tune.key ?? ''), esc(tune.source?.name ?? '')].filter(Boolean).join(' · ')}</span>
<span class="bulk-tune-instruments">${esc(instruments)}</span>
<span class="bulk-tune-refs">${refPill}</span>
<button class="btn-bulk-add-ref action-btn">+ ref</button>
</div>
<div class="bulk-ref-form" hidden>
<input type="text" class="bulk-ref-url" placeholder="URL" />
<span class="autocomplete-row">
<select class="bulk-ref-site"><option value="">— site —</option>${allReferenceSites.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('')}</select>
<button class="btn-bulk-quick-add-site quick-add-btn" type="button" title="Add new site">+</button>
</span>
<button class="btn-bulk-ref-save">Add</button>
<button class="btn-bulk-ref-cancel remove-btn">✕</button>
<div class="bulk-ref-musicians-mount"></div>
</div>`;
const form = row.querySelector('.bulk-ref-form');
const urlInput = row.querySelector('.bulk-ref-url');
const siteSel = row.querySelector('.bulk-ref-site');
const btnAdd = row.querySelector('.btn-bulk-add-ref');
const btnSave = row.querySelector('.btn-bulk-ref-save');
const btnCancel = row.querySelector('.btn-bulk-ref-cancel');
const btnQuickAddSite = row.querySelector('.btn-bulk-quick-add-site');
const picker = makeMusicianPicker();
row.querySelector('.bulk-ref-musicians-mount').appendChild(picker.el);
btnAdd.addEventListener('click', () => {
form.hidden = !form.hidden;
if (!form.hidden) urlInput.focus();
});
btnCancel.addEventListener('click', () => {
form.hidden = true;
urlInput.value = '';
siteSel.value = '';
picker.reset();
});
btnQuickAddSite.addEventListener('click', async () => {
const item = await quickAdd('/reference_sites/', 'New site name:', allReferenceSites, null);
if (item) {
// Add the new option to every open site select in the list
bulkRefsList.querySelectorAll('.bulk-ref-site').forEach(sel => {
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name;
sel.appendChild(opt);
});
siteSel.value = item.id;
}
});
const submitRef = async () => {
const link = urlInput.value.trim();
const siteId = siteSel.value ? parseInt(siteSel.value) : null;
const musicians = picker.getMusicians();
if (!link && !siteId) return;
btnSave.disabled = true;
try {
await apiFetch(`/tunes/${tune.id}/references`, {
method: 'POST',
body: JSON.stringify({ link: link || null, site_id: siteId, musicians }),
});
await loadAll();
renderBulkRefsList();
} catch (e) {
setStatus(e.message, true);
btnSave.disabled = false;
}
};
btnSave.addEventListener('click', submitRef);
urlInput.addEventListener('keydown', e => {
if (e.key === 'Enter') submitRef();
if (e.key === 'Escape') btnCancel.click();
});
bulkRefsList.appendChild(row);
});
}
// ── Utility ───────────────────────────────────────────────────
function esc(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── 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');
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();