// ==UserScript== // @name String Theory // @namespace https://github.com/majkinetor/musicbrainz-userscripts // @version 2026.10.4.004341 // @description Unified bundle of 8 MusicBrainz userscripts (apollo_editor, art_station, credit_hoarder, fusion, group_therapy, isrc_scout, mammoth, platform_check). Built by userscripts/string_theory/build.mjs — do not hand-edit. // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA2NCA2NCIgd2lkdGg9IjY0IiBoZWlnaHQ9IjY0Ij4KICA8IS0tIGh1Yi1hbmQtc3Bva2UgIm5ldHdvcmsiIGdseXBoLCBzaW5nbGUgdml2aWQgdmlvbGV0IG9uIHRyYW5zcGFyZW50IHNvIGl0IHJlYWRzIG9uIGJvdGggZGFyayBhbmQgbGlnaHQgcGFnZXMgLS0+CiAgPGcgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjN2M1Y2ZmIiBzdHJva2Utd2lkdGg9IjQuNiIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj4KICAgIDxwYXRoIGQ9Ik0zMiAzMiBMMzIgMTUiLz4KICAgIDxwYXRoIGQ9Ik0zMiAzMiBMNDYuNSAyMy41Ii8+CiAgICA8cGF0aCBkPSJNMzIgMzIgTDQ2LjUgNDAuNSIvPgogICAgPHBhdGggZD0iTTMyIDMyIEwzMiA0OSIvPgogICAgPHBhdGggZD0iTTMyIDMyIEwxNy41IDQwLjUiLz4KICAgIDxwYXRoIGQ9Ik0zMiAzMiBMMTcuNSAyMy41Ii8+CiAgPC9nPgogIDxnIGZpbGw9IiM3YzVjZmYiPgogICAgPGNpcmNsZSBjeD0iMzIiIGN5PSIzMiIgcj0iOC42Ii8+CiAgICA8Y2lyY2xlIGN4PSIxNSIgY3k9IjE5LjUiIHI9IjYuNCIvPgogICAgPGNpcmNsZSBjeD0iNDkiIGN5PSIxOS41IiByPSI2LjQiLz4KICAgIDxjaXJjbGUgY3g9IjMyIiBjeT0iNTciIHI9IjYuNCIvPgogIDwvZz4KICA8ZyBmaWxsPSJub25lIiBzdHJva2U9IiM3YzVjZmYiIHN0cm9rZS13aWR0aD0iMy44Ij4KICAgIDxjaXJjbGUgY3g9IjMyIiBjeT0iNyIgcj0iNC45Ii8+CiAgICA8Y2lyY2xlIGN4PSIxNSIgY3k9IjQ0LjUiIHI9IjQuOSIvPgogICAgPGNpcmNsZSBjeD0iNDkiIGN5PSI0NC41IiByPSI0LjkiLz4KICA8L2c+Cjwvc3ZnPgo= // @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/tree/main/userscripts/string_theory // @run-at document-start // @match https://*.musicbrainz.org/release/add* // @match https://*.musicbrainz.org/release/*/edit // @match https://*.musicbrainz.org/*/edit_annotation // @match https://*.musicbrainz.org/artist/* // @grant GM_xmlhttpRequest // @grant GM_openInTab // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @connect * // @match *://*.musicbrainz.org/release/*/cover-art* // @match *://*.musicbrainz.org/release/*/add-cover-art* // @match *://*.musicbrainz.org/event/*/event-art* // @match *://*.musicbrainz.org/event/*/add-event-art* // @grant GM.xmlHttpRequest // @match https://*.musicbrainz.org/release/*/edit-relationships // @match https://*.musicbrainz.org/label/* // @match https://*.musicbrainz.org/place/* // @match https://tidal.com/album/* // @match https://listen.tidal.com/album/* // @match https://www.metal-archives.com/albums/* // @connect qobuz.com // @connect www.qobuz.com // @connect deezer.com // @connect www.deezer.com // @connect music.apple.com // @connect amp-api.music.apple.com // @connect www.metal-archives.com // @connect music.youtube.com // @grant GM_deleteValue // @grant GM_addValueChangeListener // @grant GM_removeValueChangeListener // @match https://*.musicbrainz.org/release/* // @match https://*.musicbrainz.org/release-group/* // @match https://*.musicbrainz.org/recording/* // @match https://*.musicbrainz.org/artist/*/recordings // @connect musicbrainz.org // @connect beta.musicbrainz.org // @connect api.acoustid.org // @match *://*.musicbrainz.org/release/*/edit-relationships // @match *://*.musicbrainz.org/artist/* // @match *://*.musicbrainz.org/label/* // @match *://*.musicbrainz.org/place/* // @match https://*.musicbrainz.org/oauth2/oob* // @match https://www.beatport.com/release/* // @connect isrc-api.soundexchange.com // @connect isrc.soundexchange.com // @connect api.deezer.com // @connect isrchunt.com // @connect isrc.mollamusicgroup.com // @connect openapi.tidal.com // @connect auth.tidal.com // @connect volumo.com // @connect hdtracks.azurewebsites.net // @connect api.beatport.com // @connect bandcamp.com // @connect soundcloud.com // @connect api-v2.soundcloud.com // @connect api.audiomack.com // @connect api.7digital.com // @connect us.7digital.com // @connect js-cdn.7digital.com // @connect a-v2.sndcdn.com // @connect open.spotify.com // @connect music.amazon.com // @connect na.mesk.skill.music.a2z.com // @match https://*.musicbrainz.org/* // @connect query.wikidata.org // @connect search.brave.com // @connect html.duckduckgo.com // @connect duckduckgo.com // @connect api.discogs.com // @connect www.discogs.com // @connect itunes.apple.com // @connect tools.wallstream.com // ==/UserScript== // Auto-generated by userscripts/string_theory/build.mjs — do NOT edit by hand. // Bundles (verbatim, each wrapped in a run-at gate): apollo_editor, art_station, credit_hoarder, fusion, group_therapy, isrc_scout, mammoth, platform_check. try { console.log('%c String Theory %c v2026.10.4.004341 ', 'background:#7c5cff;color:#fff;font-weight:bold;border-radius:3px;padding:2px 6px', 'color:#7c5cff;font-weight:bold'); console.log("String Theory bundles:\n · Apollo Editor v2026.10.4\n · Art Station v2026.10.4\n · Credit Hoarder v2026.10.4\n · Fusion v2026.10.4\n · Group Therapy v2026.10.4\n · ISRC Scout v2026.10.4\n · Mammoth v2026.10.4\n · Platform Check v2026.10.4"); } catch (e) {} // ===== apollo_editor (@run-at document-start) ===================================== (function(__stGM){ var GM_info = __stGM ? Object.assign({}, __stGM, { script: Object.assign({}, __stGM.script || {}, {"name":"Apollo Editor*","namespace":"https://musicbrainz.org/","version":"2026.10.4","description":"Speed up per-track artist-credit resolution in the MusicBrainz release editor — bulk-match each track's artist text to an MB artist (sibling releases in the release group first, then search), one-click apply, multi-artist aware, create-on-the-fly. Same table whether floating or replacing the integrated tracklist.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md","supportURL":null,"icon":"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M13 22 L19 22 L16 30 Z' fill='%23ff8c3b'/%3E%3Cpath d='M14.4 22 L17.6 22 L16 27 Z' fill='%23ffd24a'/%3E%3Cpath d='M12 18 L8 23.5 L12 22 Z' fill='%233d2470'/%3E%3Cpath d='M20 18 L24 23.5 L20 22 Z' fill='%233d2470'/%3E%3Cpath d='M16 2.5 C19 7 20 12 20 16 L20 22 L12 22 L12 16 C12 12 13 7 16 2.5 Z' fill='%235f3ec0'/%3E%3Ccircle cx='16' cy='12.5' r='3' fill='%23cfe8ff' stroke='%232a1a52' stroke-width='1'/%3E%3C/svg%3E"}) }) : { script: {"name":"Apollo Editor*","namespace":"https://musicbrainz.org/","version":"2026.10.4","description":"Speed up per-track artist-credit resolution in the MusicBrainz release editor — bulk-match each track's artist text to an MB artist (sibling releases in the release group first, then search), one-click apply, multi-artist aware, create-on-the-fly. Same table whether floating or replacing the integrated tracklist.","author":"majkinetor","homepage":null,"homepageURL":"https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md","supportURL":null,"icon":"data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Cpath d='M13 22 L19 22 L16 30 Z' fill='%23ff8c3b'/%3E%3Cpath d='M14.4 22 L17.6 22 L16 27 Z' fill='%23ffd24a'/%3E%3Cpath d='M12 18 L8 23.5 L12 22 Z' fill='%233d2470'/%3E%3Cpath d='M20 18 L24 23.5 L20 22 Z' fill='%233d2470'/%3E%3Cpath d='M16 2.5 C19 7 20 12 20 16 L20 22 L12 22 L12 16 C12 12 13 7 16 2.5 Z' fill='%235f3ec0'/%3E%3Ccircle cx='16' cy='12.5' r='3' fill='%23cfe8ff' stroke='%232a1a52' stroke-width='1'/%3E%3C/svg%3E"} }; (f=>f())(function(){ try { (function(){ /* * Editor model (discovered via test/ spikes): * read MB.releaseEditor.rootField.release().mediums()[m].tracks()[t] * .artistCredit() → { names:[{ artist:{name,gid,id}, name(creditedAs), joinPhrase }] } * search GET /ws/js/artist?q=&direct=false → full entities (incl. numeric id). * sibling GET /ws/2/release?release-group=&inc=recordings+artist-credits → other versions' * per-track credits with gids; disambiguates search hits by title. * write track.artistCredit({ names:[{ artist: fullEntity, name: creditedAs, joinPhrase }] }) * ops ed.removeTrack(t) · ed.moveTrackUp(t)/moveTrackDown(t) · track.name(s) · * track.length(ms) · track.formattedLength() · ed.utils.unformatTrackLength('3:53') */ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('apollo_editor', 'Apollo Editor')) return; // the shared MusicBrainz request gate (#633) — first, so nothing can call it before its consts exist // — generated by dev/net/sync-gate.mjs from dev/net/mb-gate.mjs — DO NOT EDIT const MBN_GAP = 1000; const MBN_BURST = 3; const MBN_KEY = "mbu:mb-gate"; const MBN_LOCK = "mbu-mb-gate"; const MBN_MAX_HOLD = 60000; function mbnGated(url) { const s = String(url || ''); return /^\/ws\/2\//.test(s) || /^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org\/ws\/2\//i.test(s); } async function mbnState(fn) { const rw = () => { let s = null, stored = true; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null'); } catch (e) { stored = false; } const g = globalThis.__mbnGate || (globalThis.__mbnGate = { tat: 0, cool: 0, hot: 0 }); if (!stored) s = g; // no storage: this page's copy only else if (!s || typeof s !== 'object') s = { tat: 0, cool: 0, hot: 0 }; // none yet (or cleared): a fresh schedule const out = fn(s); Object.assign(g, s); try { localStorage.setItem(MBN_KEY, JSON.stringify({ tat: s.tat || 0, cool: s.cool || 0, hot: s.hot || 0 })); } catch (e) {} return out; }; // The lock is held only for rw()'s synchronous moment, so a wait of seconds means it is // stuck (a frozen or sandboxed holder): give up on it and go unlocked rather than hang // every request behind it. let timer = 0; try { if (typeof navigator !== 'undefined' && navigator.locks && navigator.locks.request) { const ctrl = typeof AbortController === 'function' ? new AbortController() : null; if (ctrl) timer = setTimeout(() => ctrl.abort(), 3000); return await navigator.locks.request(MBN_LOCK, ctrl ? { signal: ctrl.signal } : {}, rw); } } catch (e) { /* no Web Locks here (an insecure or sandboxed context), or stuck: unlocked, like a lone script */ } finally { clearTimeout(timer); } return rw(); } async function mbnSlot(o) { o = o || {}; const t0 = Date.now(), background = o.background !== false; const booked = await mbnState(s => { const now = Date.now(), cool = s.cool || 0, tat = s.tat || 0; let at = background ? Math.max(now, tat - (MBN_BURST - 1) * MBN_GAP) : now; at = Math.max(at, cool); s.tat = Math.max(tat, at) + MBN_GAP; return { at, why: cool > now && cool >= at ? 'MusicBrainz asked everyone to wait' : 'pacing, one request a second' }; }); let told = false; for (;;) { if (o.cancelled && o.cancelled()) return { ok: false, waited: Date.now() - t0 }; let cool = 0; try { cool = (JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}).cool || 0; } catch (e) { cool = (globalThis.__mbnGate || {}).cool || 0; } const until = Math.max(booked.at, cool), left = until - Date.now(); // a hold that appeared while we queued if (left <= 0) break; if (!told && until - t0 > 1000 && o.log) { told = true; o.log('info', 'MusicBrainz gate: ' + (o.label || 'request') + ' waits ' + Math.round((until - t0) / 100) / 10 + 's (' + (cool > booked.at ? 'MusicBrainz asked everyone to wait' : booked.why) + ')'); } await new Promise(r => setTimeout(r, Math.min(left, 250))); // in slices, so a cancel lands promptly } return { ok: true, waited: Date.now() - t0 }; } async function mbnAnswer(status, header, o) { o = o || {}; if (status === 429 || status === 503) { const raw = header ? header('Retry-After') : null; const secs = Number(raw), date = raw ? Date.parse(raw) : NaN; const ra = Number.isFinite(secs) ? secs * 1000 : Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0; const hold = await mbnState(s => { const now = Date.now(); // One throttle, one step: the requests already in flight when it began all answer // 503 too, and each must not stretch the hold again. A throttle long gone doesn't // count at all: `hot` is shared and persists, and would otherwise slow every later // page for good. if (now > (s.cool || 0) + 30000) s.hot = 0; if (now >= (s.cool || 0)) s.hot = Math.min((s.hot || 0) + 1, 6); const ms = Math.min(Math.max(1000, ra) * Math.max(1, s.hot), MBN_MAX_HOLD); s.cool = Math.max(s.cool || 0, Date.now() + ms); return s.cool - Date.now(); }); if (o.log) o.log('warn', 'MusicBrainz gate: HTTP ' + status + (raw != null ? ' (Retry-After: ' + raw + ')' : '') + ' — every script holds ' + Math.round(hold / 100) / 10 + 's'); return { throttled: true, hold }; } if (status >= 200 && status < 500) await mbnState(s => { if (s.hot) s.hot = Date.now() > (s.cool || 0) + 30000 ? 0 : s.hot - 1; }); return { throttled: false, hold: 0 }; } async function mbnFetch(url, init, o) { o = o || {}; if (!mbnGated(url)) return fetch(url, init); const tries = o.tries || 4; for (let attempt = 1; ; attempt++) { const slot = await mbnSlot(o); if (!slot.ok) return null; const r = await fetch(url, init); const a = await mbnAnswer(r.status, n => r.headers.get(n), o); if (!a.throttled || attempt >= tries) return r; } } function mbnHot() { let s; try { s = JSON.parse(localStorage.getItem(MBN_KEY) || 'null') || {}; } catch (e) { s = globalThis.__mbnGate || {}; } return Date.now() > (s.cool || 0) + 30000 ? 0 : (s.hot || 0); // a throttle long gone is over } function mbnRawHeader(raw, name) { const want = String(name).toLowerCase(); for (const line of String(raw || '').split(/\r?\n/)) { const i = line.indexOf(':'); if (i > 0 && line.slice(0, i).trim().toLowerCase() === want) return line.slice(i + 1).trim(); } return null; } // // #522 follow-up (majkinetor, live): "why is LastPass recognizing edits as // passwords? I noticed that in apollo credited as too" — password managers // heuristically flag plain text inputs with no autocomplete/ignore hint. // None of this tool's inputs are credentials, so opt them out. const noPw = e => { e.autocomplete = 'off'; e.setAttribute('data-lpignore', 'true'); e.setAttribute('data-1p-ignore', 'true'); e.setAttribute('data-bwignore', 'true'); e.setAttribute('data-form-type', 'other'); return e; }; const NOPW_ATTRS = 'autocomplete="off" data-lpignore="true" data-1p-ignore="true" data-bwignore="true" data-form-type="other"'; // #283 every Log.* call is captured into an in-page buffer and surfaced in a // dedicated log viewer — opened from a Log button next to "? Help" — // copy/pastable as a Markdown
block, like the other scripts. (The // console output was dropped: it just duplicated this buffer.) The buffer and // window are the shared mbuLog (ST-UI block), since X12 of #623. const Log = mbuLog({ name: 'Apollo Editor', version: () => scriptVersion(), key: 'apolloEditor.logwin', load: k => gmLoad(k), save: (k, v) => gmSave(k, v), before: () => style(), }); const logMarkdown = () => Log.markdown(); const openLog = () => Log.open(); // #501: settings live in GM storage (backed up and synced by the script manager). // The one-time adoption of an older localStorage copy is retired (#623). const gmLoad = (key) => { try { return GM_getValue(key, undefined); } catch (e) { return undefined; } }; const gmSave = (key, raw) => { try { GM_setValue(key, raw); } catch (e) {} }; // first log line: the script + version. The MB release line is logged once the // editor is ready (so it carries the real title) — see init(). Log.info(mbuStartupInfo('Apollo Editor')); const W = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window; const ORIGIN = location.origin; const u = v => { try { return typeof v === 'function' ? v() : v; } catch (e) { return undefined; } }; const getEditor = () => { try { return W.MB && W.MB.releaseEditor; } catch (e) { return null; } }; // #613 shared matching (dev/match/artist-match.mjs) — the exact-identity rule, the // special-purpose artist list, and the name folding Apollo, Group Therapy and Credit // Hoarder share. Filled in by dev/match/sync-match.mjs (pre-commit hook). // — generated by dev/match/sync-match.mjs from dev/match/artist-match.mjs — DO NOT EDIT const MBM_EXACT_LIMIT = 100; const MBM_SPECIAL_PURPOSE = new Set(["125ec42a-7229-4250-afc5-e057484327fe","f731ccc4-e22a-43af-a747-64213329e088","33cf029c-63b0-41a0-9855-be2a3665fb3b","314e1c25-dde7-4e4d-b2f4-0a7b9f7c56dc","eec63d3c-3b81-4ad4-b1e4-7c147d4d2b61","9be7f096-97ec-4615-8957-8d40b5dcbc41","89ad4ac3-39f7-470e-963a-56509c546377","7e84f845-ac16-41fe-9ff8-df12eb32af55","66ea0139-149f-4a0c-8fbf-5ea9ec4a6e49","a0ef7e1d-44ff-4039-9435-7d5fefdeecc9","90068d37-bae7-4292-be4a-704c145bd616","80a8851f-444c-4539-892b-ad2a49292aa9"]); function mbmFold(s) { return String(s == null ? '' : s).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); } function mbmFoldKeepCase(s) { return String(s == null ? '' : s).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').replace(/\s+/g, ' ').trim(); } function mbmSameName(a, b) { return mbmFold(a) === mbmFold(b) && mbmFold(a) !== ''; } function mbmSameNameCase(a, b) { return mbmFoldKeepCase(a) === mbmFoldKeepCase(b) && mbmFoldKeepCase(a) !== ''; } function mbmHolds(entity, name, caseExact) { if (!entity) return null; const same = caseExact ? mbmSameNameCase : mbmSameName; if (same(entity.name, name)) return 'name'; if ((entity.aliases || []).some(al => same(al && (al.name != null ? al.name : al), name))) return 'alias'; return null; } function mbmIdentityQuery(name, field) { const q = String(name == null ? '' : name).replace(/["\\]/g, ' ').replace(/\s+/g, ' ').trim(); return q ? 'alias:"' + q + '" OR ' + (field || 'artist') + ':"' + q + '"' : ''; } function mbmExactIdentity(json, name, opts) { const o = opts || {}; if (!json || typeof json !== 'object') return { status: 'failed', exact: [] }; const list = json.artists || json.labels || json.places || []; let exact = list.filter(e => mbmHolds(e, name)); if (exact.length > 1) { // several case-insensitive holders, exactly one WITH case → that one (#445 "Kasane Teto") const caseExact = exact.filter(e => mbmHolds(e, name, true)); if (caseExact.length === 1) exact = caseExact; else if (o.scoreGap) { const scored = exact.filter(e => typeof e.score === 'number').sort((a, b) => b.score - a.score); if (scored.length >= 2 && scored[0].score - scored[1].score >= o.scoreGap) exact = [scored[0]]; } } const offset = typeof json.offset === 'number' ? json.offset : 0; const complete = typeof json.count === 'number' && json.count <= offset + list.length; if (exact.length === 1 && complete) return { status: 'unique', hit: exact[0], via: mbmHolds(exact[0], name) === 'name' ? 'name' : 'alias', exact, complete }; if (exact.length > 1) return { status: 'ambiguous', exact, complete }; if (!complete) return { status: 'incomplete', exact, complete }; return { status: 'none', exact, complete }; } function mbmRelatedArtists(artistJson) { if (!artistJson || !artistJson.id) return []; const out = [{ gid: artistJson.id, name: artistJson.name || '', aliases: (artistJson.aliases || []).map(a => a && a.name).filter(Boolean), rel: 'self' }]; for (const r of artistJson.relations || []) { const a = r && r.artist; if (!a || !a.id || out.some(x => x.gid === a.id)) continue; out.push({ gid: a.id, name: a.name || '', aliases: [], rel: r.type || '' }); } return out; } function mbmContextHolders(related, name, candidates) { const cand = new Map((candidates || []).map(c => [c.id || c.gid, c])); const out = []; for (const r of related || []) { let via = mbmSameName(r.name, name) ? 'name' : (r.aliases || []).some(a => mbmSameName(a, name)) ? 'alias' : null; if (!via) { const c = cand.get(r.gid); if (c && mbmHolds(c, name)) via = mbmHolds(c, name); } if (via && !out.some(x => x.gid === r.gid)) out.push({ gid: r.gid, name: r.name, via, rel: r.rel }); } return out; } function mbmCoCreditHits(recordingsJson, ctxGid, name) { const out = []; for (const rec of (recordingsJson && recordingsJson.recordings) || []) { for (const c of rec['artist-credit'] || []) { const a = c && c.artist; if (!a || !a.id || a.id === ctxGid) continue; if ((mbmSameName(c.name, name) || mbmSameName(a.name, name)) && !out.some(x => x.gid === a.id)) out.push({ gid: a.id, name: a.name }); } } return out; } function mbmGuessSortName(name) { if (!name || !name.trim()) return name; name = name.trim().replace(/\s+/g, ' '); // names in other scripts are not "given family" in Latin order: leave them to the editor if (/[^\p{Script=Latin}\p{Script=Common}\p{Script=Inherited}]/u.test(name)) return name; const words = name.split(' '); if (words.length === 1) return name; const article = name.match(/^(the|a|an)\s+(.+)$/i); if (article) return article[2] + ', ' + article[1].charAt(0).toUpperCase() + article[1].slice(1).toLowerCase(); let base = name, suffix = ''; const sfx = name.match(/^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i); if (sfx) { base = sfx[1].trim(); suffix = ' ' + sfx[2]; } const parts = base.split(' '); if (parts.length === 1) return name; return parts[parts.length - 1] + ', ' + parts.slice(0, -1).join(' ') + suffix; } // // normalize hyphen/dash look-alikes (MB uses ‐ U+2010, others use - U+002D, en/em // dashes, minus…) to a plain '-' so e.g. "Gol‐e Yakh" folds the same as "Gol-e Yakh" const fold = s => (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); const sameName = (a, b) => fold(a) === fold(b); // gid → artist disambiguation, harvested from every WS2/js artist-credit the // script fetches (search results, recording lookups). The MB page (KO) model // doesn't carry disambiguations for freshly-picked entities, so the recordings // table falls back to this cache to show them after a pick. #195 const _disamb = new Map(); const noteDisamb = (gid, c) => { if (gid && c) _disamb.set(gid, c); }; const getDisamb = gid => (gid && _disamb.get(gid)) || ''; const MBID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; // a MusicBrainz /artist/ URL, a bare MBID, or an MBID pasted anywhere in the text → the gid function mbidFrom(v) { v = (v || '').trim(); const url = v.match(new RegExp('musicbrainz\\.org/artist/(' + MBID_RE.source + ')', 'i')); if (url) return url[1].toLowerCase(); const m = v.match(new RegExp('(?:^|[\\s/])(' + MBID_RE.source + ')(?:[\\s/?#]|$)', 'i')); return m ? m[1].toLowerCase() : null; } // an ISRC (CC-XXX-YY-NNNNN), with or without separators, found as a standalone // token → the normalised 12-char uppercase form. Word-boundaried so it won't // fire mid-string; the structure (2 alpha · 3 alphanum · 7 digit) is specific // enough not to collide with a bare MBID. #196 const ISRC_TOKEN = /\b([A-Za-z]{2})-?([A-Za-z0-9]{3})-?([0-9]{2})-?([0-9]{5})\b/; function isrcFrom(v) { const m = String(v || '').match(ISRC_TOKEN); return m ? (m[1] + m[2] + m[3] + m[4]).toUpperCase() : null; } /* ── create-artist-in-a-tab → auto-insert (BroadcastChannel handshake, like the Discogs importer) ── */ const ART_CHANNEL = ('BroadcastChannel' in W) ? new W.BroadcastChannel('apollo-editor-artist') : null; const PENDING_KEY = 'apolloEditor.pendingArtist'; const CLOSE_KEY = 'apolloEditor.closeAfterEdit'; // tab opened just to add a Discogs link → self-close after submit const _pendingCreates = new Map(); let _createSeq = 0; if (ART_CHANNEL) ART_CHANNEL.addEventListener('message', e => { const d = e.data; if (!d || d.type !== 'tc-artist-created') return; const pend = _pendingCreates.get(d.token); if (!pend) return; _pendingCreates.delete(d.token); if (!d.gid) { Log.warn('artist created but no gid came back'); return; } // The postMessage can only carry a plain {gid,name,id}, but commitTrack writes // `artist: ` into the NATIVE artist credit — and MB only treats it as a // real linked artist when it's the WHOLE entity (the same shape fetchEntity / the // paste path produce). A plain object renders in the Apollo table but is dropped // from the native model on submit. So re-fetch the full entity in THIS tab (the // artist is already indexed — the create tab fetched it before posting back). #191 fetchEntity(d.gid).then(ent => { if (pend.slot && typeof pend.slot._onPick === 'function') pend.slot._onPick(ent || { gid: d.gid, name: d.name, id: d.id }); // #652: a release artist else pickArtist(pend.slot, ent || { gid: d.gid, name: d.name, id: d.id }); // #273: close the background create tab via its handle (a GM-opened tab can't always self-close). try { if (pend.bgTab && typeof pend.bgTab.close === 'function') pend.bgTab.close(); } catch (x) {} Log.info('inserted newly-created artist', JSON.stringify(d.name), 'into the table' + (ent ? '' : ' (plain fallback — native link may be incomplete)')); }); }); /* ── settings ── */ const SKEY = 'apolloEditor.settings.v1'; function loadSettings() { const d = { apolloEnabled: true, colWidths: {}, applyMode: 'all', altRows: false, gridCols: false, gridRows: true, replaceReleaseInfo: true, replaceTracklist: true, replaceRecordings: true, modifyAnnotation: true, modifyDuplicates: true, autoMatch: false, autoMatchRec: false, autoMatchLabel: true, autoMatchArtist: true, discogsUrlMatch: true, recLenTol: 5, recIgnoreCase: true, recIgnorePunct: true, recTitleTol: 1, recCutoff: 'near', recDetailedHl: true, recPunctSize: 3, recHlColor: '#e53935', lastTool: '', layout: 'normal', lastView: 'apollo', zenMode: true, autoConfirmSeed: true, keepCaretColumn: true, hoverHighlight: false, srRegex: false, srTemplates: [], srSeedV: 0, srHistory: [], srDefault: '', srHistoryOpen: false }; try { const stored = JSON.parse(gmLoad(SKEY) || '{}'); const s = Object.assign(d, stored); if (stored.gridCols === undefined && stored.grid !== undefined) s.gridCols = stored.grid; return s; } catch (e) { return d; } } function saveSettings() { try { gmSave(SKEY, JSON.stringify(SETTINGS)); } catch (e) {} } let SETTINGS = loadSettings(); try { srSeedTemplates(); } catch (e) {} // #375 seed the default S&R templates once let _cfgTab = 'general'; // remembered settings tab (#294), per session // measure a name's pixel width so an artist input can shrink-wrap to its text (so the // clear × hugs the name instead of sitting at the box's far right). #284 follow-up. const _nmMeasCtx = (() => { try { return document.createElement('canvas').getContext('2d'); } catch (e) { return null; } })(); const fitNmWidth = inp => { if (!_nmMeasCtx) return; const v = inp.value; if (!v) { inp.style.width = ''; return; } // empty → let flex give it the full width _nmMeasCtx.font = '13px Arial'; // matches .tc-search .nm inp.style.width = (Math.ceil(_nmMeasCtx.measureText(v).width) + 5) + 'px'; }; // #394 any entity's standalone Edit-annotation page — ///edit_annotation (artist, recording, // work, event, label, release, …), not just release. The annotation editor is generic (it wraps the // textarea[name="edit-annotation.text"] every entity's page has). const ANNO_PAGE_RE = /^\/[a-z][a-z-]*\/[0-9a-f-]{36}\/edit_annotation/i; // FOUC guard (we run at document-start): on the standalone Edit annotation page, hide the native form until our // editor mounts (and removes #tc-anno-fouc), so the original interface never flashes. Skipped when off. if (ANNO_PAGE_RE.test(location.pathname) && SETTINGS.apolloEnabled !== false && SETTINGS.modifyAnnotation !== false) { const s = document.createElement('style'); s.id = 'tc-anno-fouc'; s.textContent = '#content > form{visibility:hidden}'; (document.head || document.documentElement).appendChild(s); setTimeout(() => document.getElementById('tc-anno-fouc')?.remove(), 6000); // safety: never hide forever if the editor fails to mount } function waitFor(check, { tries = 120, every = 500 } = {}) { return new Promise(res => { let n = 0; const t = () => { let v; try { v = check(); } catch (e) {} if (v) return res(v); if (++n >= tries) return res(null); setTimeout(t, every); }; t(); }); } /* ── model access ── */ function release() { return u(getEditor().rootField.release); } function mediums() { return u(release().mediums) || []; } function koTrack(mi, ti) { return u(mediums()[mi].tracks)[ti]; } // #376 pending-edit state for a track's recording: {rec} if the recording itself has open edits, // {art} if any artist in its credit does — MB marks each with span.mp (gold). function recPendingState(mi, ti) { try { const ko = koTrack(mi, ti), rec = u(ko.recording); // #376 check the CURRENTLY-shown artist credit (the track's), not the recording's — so picking a // different (non-pending) artist clears the highlight. Title pending stays recording-level. // Only a SELECTED artist (has a gid) counts — a free-text credit with no linked entity gets no // highlight (majkinetor: "remove highlight when no artist is selected — it's for a previously // selected artist"). artGids drives the per-SLOT gold so a free-text slot beside a pending one stays clean. const artGids = new Set(); const ac = u(ko.artistCredit), names = ac && u(ac.names); if (names) for (const n of names) { const a = u(n.artist); if (a && u(a.editsPending)) { const g = u(a.gid); if (g) artGids.add(g); } } return { rec: rec ? !!u(rec.editsPending) : false, art: artGids.size > 0, artGids }; } catch (e) { return null; } } // #376 a slot golds only when it's a SELECTED artist (committed, with a gid) whose entity is pending — // so editing a field to free text (which unselects the mbid) drops the gold on the next re-adorn. const slotIsPending = (artGids, slot) => !!(slot && slot.committed && slot.gid && artGids && artGids.has(slot.gid)); function liveNames(track) { const ac = u(track.artistCredit) || {}; return u(ac.names) || []; } const ORIGINALS = new Map(); /* chaban-mb on #586, verified in test/probe-586f-pregap-revert.mjs: the page-load snapshot was keyed by "medium:INDEX", and an index is not an identity. Tick the Pregap box and MusicBrainz inserts a track at index 0, so every later track shifts down one — "Revert all" then writes each snapshot onto the track BELOW the one it came from, and unticking Pregap deletes what is now index 0. The tracklist comes back shifted by one with the first track gone and the last duplicated. Nothing about it looks like a failure. MB gives every track a `uniqueID` that survives inserts and removals and is present on newly added tracks too ("new-17"), so the snapshot is keyed by that. The index remains only as a last resort for a track that somehow has none, which is also the only case that can still drift. */ const trackUid = t => { try { return t && t.uniqueID != null ? t.uniqueID : (t && t.id != null ? t.id : null); } catch (e) { return null; } }; const origKeyOf = (mi, t, ti) => { const uid = trackUid(t); return uid != null ? mi + ':#' + uid : mi + ':@' + ti; }; const origKey = (mi, ti) => { let t = null; try { t = koTrack(mi, ti); } catch (e) {} return origKeyOf(mi, t, ti); }; const snapTrack = t => ({ title: u(t.name) || '', number: u(t.number), length: u(t.formattedLength) || '', // #586 (chaban-mb: "Revert all function seems to not affect data tracks") — // the snapshot never carried the data-track flag, so Revert all restored the // cells and left the section where it was. isDataTrack: typeof t.isDataTrack === 'function' ? !!t.isDataTrack() : false, names: liveNames(t).map(n => ({ artist: u(n.artist) || { name: u(n.name) || '' }, creditedAs: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' })), }); function snapshotOriginals() { ORIGINALS.clear(); mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => ORIGINALS.set(origKeyOf(mi, t, ti), snapTrack(t)))); Log.info('snapshot of', ORIGINALS.size, 'original tracks'); } // MB lazy-loads each medium's tracks asynchronously, so the startup snapshot misses mediums that // hadn't loaded yet. Capture the page-load state of any track that appears later — before matching // writes to it — so change-tracking (the ↺ button + the changed-row border) works on every medium. function snapshotMissing() { let added = 0; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const k = origKeyOf(mi, t, ti); if (!ORIGINALS.has(k)) { ORIGINALS.set(k, snapTrack(t)); added++; } })); if (added) Log.info('snapshot +', added, 'newly loaded original track(s) →', ORIGINALS.size, 'total'); } function readTracklist() { const out = []; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const names = liveNames(t).map(n => { const a = u(n.artist) || null; return { creditedAs: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '', artistGid: a ? u(a.gid) : null, artistName: a ? u(a.name) : '' }; }); out.push({ mi, ti, number: u(t.number), title: u(t.name) || '', length: u(t.formattedLength) || '', names, resolved: names.length > 0 && names.every(n => n.artistGid) }); })); return out; } /* ── search + siblings ── */ const _cache = new Map(); // resolve an MBID to a full entity (incl. the numeric id needed for the credit write-back) async function fetchEntity(gid, kind) { try { const j = await fetch(`${ORIGIN}/ws/js/entity/${gid}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); // return the WHOLE entity (like a search hit) so the credit write-back has every field it needs if (j && j.gid) { if (!j.entityType) j.entityType = kind || 'artist'; return j; } } catch (e) { Log.warn('fetch entity failed', gid, e.message); } return null; } async function searchArtist(name, limit) { limit = limit || 8; const k = fold(name) + '|' + limit; if (!fold(name)) return []; if (_cache.has(k)) return _cache.get(k); let list = []; try { const j = await fetch(`${ORIGIN}/ws/js/artist?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) { Log.warn('search failed:', name, e.message); } list = list.filter(c => c && (c.name || '').trim()); // drop the trailing empty placeholder entry list.forEach(c => noteDisamb(c.gid || c.id, c.comment)); // cache disambiguations for the table after a pick (#195) _cache.set(k, list); return list; } // #442/#445: the UNIFIED exact-identity resolver — the single source of truth for a // confident exact match in matchSlot. Resolves a credited name to the unique MB artist // that carries it as an exact NAME or ALIAS (an alias is just an alternative name), and // accepts it ONLY when EXACTLY ONE artist across name+alias space has it. That makes the // check alias-AWARE like MB's own duplicate/dedup logic: a name that is ALSO another // artist's alias is ambiguous → declined (a candidate, not an auto-link) — the #445 fix. // The fast /ws/js search matchSlot uses is alias-blind, so this one /ws/2 lookup (which // carries aliases + matches the alias field) both catches an alias-only credit ("Don Abi" // → "Abiodun", #442) and enforces the unified unambiguity. Returns { entity, via } where // via is 'name' or 'alias' (label only), else null (ambiguous OR none). Cached per name. const _aliasMatchCache = new Map(); // `kind`: 'artist' (default) or 'label' — the label auto-match uses the same rule (#623, A5) const _aliasSeen = new Map(); // #654: what each name/alias search found, for the match tooltip const aliasSeen = (name, kind) => _aliasSeen.get(((kind || 'artist') === 'artist' ? '' : kind + ':') + fold(name)) || null; async function resolveByExactAlias(name, kind) { kind = kind || 'artist'; const key = (kind === 'artist' ? '' : kind + ':') + fold(name); if (!fold(name)) return null; if (_aliasMatchCache.has(key)) return _aliasMatchCache.get(key); const q = mbmIdentityQuery(name, kind); if (!q) return null; // a throttled lookup must NOT be cached as "no match" — that would freeze a // transient 503 into a permanent auto-match failure for this name (#555) const res = await wsJson(`${ORIGIN}/ws/2/${kind}?query=${encodeURIComponent(q)}&fmt=json&limit=${MBM_EXACT_LIMIT}`, { label: kind + ' alias search' }); if (!res.json) { Log.warn('alias search failed:', name, '— not cached, a later pass retries'); return null; } // #613: unique only when MB returned EVERY match. The search doesn't rank exact holders // first — `artist:"kim"` matches 2,777 artists and the one exact "Kim" among the first 25 // was taken as unique. A common name that can't be proven unique stays a candidate. const idn = mbmExactIdentity(res.json, name); Log.debug(kind + ' alias search:', JSON.stringify(name), '→', (res.json.artists || res.json.labels || []).length, 'of', res.json.count, 'match(es),', idn.exact.length, 'exact —', idn.status); if (idn.status === 'incomplete' && idn.exact.length) Log.info('Match:', JSON.stringify(name), '— one exact name/alias seen, but', res.json.count, 'artists match; not provably unique → left to pick (#613)'); _aliasSeen.set(key, { status: idn.status, exact: idn.exact.length, count: res.json.count, via: idn.via || null }); // #654: the tooltip says why it was or wasn't unique let out = null; // unambiguous only if (idn.status === 'unique') { // #445: 'name' when it's a real NAME hit (the /ws/js search under-ranked it), 'alias' when // only an alias matches — so the log/badge never calls a name match "via exact alias" const ent = await fetchEntity(idn.hit.id, kind); if (ent && ent.gid) out = { entity: ent, via: idn.via }; } _aliasMatchCache.set(key, out); return out; } /* ── label auto-match (#407) ────────────────────────────────────────────── * The release-info Label field is seeded (from Discogs, or typed) as plain text * with no MBID — the single most common thing majkinetor forgets to resolve. When * the name has exactly ONE exact MB label, select it automatically; when it's * ambiguous (e.g. "Columbia" → several) or has no exact hit, leave it for a human. * Labels live in the KO release model — `release().labels()[i].label` is an * observable holding the label entity, so we set it directly (verified: this also * fills the #label-N input and is picked up on submit). */ let _labelsAutoMatchedOnce = false; // Resolve every still-unset release label whose name has a unique exact MB hit. async function matchReleaseLabels() { if (SETTINGS.autoMatchLabel === false) return; const rel = release(); if (!rel) return; const labels = u(rel.labels) || []; if (!labels.length) return; // #652: a label credited by the release's Discogs page, or by First Contact's platform, is // linked by that link when exactly one MB label has it const dmap = SETTINGS.discogsUrlMatch === false ? null : await loadDiscogsMap().catch(() => null); const h = fcHandoff(); let linked = 0, lastName = ''; for (let i = 0; i < labels.length; i++) { const lf = labels[i]; if (!lf || typeof lf.label !== 'function') continue; const cur = lf.label(); const name = cur && cur.name; if (!name || (cur && cur.gid)) continue; // empty slot, or already resolved → leave it const set = (ent, st) => { let e = ent; // #623 (sweep, A1): the page's MB (unsafeWindow), as everywhere else — the sandbox // `window.MB` is absent in managers that isolate it, and the bare entity got written try { if (W.MB && typeof W.MB.entity === 'function') e = W.MB.entity(ent, 'label'); } catch (x) {} lf.label(e); _riLab.set(lf, Object.assign({ gid: ent.gid }, st)); linked++; lastName = ent.name; }; const url = riLabelUrl(name, i, labels.length, dmap, h); if (url) { const p = platformOf(url) || { abbr: 'link', name: 'platform' }; const hits = (await resolvePlatformUrls([url], 'label')).get(url); if (hits && hits.length === 1) { const ent = await fetchEntity(hits[0].gid, 'label'); if (ent && ent.gid) { try { set(ent, { status: p.abbr === 'disc' ? 'disc' : 'plat', abbr: p.abbr, title: `matched by its ${p.name} link: ${url}` }); Log.info('Label match:', name, '→', ent.name, '(' + ent.gid + ') — via', p.name, 'link', url); continue; } catch (e) { Log.warn('label set failed', name, e.message); } } } else Log.debug('Label:', name, '—', p.name, 'link', url, hits == null ? 'lookup unavailable' : hits.length ? hits.length + ' labels have it' : 'not in MusicBrainz', '→ the name'); } // #623 (sweep, A5): "exactly one exact hit" was judged on the first 8 search results, // which don't rank exact matches first — a second label of that name further down was // invisible. The same rule as track artists now: unique by name or alias among ALL // matches (#613), else left for a human. const idHit = await resolveByExactAlias(name, 'label'); if (!idHit) { Log.info('Label:', name, '— no unique exact MB label (name or alias) — left unset'); continue; } try { set(idHit.entity, { status: idHit.via === 'alias' ? 'alias' : 'high', title: idHit.via === 'alias' ? 'the only MusicBrainz label with this name as an alias' : 'the only MusicBrainz label with this name' }); Log.info('Label match:', name, '→', idHit.entity.name, '(' + idHit.entity.gid + ')'); } catch (e) { Log.warn('label set failed', name, e.message); } } renderRiLabels(); if (linked) toast(linked === 1 ? ('✓ Label matched: ' + lastName) : ('✓ Auto-matched ' + linked + ' labels')); } /* ── #652: the release artist, matched like a track artist ───────────────────────── * The release artist credit runs through the Tracklist's stages, most confident first: * the release's Discogs artists, First Contact's platform links, the release artist of the * other editions (the release group's, then the duplicates': the requests the Tracklist * already makes), the exact name or alias, and co-credit with the artists on the tracks. * A confident match is written into the credit (the full entity, #348); each name gets the * Tracklist's badge, match card and offers (+ / 🔗 / ⚠) next to the field. */ let _artistAutoMatchedOnce = false; let _riArt = []; // one slot-like record per release artist name: what matching found const riNames = () => { try { const ac = u(release().artistCredit); return (ac && u(ac.names)) || []; } catch (e) { return []; } }; // the artists already on the tracks, most credited first: the co-credit stage's context function riContextGids() { const count = new Map(); try { readTracklist().forEach(t => t.names.forEach(n => { if (n.artistGid && coCreditCtx(n.artistGid)) count.set(n.artistGid, (count.get(n.artistGid) || 0) + 1); })); } catch (e) {} return [...count.entries()].sort((a, b) => b[1] - a[1]).map(e => e[0]).slice(0, 6); } // the other editions' release artists that match a credited name → the position stage's shape function riTallyEditions(rels, creditedAs, self) { const by = new Map(), editions = []; rels.forEach(r => { if (!r || !r.id || r.id === self) return; const ac = r['artist-credit'] || []; if (!ac.length) return; editions.push({ gid: r.id, from: r._from || 'rg', title: r.title || '', date: r.date || '', format: '', names: ac.map(a => (a.artist && a.artist.name) || a.name), gids: ac.map(a => a.artist && a.artist.id).filter(Boolean) }); ac.forEach(a => { const g = a.artist && a.artist.id; if (!g || SPECIAL_PURPOSE_ARTISTS.has(g)) return; if (!posNameMatch(creditedAs, a.name) && !posNameMatch(creditedAs, a.artist.name)) return; const v = by.get(g) || { gid: g, name: a.artist.name || a.name, rels: new Set() }; v.rels.add(r.id); by.set(g, v); }); }); return { artists: [...by.values()].map(v => ({ gid: v.gid, name: v.name, votes: v.rels.size })).sort((a, b) => b.votes - a.votes), of: editions.length, editions }; } async function riEditionArtists(creditedAs) { const rel = release(), self = rel ? u(rel.gid) || null : null; const rg = rel && u(rel.releaseGroup), rgGid = rg ? u(rg.gid) : null; let rels = rgGid ? ((await rgReleases(rgGid)) || []).map(r => Object.assign({ _from: 'rg' }, r)) : []; let res = riTallyEditions(rels, creditedAs, self); if (res.artists.length !== 1) { const title = rel ? (u(rel.name) || '') : ''; const cached = [..._dupRawCache.values()]; const dups = cached.length ? cached : await duplicateReleases(title, null, rgGid); rels = rels.concat((dups || []).map(r => Object.assign({ _from: 'dup' }, r))); res = riTallyEditions(rels, creditedAs, self); } Log.debug('release artist editions:', JSON.stringify(creditedAs), '—', res.of, 'edition(s);', res.artists.length ? res.artists.map(a => a.name + ' ×' + a.votes).join(', ') : 'none credit a matching name'); return res.artists.length ? res : null; } // write one name of the release artist credit, keeping the others as they are function riWriteArtist(i, ent) { const rel = release(), names = riNames(); if (!names[i]) return false; const e = ent; const out = names.map((n, j) => ({ artist: j === i ? e : u(n.artist), name: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' })); try { rel.artistCredit({ names: out }); return true; } catch (x) { Log.warn('release artist: writing', ent && ent.name, 'failed —', x.message); return false; } } // the user's pick (a candidate, or an artist just created) into name i async function riPick(i, ent, how) { if (ent && ent.gid && !ent.id) ent = (await fetchEntity(ent.gid)) || ent; const ps = _riArt[i]; if (!ent || !ent.gid || !ps) return; pickArtist(ps, ent); // the cell's own pick: writes the whole credit through riCommit Log.info('Release artist', i + 1, '→', ent.name, '(' + ent.gid + ')', how || 'picked'); await riRetag(); } /* #652: the release Artist field IS the Tracklist's artist cell. MusicBrainz's box, its Edit * bubble and three-column popup are hidden; riEntry is a track-like entry whose slots are * _riArt, so every cell action (pick, credited-as, join, add, split, reorder, remove, create) * runs the Tracklist's code, and commitTrack hands it to riCommit, which writes the release * artist credit instead of a track's. */ const riEntry = { release: true, mi: -1, ti: -1, number: 'release', title: '', slots: [] }; let _riCommittedKey = null, _riMatching = false; // the live credit as we last wrote or read it const riLiveKey = () => JSON.stringify(riNames().map(n => { const a = u(n.artist); return [(a && u(a.gid)) || '', u(n.name) || '', u(n.joinPhrase) || '']; })); function riBind() { riEntry.slots = _riArt; _riArt.forEach((ps, i) => { ps._entry = riEntry; ps._ri = i; ps._onPick = ent => riPick(riEntry.slots.indexOf(ps), ent, 'created'); }); } // the credit changed under us (seeded late, MusicBrainz's own editor in the Original view, a // paste): rebuild the slots from it, keeping what matching found for an artist still there function riSyncSlots() { const key = riLiveKey(); if (key === _riCommittedKey && _riArt.length) return; const prev = new Map(_riArt.filter(p => p.gid).map(p => [p.gid, p])); _riArt = riNames().map(n => { const a = u(n.artist), gid = (a && u(a.gid)) || null, cr = u(n.name) || '', nm = (a && u(a.name)) || cr, jp = u(n.joinPhrase) || ''; const p = gid && prev.get(gid); if (p) return Object.assign(p, { creditedAs: cr || nm, joinPhrase: jp }); return { creditedAs: cr || nm, joinPhrase: jp, status: gid ? 'set' : 'none', entity: gid ? a : null, gid, name: gid ? nm : '', candidates: [], committed: !!gid }; }); if (!_riArt.length) _riArt = [{ creditedAs: '', joinPhrase: '', status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false }]; riBind(); _riCommittedKey = key; Log.debug('release artist cell: read the credit', key); } function riCommit(entry) { const rel = release(); if (!rel || typeof rel.artistCredit !== 'function') return; const liveByGid = new Map(); riNames().forEach(n => { const a = u(n.artist), g = a && u(a.gid); if (g && !liveByGid.has(g)) liveByGid.set(g, a); }); const names = entry.slots.map(p => { const cr = p.creditedAs || p.name || ''; if (p.committed && p.gid) return { artist: liveByGid.get(p.gid) || p.entity || { name: p.name }, name: cr, joinPhrase: p.joinPhrase || '' }; return { artist: { name: cr }, name: cr, joinPhrase: p.joinPhrase || '' }; }); _selfEdit = true; try { rel.artistCredit({ names }); } catch (x) { Log.warn('release artist: writing the credit failed —', x.message); } finally { _selfEdit = false; } _riCommittedKey = riLiveKey(); Log.info('release artist credit →', JSON.stringify(names.map(n => n.name + n.joinPhrase).join('')), '·', names.map(n => (n.artist && n.artist.gid) ? 'linked' : 'unlinked').join(', ')); } async function matchReleaseArtist(manual) { if (SETTINGS.autoMatchArtist === false && !manual) return; const rel = release(); if (!rel || typeof rel.artistCredit !== 'function') return; const names = riNames(); if (!names.length) return; const dmap = SETTINGS.discogsUrlMatch === false ? null : await loadDiscogsMap().catch(() => null); const relDisc = dmap && dmap.releaseArtists && dmap.releaseArtists.length === names.length ? dmap.releaseArtists : null; const h = fcHandoff(); if (h && h.credit) await resolvePlatformUrls(h.credit.map(a => a.url).filter(Boolean)); // one batched lookup const ctx = riContextGids(); Log.debug('release artist: matching', names.length, 'name(s);', relDisc ? 'Discogs artists known;' : '', h ? 'First Contact links known;' : '', ctx.length, 'track artist(s) as co-credit context'); let linked = 0, lastName = ''; const state = []; _riMatching = true; // the pass writes as it goes: the cell waits for the whole result const prevBy = new Map(_riArt.filter(p => p.gid && p.committed && p.status !== 'set').map(p => [p.gid, p])); // a re-match (after a split) keeps the badges it had for (let i = 0; i < names.length; i++) { const n = names[i], cur = u(n.artist), creditedAs = u(n.name) || '', joinPhrase = u(n.joinPhrase) || ''; const gid = cur && u(cur.gid), nm = (cur && u(cur.name)) || creditedAs; const ps = { creditedAs: creditedAs || nm, joinPhrase, _ri: i, status: gid ? 'set' : 'none', entity: null, gid: gid || null, name: gid ? nm : '', candidates: [], committed: !!gid }; const was = gid && prevBy.get(gid); if (was) Object.assign(ps, { status: was.status, entity: was.entity, candidates: was.candidates || [], _pos: was._pos || null, _why: was._why || null, _at: was._at }); const durl = relDisc ? relDisc[i] : null, purl = h ? fcCreditUrl(h.credit, i, ps.creditedAs, names.length) : null; if (!gid && nm) { const m = await matchSlot(nm, null, durl, ctx, () => riEditionArtists(nm), purl); if (m.why && m.why.pos) m.why.pos.ri = true; Object.assign(ps, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates || [], _pos: m.pos || null, _why: m.why || null, _at: Date.now() }); if (m.entity && autoCommittable(ps)) { const ent = m.entity.id ? m.entity : await fetchEntity(m.entity.gid); if (ent && ent.id && riWriteArtist(i, ent)) { ps.committed = true; ps.entity = ent; linked++; lastName = ent.name; Log.info('Artist match:', nm, '→', ent.name, '(' + ent.gid + ') —', ps.status); } } else Log.info('Artist:', nm, '—', m.entity ? 'uncertain, ' + m.entity.name + ' is the best candidate; left for you' : 'no match; left unset'); } await tagDiscogsAddable(ps, durl); await tagPlatformAddable(ps, purl); state.push(ps); } _riArt = state; riBind(); _riCommittedKey = riLiveKey(); _riMatching = false; renderRiArtist(true); if (linked) toast(linked === 1 ? ('✓ Artist matched: ' + lastName) : ('✓ Auto-matched ' + linked + ' artists')); } // after a link was added or an artist picked: the offers again async function riRetag() { for (const ps of _riArt) { if (ps._discogsUrl) await tagDiscogsAddable(ps, ps._discogsUrl); if (ps._platUrl) await tagPlatformAddable(ps, ps._platUrl); } renderRiArtist(); } // the badges and offers, after MusicBrainz's artist credit field function renderRiArtist(force) { const td = document.querySelector('#information td.release-artist'); if (!td) return; let cell = td.querySelector(':scope > .tc-ri-art'), box = td.querySelector(':scope > .tc-ri-am'); const th = td.previousElementSibling; let lead = th && th.querySelector(':scope > .tc-ri-lead'); if (!riWant()) { if (cell) cell.remove(); if (box) box.remove(); if (lead) lead.remove(); td.classList.remove('tc-ri-artcell'); if (th) th.classList.remove('tc-ri-artlbl'); return; } if (box) box.remove(); // the badges sit on their own lines now if (lead) lead.remove(); // (older builds kept the credited-as column in the label cell) if (th) { th.classList.add('tc-ri-artlbl'); td.style.setProperty('--tc-ri-lead-w', Math.max(80, th.getBoundingClientRect().width - 14) + 'px'); } if (!cell) { cell = document.createElement('div'); cell.className = 'tc-ri-art mbu-ui'; td.prepend(cell); td.classList.add('tc-ri-artcell'); Log.debug('release artist: the Tracklist cell replaces MusicBrainz\'s artist field'); } if (_riMatching || (!force && cell.contains(document.activeElement))) return; // never rebuild under the user's caret; the next tick catches up riSyncSlots(); // a release artist already "set" carries no entity of ours: feed the disambiguation cache from MB's own // credit (the KO artist has its comment) so the bar shows it, as the Tracklist does (#195) riNames().forEach(n => { const a = u(n.artist); if (a) noteDisamb(u(a.gid), u(a.comment)); }); const sig = JSON.stringify(_riArt.map(p => [p.status, p.gid, !!p.committed, p.creditedAs, p.joinPhrase, p.name, !!p._discogsAddable, !!p._platAddable, p._platConflict && p._platConflict.gid, p._discogsConflict && p._discogsConflict.gid, !!p._pending])); if (!force && cell.dataset.sig === sig) return; // re-applied every tick: only touch the DOM on a change cell.dataset.sig = sig; cell.textContent = ''; const tbl = document.createElement('table'); tbl.className = 'tc-mirror ' + (SETTINGS.gridRows !== false ? 'gridrows ' : '') + (SETTINGS.layout || 'normal'); const tr = tbl.appendChild(document.createElement('tbody')).appendChild(document.createElement('tr')); const art = tr.appendChild(document.createElement('td')); art.className = 'c-art'; const paintBadges = () => riBadges(td); _riArt.forEach((p, i) => { const line = art.appendChild(slotEl(riEntry, p, i, paintBadges)); // #652 (majkinetor): "Credit as instead of Artist label, selector aligned with edit above it" — // the credited-as box and the artist-type icon stay in the line, drawn out into the label // column: they move with it, so they are level whatever the theme does to either cell const row = document.createElement('div'); row.className = 'tc-ri-leadrow'; const cw = line.querySelector(':scope > .tc-credwrap'), ic = cw && cw.nextElementSibling; if (cw) row.appendChild(cw); if (ic && !ic.classList.contains('tc-search')) row.appendChild(ic); line.prepend(row); const rb = document.createElement('span'); rb.className = 'tc-ri-rb'; rb.dataset.ri = i; line.appendChild(rb); }); cell.appendChild(tbl); paintBadges(); } // the match badge at the end of each artist's line (the Tracklist shows them in its own column) function riBadges(td) { td.querySelectorAll('.tc-ri-art .tc-ri-rb').forEach(rb => { const i = +rb.dataset.ri, p = _riArt[i]; rb.textContent = ''; const txt = p && badgeText(p); if (!txt) return; const chip = rb; const b = document.createElement('span'); b.className = 'tc-badge ' + p.status; b.dataset.ri = i; b.textContent = txt; if (p.status === 'low' && p.entity) { b.classList.add('tc-ri-pick'); b.title = `uncertain: click to link ${p.entity.name}`; b.onclick = () => riPick(i, p.entity, 'picked (the uncertain candidate)'); } chip.appendChild(b); }); } /* ── #652: a label by its Discogs or platform link ───────────────────────────────── */ const _riLabInit = new WeakMap(); // the label field → the label gid it had when Apollo first saw it ('' = empty) const _riLab = new WeakMap(); // the label field → { status, abbr } for its badge function riLabelUrl(name, i, n, dmap, h) { const pick = (list, prefer) => { if (!list || !list.length) return null; const by = list.find(l => l.url && sameName(l.name, name)); return by ? by.url : (list.length === n && list[i] && list[i].url ? list[i].url : null); }; const d = pick(dmap && dmap.labels); if (d) return d; return pick(h && h.labels); } function renderRiLabels() { const rel = release(); if (!rel) return; const labels = u(rel.labels) || []; labels.forEach((lf, i) => { const inp = document.getElementById('label-' + i); if (!inp) return; const host = inp.closest('span.autocomplete') || inp.parentElement; let b = host.querySelector(':scope > .tc-ri-lab'); let st = _riLab.get(lf); const cur = lf && typeof lf.label === 'function' ? lf.label() : null; // #652: a label already on the release (not matched by Apollo) shows "set", as the artist does const mine = st && cur && cur.gid === st.gid; if (!_riLabInit.has(lf) && lf) _riLabInit.set(lf, cur && cur.gid || ''); const show = riWant() && cur && cur.gid; if (!show) { if (b) b.remove(); inp.style.removeProperty('padding-right'); return; } // a label picked by hand (differs from what the release had and from Apollo's match) shows "user", as the artist does if (!mine) st = cur.gid !== _riLabInit.get(lf) ? { status: 'user', title: 'chosen by you' } : { status: 'set', title: 'already set on the release' }; const txt = st.abbr || ({ disc: 'disc', high: 'name', alias: 'alias' })[st.status] || st.status; if (!b) { b = document.createElement('span'); b.className = 'tc-ri-lab mbu-ui'; host.appendChild(b); } // click opens the label in a new tab; the tooltip names it with its disambiguation const tip = `${cur.name || ''}${cur.comment ? ' (' + cur.comment + ')' : ''}${st.title ? ' — ' + st.title : ''} click to open the label`; const key = st.status + txt + cur.gid + tip; if (b.dataset.t !== key) { b.dataset.t = key; b.innerHTML = `${esc(txt)}`; } b.dataset.gid = cur.gid; if (!b._wired) { b._wired = true; b.addEventListener('mousedown', e => { e.preventDefault(); e.stopPropagation(); }); b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); Log.debug('label badge: open', b.dataset.gid); if (b.dataset.gid) window.open(`${ORIGIN}/label/${b.dataset.gid}`, '_blank', 'noopener'); }); } // the badge sits inside the field: reserve its room so it never covers the label's name const pr = Math.ceil(host.getBoundingClientRect().right - b.getBoundingClientRect().left) + 4; if (pr > 4) { inp.style.setProperty('box-sizing', 'border-box', 'important'); inp.style.setProperty('padding-right', pr + 'px', 'important'); } }); } // full alias arrays for display (the js search only carries primaryAlias, often empty). One WS2 // search per query returns every result's aliases with locale — no per-artist fetch. Cached. const _aliasCache = new Map(); // query → { gid: aliases } const _gidAliases = new Map(); // gid → aliases — survives table rebuilds (so the bar keeps its alias) const cacheAliases = (gid, aks) => { if (gid && aks) _gidAliases.set(gid, aks); }; // aliases for already-resolved artists (existing releases / auto-matched) WITHOUT a fetch each — // one batched WS2 query per ~90 gids (arid:g1 OR arid:g2 …), cached by gid async function fetchAliasesByGids(gids) { const uniq = [...new Set((gids || []).filter(g => g && !_gidAliases.has(g)))]; for (let i = 0; i < uniq.length; i += 90) { const q = uniq.slice(i, i + 90).map(g => 'arid:' + g).join(' OR '); const res = await wsJson(`${ORIGIN}/ws/2/artist?query=${encodeURIComponent(q)}&limit=100&fmt=json`, { label: 'batch alias fetch' }); if (!res.json) { Log.warn('batch alias fetch failed for', uniq.slice(i, i + 90).length, 'artist(s)'); continue; } (res.json.artists || []).forEach(a => { cacheAliases(a.id, a.aliases || []); noteDisamb(a.id, a.disambiguation); }); } } const isEditingNow = () => { const a = document.activeElement; return a && /^(INPUT|SELECT)$/.test(a.tagName) && (a.closest('.tc-medsec') || a.closest('#tc-panel')); }; // re-run adorn for every rendered slot (adds/updates the alias span) WITHOUT rebuilding rows — so it // can't steal focus or detach the slot an in-flight edit is using function refreshAdorns() { if (!MODEL) return; MODEL.tracks.forEach(t => { const row = rowEl(t.mi, t.ti); if (!row) return; const ps = recPendingState(t.mi, t.ti); const pg = ps && ps.artGids; const searches = row.querySelectorAll('.tc-search'); t.slots.forEach((s, i) => { const search = searches[i]; if (search) { adorn(search, s, search.querySelector('.nm')); search.classList.toggle('tc-slot-pending', slotIsPending(pg, s)); } }); }); } // batch-fetch aliases for every committed artist we don't have yet, then refresh the bars in place async function enrichResolvedAliases() { if (!MODEL) return; const need = []; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.committed && s.gid && !_gidAliases.has(s.gid)) need.push(s.gid); })); if (!need.length) return; await fetchAliasesByGids(need); refreshAdorns(); } // #575 round 2: this is called from loadAndRender and rebuild, and rebuild runs // again for each artist committed during matching — so on majkinetor's release // it fired 29 batched alias fetches, several of them for a SINGLE gid, where // fetchAliasesByGids is built to take 90 at a time. Coalesced on a trailing // timer: the last call of a burst does the work, and since `need` is recomputed // from the model every time, it picks up everyone who arrived meanwhile. The // aliases are decoration beside a name, so arriving a moment later costs // nothing; a run of one-gid searches costs a start slot each. let _enrichT = 0; function enrichResolvedAliasesSoon() { clearTimeout(_enrichT); _enrichT = setTimeout(() => { enrichResolvedAliases().catch(e => Log.warn('alias enrich failed', e.message)); }, 1200); } // the alias(es) to show next to a result: the English-locale one(s) if present, otherwise the first // alias — joined with ", " and capped so it never gets too long // MusicBrainz special-purpose artists carry hundreds of junk/locale aliases // (e.g. [unknown] → '"Gold Diggers of 1937" Chorus'); never surface one as an AKA. // Keyed by MBID, not a name pattern — not all are bracketed (Various Artists) and // plenty of real artists DO use brackets. (#171, per @chaban-mb) // #613: the list now lives in dev/match/artist-match.mjs (inlined above as // MBM_SPECIAL_PURPOSE) — shared with Group Therapy and Credit Hoarder; only // discogs_credits/src/data/special-purpose.js still keeps its own copy. const SPECIAL_PURPOSE_ARTISTS = MBM_SPECIAL_PURPOSE; function aliasStr(c) { if (c.gid && SPECIAL_PURPOSE_ARTISTS.has(c.gid)) return null; // #171 — no AKA for special-purpose artists const name = c.name || '', aks = c.aliases || [], diff = s => s && fold(s) !== fold(name); const en = aks.filter(a => /^en/i.test(a.locale || '') && diff(a.name)).sort((a, b) => (b.primary ? 1 : 0) - (a.primary ? 1 : 0)).map(a => a.name); let out = en; if (!out.length) { const first = aks.find(a => diff(a.name)); out = first ? [first.name] : (diff(c.primaryAlias) ? [c.primaryAlias] : []); } const seen = new Set(); out = out.filter(s => { const f = fold(s); if (!f || seen.has(f)) return false; seen.add(f); return true; }); if (!out.length) return null; let s = out.join(', '); const MAX = 48; if (s.length > MAX) { const cut = s.lastIndexOf(', ', MAX); s = (cut > 12 ? s.slice(0, cut) : s.slice(0, MAX - 1)) + '…'; } return s; } // #626: the release group's editions, with their tracklists and credits — ONE request per // release group, shared by the rg sibling map below, the artist position source and the // Recordings pass's position index (#440), which used to fetch this same URL each on their // own. A failed lookup is not cached (#555), so the next caller retries. // Cached as a promise, so callers that arrive together (the matching lanes) share it. const _rgRelCache = new Map(); // rgGid → Promise function rgReleases(rgGid) { if (!rgGid) return Promise.resolve(null); if (_rgRelCache.has(rgGid)) return _rgRelCache.get(rgGid); const p = wsJson(`${ORIGIN}/ws/2/release?release-group=${rgGid}&inc=recordings+artist-credits&fmt=json&limit=100`, { label: 'release-group editions' }).then(res => { if (!res.json) { _rgRelCache.delete(rgGid); Log.warn('release-group editions: lookup failed for', rgGid, '— not cached'); return null; } const rels = res.json.releases || []; Log.debug('release-group editions:', rels.length, 'release(s) in', rgGid); return rels; }, e => { _rgRelCache.delete(rgGid); Log.warn('release-group editions failed:', e.message); return null; }); _rgRelCache.set(rgGid, p); return p; } async function fetchSiblings(rgGid) { const map = new Map(); try { const rels = await rgReleases(rgGid); if (!rels) return map; rels.forEach(rel => (rel.media || []).forEach(med => (med.tracks || []).forEach(t => { const title = fold(t.title || (t.recording && t.recording.title)); const ac = (t['artist-credit'] && t['artist-credit'].length) ? t['artist-credit'] : ((t.recording && t.recording['artist-credit']) || []); if (!title || map.has(title) || !ac.length || !ac.every(x => x.artist && x.artist.id)) return; const src = { gid: rel.id, title: rel.title || '', date: rel.date || '', format: med.format || '', pos: (med.position || 1) + '.' + (t.position || t.number || ''), track: t.title || (t.recording && t.recording.title) || '' }; // #654: the tooltip names it map.set(title, ac.map(x => ({ gid: x.artist.id, name: x.artist.name, creditedAs: x.name || x.artist.name, joinPhrase: x.joinphrase || '', src }))); }))); } catch (e) { Log.warn('sibling load failed:', e.message); } return map; } let _sibCache = { gid: undefined, map: null }; async function loadSiblingMap(force) { const rg = u(release().releaseGroup); const rgGid = rg ? u(rg.gid) : null; if (!rgGid) { Log.info('no release group linked → search-only'); return new Map(); } if (!force && _sibCache.gid === rgGid && _sibCache.map && _sibCache.map.size) return _sibCache.map; let map = new Map(); for (let i = 0; i < 3 && !map.size; i++) { if (i) await new Promise(r => setTimeout(r, 1100)); map = await fetchSiblings(rgGid); } _sibCache = { gid: rgGid, map }; if (map.size) Log.info('sibling map:', map.size, 'titles from RG', rgGid); else Log.warn('sibling map empty (RG', rgGid + ') — search only; retries on rebuild'); return map; } /* ── Discogs artist-link matching (#224) ────────────────────────────────────── * When the release carries a Discogs link, match each track artist by its * Discogs URL — a strong, human-verified signal — before the name search. * Self-contained: Apollo stays fully independent of Credit Hoarder. Gated by * the "Discogs artist link matching" option. Resolves one slot at a time * (matchModel is already sequential), waiting + retrying on rate limits. */ const DISCOGS_TOKEN = 'gYAnSAmIoXiHezHBmHoqcBCuJRyQLJBYSjurbGTZ'; // Scrape the release's Discogs link from the page — edit-relationships anchors // or the release editor's external-link inputs. First match wins (#224). let _discogsUrlLogged = false; let _discVerifyUrl = null, _lastDiscCheck = null; // dedupe the per-run "verifying…"/outcome log lines function discogsReleaseUrlFromPage() { const re = /discogs\.com\/(?:[a-z]{2}\/)?(?:[^/\s"']*\/)?release\/(\d+)/i; let url = null; for (const a of document.querySelectorAll('a[href*="discogs.com/release/"]')) { const m = re.exec(a.getAttribute('href') || ''); if (m) { url = `https://www.discogs.com/release/${m[1]}`; break; } } if (!url) for (const inp of document.querySelectorAll('input')) { const m = re.exec(inp.value || ''); if (m) { url = `https://www.discogs.com/release/${m[1]}`; break; } } if (url && !_discogsUrlLogged) { _discogsUrlLogged = true; Log.info('Discogs: release link found —', url); } return url; } // Map folded track title → array of Discogs artist www-URLs (one per credited // track artist, in order). Keyed by title like the sibling map, so it's robust // to medium/ordering differences. Cached per release link. let _discogsMap = { url: undefined, map: null }; async function loadDiscogsMap(force) { if (SETTINGS.discogsUrlMatch === false) return null; const url = discogsReleaseUrlFromPage(); if (!url) { _discogsMap = { url: null, map: null }; return null; } if (!force && _discogsMap.url === url && _discogsMap.map) return _discogsMap.map; const api = `${url.replace('https://www.discogs.com/release/', 'https://api.discogs.com/releases/')}?token=${DISCOGS_TOKEN}`; // #281: the Discogs API (one shared token) is rate-limited — a 429 used to make // the fetch return null, which was then CACHED as an empty map, so the whole // session showed no links until a full page reload. Retry with backoff, and // crucially never cache a FAILED fetch (return null → caller retries / shows a // retry affordance). Only a real JSON response is cached (success or genuine // empty), keyed by URL. for (let attempt = 0; attempt < 4; attempt++) { let r; try { r = await fetch(api); } catch (e) { Log.warn('Discogs match: fetch failed —', e.message); await _sleep(800 * (attempt + 1)); continue; } // network → retry, don't cache if (r.status === 429 || r.status === 503) { // rate limited → back off, don't cache const ra = parseInt(r.headers.get('retry-after') || '', 10); Log.warn('Discogs match: rate limited (HTTP', r.status + ') — backing off'); await _sleep(Math.max(1000, (ra > 0 ? ra : 2) * 1000)); continue; } if (!r.ok) { Log.warn('Discogs match: HTTP', r.status); await _sleep(800 * (attempt + 1)); continue; } // transient → retry, don't cache let json = null; try { json = await r.json(); } catch (e) { await _sleep(800); continue; } const map = new Map(); // #283: the release-level artist(s) (194 = Discogs "Various", skip) — used as a // fallback for tracks that credit no per-track artist (single-artist releases). map.releaseArtists = (json && Array.isArray(json.artists)) ? json.artists.filter(a => a && a.id && a.id !== 194).map(a => `https://www.discogs.com/artist/${a.id}`) : []; // #652: the release's labels, to match the Label field by its Discogs link (1818 = Discogs's "Not On Label") map.labels = (json && Array.isArray(json.labels)) ? json.labels.filter(l => l && l.id && l.id !== 1818).map(l => ({ name: l.name || '', url: `https://www.discogs.com/label/${l.id}` })) : []; // #283: also keep the per-track artist URLs in Discogs order, so a track whose // TITLE doesn't match (transliteration / punctuation the fold can't catch) can // still be matched BY POSITION when the two tracklists are the same length. map.byPos = []; // #442: Discogs models a "feat." credit as an extraartist with a Featuring role, // NOT a main track artist (`t.artists`), so it never reached the by-title/by-pos // maps above — a feat slot split from the MB title (e.g. "Don Abi") then had no // Discogs URL to match on, even though the artist IS credited on Discogs and its // MB entry links that Discogs artist (often the split name is only an ALIAS of the // MB artist, making the link the only reliable bridge). Keep the featured artists // separately, keyed by folded title and by position, as [{ name, url }]. map.featByPos = []; map.featByTitle = new Map(); // #447: a track's performing credit on Discogs is a "Featuring" extra-artist on some // releases and a "Vocals" / "Backing Vocals" / "Rap" / "MC" one on others (e.g. a // "feat. X" in the MB title where Discogs credits X as Vocals). Capture ANY performing // role — vocals, rap, voice, performer, narration, choir — so those artists' Discogs // links are available to match a feat slot. Non-performing roles (producer, remix, // written-by, mixed/engineer, instruments) stay out; and matching is name-gated anyway, // so a captured artist only links when the slot's credited name matches it. const CREDIT_ROLE_RE = /feat|vocal|voice|\brap\b|\bmc\b|\bemcee\b|narrat|spoken|perform|sung|choir|chorus|chant/i; if (json && Array.isArray(json.tracklist)) { json.tracklist.filter(t => (t.type_ || 'track') === 'track').forEach(t => { const urls = (t.artists || []).map(a => (a && a.id) ? `https://www.discogs.com/artist/${a.id}` : null); map.byPos.push(urls); const feats = (t.extraartists || []).filter(a => a && a.id && CREDIT_ROLE_RE.test(a.role || '')).map(a => ({ name: a.name || '', url: `https://www.discogs.com/artist/${a.id}` })); map.featByPos.push(feats); const key = fold(t.title || ''); if (key && !map.featByTitle.has(key)) map.featByTitle.set(key, feats); if (!key || map.has(key)) return; map.set(key, urls); }); // this loads the Discogs release for the link CHECK (and for URL-matching unset // artists) — not re-matching already-set ones, hence "loaded" not "matched". Log.info('Discogs: loaded release —', map.size, 'track title(s)' + (map.releaseArtists.length ? ` + ${map.releaseArtists.length} release artist(s)` : ''), 'from', url); } else { Log.warn('Discogs: release JSON had no tracklist'); } _discogsMap = { url, map }; // cache ONLY a real response (success or genuine empty) return map; } Log.warn('Discogs match: could not load release JSON after retries (rate-limited?) — leaving uncached'); return null; // all retries exhausted → unknown, NOT cached, so the next call retries } // #283: a track's per-slot Discogs artist URLs — by folded title, falling back to // POSITION when the title doesn't match and the two tracklists are the same length // (so subtle title differences don't drop the track). { urls, byPos }. function discogsUrlsForTrack(dmap, title, index, total) { if (!dmap) return { urls: null, byPos: false }; const byTitle = dmap.get(fold(title)); if (byTitle) return { urls: byTitle, byPos: false }; if (dmap.byPos && dmap.byPos.length === total && total > 0) return { urls: dmap.byPos[index] || null, byPos: true }; return { urls: null, byPos: false }; } // #442: the Discogs "Featuring" artist URL for a feat slot split out of the title, // matched to the slot's credited name (Discogs credits e.g. "Don Abi", which may be // an ALIAS of the MB artist named "Abiodun", so the shared Discogs link is the only // reliable bridge — a name search alone can't confidently pick it). By folded title, // falling back to POSITION when titles differ but the two tracklists are equal length. function discogsFeatUrlFor(dmap, title, index, total, creditedAs) { if (!dmap || !creditedAs) return null; let feats = dmap.featByTitle && dmap.featByTitle.get(fold(title)); if (!feats && dmap.featByPos && dmap.featByPos.length === total && total > 0) feats = dmap.featByPos[index]; if (!feats || !feats.length) return null; const want = fold(creditedAs); const hit = feats.find(f => fold(f.name) === want) || feats.find(f => { const fn = fold(f.name); return fn && want && (fn.includes(want) || want.includes(fn)); }); return hit ? hit.url : null; } // Resolve a Discogs artist URL to MB artist(s) via its URL relationship. // Returns [{ gid, name }] (0 / 1 / many) on success, or `null` when the lookup // could not complete (rate-limited / network) — callers must treat null as // "unknown" and NOT cache it, so a transient 503 never becomes a false // negative (#227). Only successful responses are cached. const _discogsResolveCache = new Map(); const _sleep = ms => new Promise(z => setTimeout(z, ms)); // The public /ws/2 endpoint is rate-limited (~1 req/s), so requests are paced // here (#227). This used to pace them by CHAINING: each call waited for the // previous one to come back, then waited out the gap. That makes our rate // depend on MusicBrainz's latency instead of on us. // // #575 round 2, measured on majkinetor's log once duplicate requests were gone: // 70 requests took 843 seconds — an actual rate of 0.08/s, more than ten times // UNDER the limit we were pacing for. The searches themselves are slow (an // alias search on his data returns in ~12s), and with a chain every one of // those blocks everything behind it. So the queue wasn't protecting us from // the limiter, it was idling in front of a slow server. // // Pacing applies to request STARTS, not completions, so a slow response doesn't stall the // ones behind it; up to WS_MAX_INFLIGHT outstanding. // // #575 round 3 (majkinetor's 2026-09-08 log: 90 of 121 responses were HTTP 503): a // throttle is a SHARED signal. One 503 holds every lane behind one deadline, and // concurrency collapses to a single probing request until a response comes back clean, // so the retries don't keep the limiter tripped. // // #633: both are now the shared MusicBrainz request gate's (the ST-NET block), so the // schedule and the hold cover every script and tab on the origin, not Apollo alone. // What stays here: the lanes, the Stop and the stale checks. const WS_MAX_INFLIGHT = 4; // a burst ceiling; the gate's pace is what bounds the rate let _wsInFlight = 0; const wsLanes = () => (mbnHot() ? 1 : WS_MAX_INFLIGHT); // while throttled, probe with one const wsGateLog = (lv, m) => (lv === 'warn' ? Log.warn : Log.info)(m); // opts.stale() → true means "this request has been superseded" (the picker types // a new query while an older one still queues). Checked while it waits for its slot // AND once it has one, so a stale call costs no request. #555 // A picker search (it has a stale()) is interactive for the gate: it doesn't queue // behind a matching pass's background lookups. async function wsGet(url, opts) { const o = opts || {}; if (o.stale && o.stale()) return null; // The wait is interruptible: a lane parked in the gate cannot notice a Stop otherwise, // and a pass whose lanes are all parked looks unstoppable (#577). const slot = await mbnSlot({ background: !o.stale, label: o.label || 'ws2', log: wsGateLog, cancelled: () => _matchStop || !!(o.stale && o.stale()) }); if (!slot.ok) return null; // caller treats null as superseded while (_wsInFlight >= wsLanes()) { if (_matchStop) return null; await _sleep(120); } if (o.stale && o.stale()) return null; _wsInFlight++; try { return await fetch(url, { headers: { Accept: 'application/json' } }); } finally { _wsInFlight--; } } // tell the gate how MusicBrainz answered: a 429/503 holds everybody, a clean answer earns room back const wsAnswer = r => mbnAnswer(r.status, n => (r.headers && r.headers.get ? r.headers.get(n) : null), { log: wsGateLog }); // EVERY /ws/2 read goes through here. MusicBrainz answers a throttled request // with HTTP 503 (or 429) and a body of `{"error":"…"}` — no `recordings` / // `artists` key. Callers that did `fetch(url).then(r => r.json())` therefore // read `undefined`, mapped it to an empty list and rendered "no matches" with // NOTHING logged: the same query worked or didn't at random, depending purely // on how many requests happened to be in flight. So: retry with backoff, // honour Retry-After, and log every attempt. #555 const WS_TRIES = 4; // #575 (majkinetor, "Matching takes too long"): every per-caller cache in here // — _aliasMatchCache, _credCache, the duplicates ones — is CHECKED before its // await and WRITTEN after it. When several track slots resolve the same artist // at once they all miss, all enqueue, and the cache only starts helping once // the first answer lands. A classic cache stampede. // // It is expensive here because /ws/2 reads are paced (the shared gate, one a second), so // every duplicate costs a whole start slot — and the extra volume is what trips // MusicBrainz's rate limiter, whose backoff then slows down everything behind // it. Measured on his log: // 115 requests for 48 distinct URLs (67 wasted, 58%), 25 throttle events, one // query — arid+"Martha Badibala" — fetched TEN times. // // Coalescing in flight fixes every caller at once and changes no caller's // logic: a request for a URL already in flight joins that flight instead of // queueing behind it. The entry is dropped the moment the flight settles, so // nothing is remembered across time — a throttled miss is still not cached // (#555) and a later retry really does re-fetch. // // Two option shapes opt out, because they make a response caller-specific // rather than a property of the URL: `stale` (the caller may want the request // dropped when its popup closed — sharing would drop it for someone else too) // and `onThrottle` (a per-caller UI hint that only the owning caller can act on). const _wsFlight = new Map(); function wsJson(url, opts) { const o = opts || {}; if (o.stale || o.onThrottle) return wsJsonOnce(url, o); const inflight = _wsFlight.get(url); if (inflight) { Log.debug((o.label || 'ws2') + ': joined an identical request already in flight —', url); return inflight; } const p = wsJsonOnce(url, o); _wsFlight.set(url, p); // settle-or-fail, then release: the next caller must be able to retry p.then(() => {}, () => {}).then(() => { if (_wsFlight.get(url) === p) _wsFlight.delete(url); }); return p; } async function wsJsonOnce(url, opts) { const o = opts || {}, label = o.label || 'ws2'; for (let attempt = 1; attempt <= WS_TRIES; attempt++) { let r; try { r = await wsGet(url, o); } catch (e) { Log.warn(label + ': network error —', e.message, '(attempt ' + attempt + '/' + WS_TRIES + ')', url); await _sleep(600 * attempt); continue; } if (!r) { Log.debug(label + ': superseded while queued, dropped —', url); return { stale: true }; } Log.debug(label + ': HTTP', r.status, url); const a = await wsAnswer(r); if (a.throttled) { // #575 round 3: hold every lane, not just this one. wsGet waits in the gate for // the shared deadline, so the retry needs no sleep of its own — sleeping here // too would stack a private penalty on top of the shared one. Log.warn(label + ': throttled by MusicBrainz (HTTP ' + r.status + ') — all requests holding ' + a.hold + 'ms (attempt ' + attempt + '/' + WS_TRIES + ')'); if (o.onThrottle) { try { o.onThrottle(attempt); } catch (e) {} } continue; } if (r.status === 404) { Log.debug(label + ': 404 not found —', url); return { notFound: true }; } if (!r.ok) { Log.warn(label + ': HTTP ' + r.status + ' — retrying (attempt ' + attempt + '/' + WS_TRIES + ')', url); await _sleep(600 * attempt); continue; } let j; try { j = await r.json(); } catch (e) { Log.warn(label + ': unparsable JSON —', e.message, url); return { failed: true }; } // some edges answer 200 with an error envelope — treat it as a failure, never as 0 hits if (j && typeof j.error === 'string') { Log.warn(label + ': web service error —', j.error); return { failed: true }; } return { json: j }; } Log.err(label + ': gave up after ' + WS_TRIES + ' attempts —', url); return { failed: true }; } async function resolveByDiscogsUrl(discogsUrl, force) { if (!discogsUrl) return []; if (!force && _discogsResolveCache.has(discogsUrl)) return _discogsResolveCache.get(discogsUrl); for (let attempt = 0; attempt < 5; attempt++) { let r; try { r = await wsGet(`${ORIGIN}/ws/2/url?resource=${encodeURIComponent(discogsUrl)}&inc=artist-rels&fmt=json`); } catch (e) { await _sleep(1200); continue; } // network error → retry, don't cache if (r.status === 404) { _discogsResolveCache.set(discogsUrl, []); _dput('resolve', discogsUrl, []); return []; } // URL not in MB → 0 owners (cacheable) if ((await wsAnswer(r)).throttled) continue; // rate limited → the shared hold, don't cache (#575, #633) if (!r.ok) { await _sleep(1200); continue; } // other transient error → retry, don't cache let j; try { j = await r.json(); } catch (e) { await _sleep(1200); continue; } const seen = new Set(); const out = (j.relations || []).filter(rel => rel.artist && rel.artist.id).map(rel => ({ gid: rel.artist.id, name: rel.artist.name })).filter(e => !seen.has(e.gid) && seen.add(e.gid)); _discogsResolveCache.set(discogsUrl, out); _dput('resolve', discogsUrl, out); // cache ONLY a successful response return out; } return null; // all retries exhausted — unknown, leave uncached so it's retried later } // slot status from a match result — 'disc' for a confident Discogs-URL match, // 'rg' for a release-group sibling, else the name-search confidence. const slotStatusOf = m => !m.entity ? 'none' : (m.source === 'rg' ? 'rg' : m.source === 'pos' ? 'pos' : m.source === 'cred' ? 'cred' : m.source === 'alias' ? 'alias' : (m.source === 'discogs' && m.confidence === 'high') ? 'disc' : (m.source === 'plat' && m.confidence === 'high') ? 'plat' : m.confidence); /* ── add / create the Discogs link for a slot (#227) ────────────────────────── * Stash the slot's Discogs artist URL and decide whether a link can be added: * - unresolved slot + known URL → create the artist seeded with the link * - matched slot + URL that has NO MB owner yet → add it to that artist * - URL already owned (by this or another artist) → nothing to add * Reuses the cached `resolveByDiscogsUrl` (no extra request during matching). */ const DISCOGS_ARTIST_LINK_TYPE = '180'; // MB Discogs artist-URL relationship (string, for seeding) const DISCOGS_LINK_TYPE_ID = 180; // numeric, for matching the js entity rels const discogsIdOf = u => (String(u).match(/\/artist\/(\d+)/) || [])[1] || null; // The Discogs URLs a MB artist links, via the INTERNAL /ws/js entity endpoint // (not rate-limited). Returns an array (possibly empty) on success, or null on // failure. Cached per gid (#227 speed: linked artists cost no /ws/2 call). const _artistRelsCache = new Map(); // #231: persist the Discogs link caches across reloads/releases so the // rate-limited /ws/2/url lookups (and the internal rels reads) aren't repeated // on F5 or when revisiting a release. 1-day TTL (links change rarely; a fresh // edit still drops the entry immediately via the #227 invalidation). Invisible // (no UI), apollo-only. Stored as { resolve: {url:{v,t}}, rels: {gid:{v,t}} }. const DCACHE_KEY = 'apollo:discogs-link-cache-v1'; const DCACHE_TTL = 24 * 60 * 60 * 1000; let _dpersist = { resolve: {}, rels: {} }; let _dsaveTimer = null; function _dsave() { if (_dsaveTimer) return; _dsaveTimer = setTimeout(() => { _dsaveTimer = null; try { localStorage.setItem(DCACHE_KEY, JSON.stringify(_dpersist)); } catch (e) {} }, 700); } function _dput(kind, key, val) { _dpersist[kind][key] = { v: val, t: Date.now() }; _dsave(); } function _ddrop(kind, key) { if (_dpersist[kind] && _dpersist[kind][key]) { delete _dpersist[kind][key]; _dsave(); } } (function _dloadPersist() { try { const o = JSON.parse(localStorage.getItem(DCACHE_KEY) || 'null'); if (!o || typeof o !== 'object') return; const now = Date.now(); let pruned = false; for (const [k, e] of Object.entries(o.resolve || {})) { if (e && now - e.t < DCACHE_TTL) { _dpersist.resolve[k] = e; _discogsResolveCache.set(k, e.v); } else pruned = true; } for (const [k, e] of Object.entries(o.rels || {})) { if (e && now - e.t < DCACHE_TTL) { _dpersist.rels[k] = e; _artistRelsCache.set(k, e.v); } else pruned = true; } if (pruned) _dsave(); // write back without the expired entries } catch (e) {} })(); /* ── #651: artist matching by the platform links First Contact hands over ────────── First Contact seeds /release/add from a platform's album page and publishes, as its handoff, every credited artist with that artist's page on the platform (Deezer, Spotify, …). An MB artist that carries the link is as certain as one carrying the release's Discogs link, so it is the same stage: one owner → linked, badged with the platform. */ // #672: Apollo knows no platform. First Contact's handoff says what the platform is // (`platform: { abbr, name, artistLinkType? }`: the badge, the name in logs and edit notes, the // artist link type to seed where MB's URL cleanup can't pick one) and, on each link MB may store // in other forms (Apple Music's other storefronts, Amazon's other domains, ...), all of them // (`urlForms`). A link the handoff doesn't have, or an older (v1) handoff, gets the generic // "link" badge and is looked up as it is. Discogs is Apollo's own source (the release's Discogs // page), so a Discogs link is known without any handoff. const DISCOGS_PLATFORM = { abbr: 'disc', name: 'Discogs', artistLinkType: DISCOGS_ARTIST_LINK_TYPE }; function platformOf(url) { try { if (/(^|\.)discogs\.com$/i.test(new URL(url).hostname)) return DISCOGS_PLATFORM; } catch (e) { return null; } const h = fcHandoff(); return h && h.platform && h.platform.abbr && fcUrlForms(url) ? h.platform : null; } // The /ws/2/url lookup matches the URL exactly as MusicBrainz stores it: every form the handoff // gives is asked in the same batched request. function platformUrlForms(url) { const forms = [url]; // a bare site (a Bandcamp account is handed over as its origin) is stored with the trailing slash if (/^https?:\/\/[^/?#]+$/i.test(url)) forms.push(url + '/'); else if (/^https?:\/\/[^/?#]+\/$/i.test(url)) forms.push(url.slice(0, -1)); forms.push(...(fcUrlForms(url) || [])); return [...new Set(forms)]; } // Resolve platform links to their MB owners, batched: /ws/2/url takes up to 100 `resource`s per // call and answers only the ones it knows. → Map url → [{ gid, name }] (the owners across all of // its forms), or null for a url whose lookup failed (not cached, so it's asked again later). // Shares the Discogs cache: both are "who owns this URL", keyed by the URL. async function resolvePlatformUrls(urls, kind) { kind = kind || 'artist'; const key = f => (kind === 'artist' ? f : kind + ':' + f); // #652: a label's owners, cached apart urls = [...new Set(urls.filter(Boolean))]; const need = [...new Set(urls.flatMap(platformUrlForms))].filter(f => !_discogsResolveCache.has(key(f))); const failed = new Set(); for (let i = 0; i < need.length;) { const chunk = []; let len = 0; while (i < need.length && chunk.length < 100 && len < 6000) { chunk.push(need[i]); len += encodeURIComponent(need[i]).length + 10; i++; } const q = chunk.map(f => 'resource=' + encodeURIComponent(f)).join('&'); const r = await wsJson(`${ORIGIN}/ws/2/url?${q}&inc=${kind}-rels&fmt=json`, { label: kind === 'artist' ? 'platform links' : kind + ' links' }); if (r.notFound) { chunk.forEach(f => { _discogsResolveCache.set(key(f), []); _dput('resolve', key(f), []); }); Log.debug('platform links: none of', chunk.length, 'URL(s) is in MusicBrainz'); continue; } if (!r.json) { chunk.forEach(f => failed.add(f)); Log.warn('platform links: lookup of', chunk.length, 'URL(s) failed; they are asked again on the next pass'); continue; } const list = r.json.urls || (r.json.resource ? [r.json] : []); const by = new Map(list.map(x => [String(x.resource).toLowerCase(), x])); let known = 0; chunk.forEach(f => { const x = by.get(f.toLowerCase()); const seen = new Set(); const own = x ? (x.relations || []).filter(rel => rel[kind] && rel[kind].id).map(rel => ({ gid: rel[kind].id, name: rel[kind].name })).filter(e => !seen.has(e.gid) && seen.add(e.gid)) : []; if (own.length) known++; _discogsResolveCache.set(key(f), own); _dput('resolve', key(f), own); }); Log.debug('platform links: looked up', chunk.length, 'URL form(s) in one request,', known, 'owned by an MB artist'); } const out = new Map(); urls.forEach(url => { const forms = platformUrlForms(url), seen = new Set(), own = []; let unknown = false; forms.forEach(f => { if (failed.has(f) || !_discogsResolveCache.has(key(f))) { unknown = true; return; } _discogsResolveCache.get(key(f)).forEach(e => { if (!seen.has(e.gid)) { seen.add(e.gid); own.push(e); } }); }); out.set(url, own.length ? own : unknown ? null : []); }); return out; } const resolveByPlatformUrl = async url => (url ? (await resolvePlatformUrls([url])).get(url) : []); // drop a link's cached owners (after we added it, or created an artist with it) const forgetPlatformUrl = url => platformUrlForms(url).forEach(f => { _discogsResolveCache.delete(f); _ddrop('resolve', f); }); // First Contact's handoff: on , and again on its 'first-contact:seed' // event (asked for with 'first-contact:request', in case it came before us). let _fcHandoff = null, _fcUrls = new Map(); // every link in a handoff → the forms it says MB may store it under ([] when only its own) function handoffUrlIndex(h) { const idx = new Map(), add = a => { if (a && a.url) idx.set(a.url, Array.isArray(a.urlForms) ? a.urlForms : []); }; (h.credit || []).forEach(add); (h.labels || []).forEach(add); (h.mediums || []).forEach(m => (m.tracks || []).forEach(t => (t.credit || []).forEach(add))); return idx; } function takeFcHandoff(json, from) { try { const h = JSON.parse(json || 'null'); if (!h || !h.mediums || (_fcHandoff && _fcHandoff.token === h.token)) return; _fcHandoff = h; _fcUrls = handoffUrlIndex(h); const p = h.platform; if (p) Log.debug('First Contact platform:', p.name, '- badge', p.abbr + ', artist link type', (p.artistLinkType || '(MB picks it)') + ',', [..._fcUrls.values()].filter(x => x.length).length, 'link(s) with other forms'); else Log.debug('First Contact handoff v' + (h.v || '?') + ' says nothing about the platform: generic "link" badge, links looked up as they are, no link type seeded'); const n = [h.credit || []].concat(...h.mediums.map(m => m.tracks.map(t => t.credit || []))).reduce((k, c) => k + c.filter(a => a.url).length, 0); Log.info('First Contact handoff (' + from + '):', h.sourceName || h.source, '—', n, 'artist credit(s) carry a platform link', h.url ? '(' + h.url + ')' : ''); } catch (e) { Log.warn('First Contact handoff unreadable:', e.message); } } function fcHandoff() { if (!_fcHandoff && document.documentElement.dataset.firstContact) takeFcHandoff(document.documentElement.dataset.firstContact, 'page'); return _fcHandoff; } // a link's forms per the handoff, or undefined for a link the handoff doesn't have const fcUrlForms = url => (fcHandoff(), _fcUrls.get(url)); if (/\/release\/add/.test(location.pathname)) { document.addEventListener('first-contact:seed', e => takeFcHandoff(e.detail, 'event')); try { document.dispatchEvent(new CustomEvent('first-contact:request')); } catch (e) { /* no First Contact */ } } // the handoff's track for an Apollo track: the same medium, the same title (a reordered // tracklist still finds it), else the same position function fcTrackFor(t) { const h = fcHandoff(); if (!h) return null; const med = h.mediums[t.mi]; if (!med || !med.tracks) return null; return med.tracks.find(x => sameName(x.title, t.title)) || med.tracks[t.ti] || null; } // a credit's platform link: the credited name first, else the same position when the credit // still has as many artists as the handoff's function fcCreditUrl(credit, i, creditedAs, nSlots) { if (!credit || !credit.length) return null; const byName = credit.find(a => a.url && (sameName(a.name, creditedAs) || sameName(a.artistName, creditedAs))); if (byName) return byName.url; return credit.length === nSlots && credit[i] && credit[i].url ? credit[i].url : null; } const fcPlatformUrl = (t, i, creditedAs) => { const ht = fcTrackFor(t); return ht ? fcCreditUrl(ht.credit, i, creditedAs, t.slots ? t.slots.length : (t.names || []).length) : null; }; // every platform link the handoff has for the pending slots, resolved in a few batched requests // before the match pass, so each slot's own lookup is answered from the cache async function prewarmPlatformLinks(tracks) { if (!fcHandoff()) return; const urls = []; tracks.forEach(t => t.slots.forEach((s, i) => { const u = fcPlatformUrl(t, i, s.creditedAs); if (u) urls.push(u); })); if (!urls.length) { Log.debug('platform links: the handoff has none for these slots'); return; } const t0 = Date.now(), res = await resolvePlatformUrls(urls), uniq = [...res.keys()]; const owned = uniq.filter(u => res.get(u) && res.get(u).length).length, failed = uniq.filter(u => res.get(u) === null).length; Log.info('platform links:', uniq.length, 'distinct artist link(s) from', _fcHandoff.sourceName || 'First Contact', '—', owned, 'owned by an MB artist,', uniq.length - owned - failed, 'not in MusicBrainz' + (failed ? ', ' + failed + ' lookup(s) failed' : ''), '(' + (Date.now() - t0) + ' ms)'); } // 🔗 / ⚠ for a slot's platform link, as for Discogs (#227): a matched artist without the link // can have it added (⚠ when another artist owns it); an unmatched slot whose link nobody owns // can create its artist with it. async function tagPlatformAddable(slot, url) { slot._platUrl = url || null; slot._platAddable = false; slot._platConflict = null; if (!url) return; if (slot.gid && SPECIAL_PURPOSE_ARTISTS.has(slot.gid)) return; // never link a special-purpose artist (#306) const hits = await resolveByPlatformUrl(url); if (hits === null) return; // unknown: offer nothing rather than a wrong offer if (slot.committed && slot.gid) { if (hits.some(h => h.gid === slot.gid)) return; // the artist already has it slot._platAddable = true; if (hits.length) slot._platConflict = hits[0]; return; } slot._platAddable = hits.length === 0; if (hits.length) slot._platConflict = hits[0]; } let _tagPlatRunning = false, _tagPlatQueued = false; async function tagPlatformForAll() { if (!MODEL || !fcHandoff()) { if (_riArt.length) riRetag(); return; } if (_tagPlatRunning) { _tagPlatQueued = true; return; } _tagPlatRunning = true; try { const jobs = []; MODEL.tracks.forEach(t => t.slots.forEach((s, i) => { const u = fcPlatformUrl(t, i, s.creditedAs); if (u || s._platUrl) jobs.push([s, u]); })); await resolvePlatformUrls(jobs.map(j => j[1])); for (const [s, u] of jobs) await tagPlatformAddable(s, u); const linked = s => !!(s.committed && s.gid); // an uncommitted candidate isn't the slot's artist yet const add = jobs.filter(([s]) => s._platAddable && linked(s)).length, create = jobs.filter(([s]) => s._platAddable && !linked(s)).length, conf = jobs.filter(([s]) => s._platConflict).length; const outcome = `${jobs.length} slot(s) with a platform link: ${add} link(s) can be added, ${create} artist(s) can be created with theirs, ${conf} owned by another artist`; if (outcome !== _lastPlatCheck) { _lastPlatCheck = outcome; Log.info('platform links:', outcome); } if (!isEditingNow()) rerender(); } finally { _tagPlatRunning = false; if (_tagPlatQueued) { _tagPlatQueued = false; tagPlatformForAll(); } else if (_riArt.length) riRetag(); // #652: the release artist's offers too } } let _lastPlatCheck = ''; function platAddTooltip(s) { const p = platformOf(s._platUrl) || { name: 'platform' }, conf = s._platConflict; if (!(s.committed && s.gid)) return `Create this artist with its ${p.name} link (${s._platUrl})`; return conf ? `${p.name}: ${s._platUrl} is linked to ${conf.name}, not to this artist — click to add it here anyway` : `${p.name}: this artist doesn't have ${s._platUrl} yet — click to add it`; } // add the slot's platform link to its artist (an edit of the artist), or create the artist with it function addOrCreatePlatformLink(slot, background) { const url = slot._platUrl; if (!url) return; // an unmatched slot, or one with only an uncertain candidate: create the credited artist with the link if (!(slot.committed && slot.gid)) { createArtist((slot.creditedAs || '').trim() || slot.name || '', slot, slot._discogsUrl || null, background); return; } const p = platformOf(url) || { name: 'platform' }, gid = slot.gid; const q = { 'edit-artist.url.0.text': url, 'edit-artist.edit_note': entityActionNote('Added ' + p.name + ' link') }; if (p.artistLinkType) q['edit-artist.url.0.link_type_id'] = p.artistLinkType; const editUrl = `${ORIGIN}/artist/${gid}/edit?${new URLSearchParams(q)}`; const done = async () => { forgetPlatformUrl(url); let hits = null; for (let attempt = 0; attempt < 5; attempt++) { // read-after-write: the new link can take a moment to show (#479) hits = await resolveByPlatformUrl(url); if (hits && hits.some(h => h.gid === gid)) break; forgetPlatformUrl(url); await _sleep(800 * (attempt + 1)); } if (hits && hits.some(h => h.gid === gid)) { MODEL && MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.gid === gid) s._flash = true; })); discMsg(`added ${p.name} link to ${slot.name || 'the artist'}`); } else Log.warn(p.name, 'link for', slot.name || gid, 'not visible yet; it stays offered until the next check'); await tagPlatformForAll(); }; if (background && typeof GM_openInTab === 'function' && ART_CHANNEL) { const bgTab = GM_openInTab(`${editUrl}#tc-autocommit`, { active: false, insert: true }); const onCommitted = e => { if (!e.data || e.data.type !== 'tc-edit-committed' || e.data.gid !== gid) return; ART_CHANNEL.removeEventListener('message', onCommitted); try { if (bgTab && typeof bgTab.close === 'function') bgTab.close(); } catch (x) {} done(); }; ART_CHANNEL.addEventListener('message', onCommitted); Log.info(p.name, 'link (background) for', slot.name || gid, '→', url); return; } const tab = W.open(editUrl, '_blank'); if (tab) { const trySet = () => { try { tab.sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) { setTimeout(trySet, 50); } }; trySet(); } Log.info(p.name, 'link: opening the edit of', slot.name || gid, '→', url); const onReturn = async () => { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onReturn); await done(); }; document.addEventListener('visibilitychange', onReturn); } async function artistDiscogsUrls(gid, force) { if (!gid) return []; if (!force && _artistRelsCache.has(gid)) return _artistRelsCache.get(gid); let out = null; for (let attempt = 0; attempt < 4; attempt++) { try { const r = await fetch(`${ORIGIN}/ws/js/entity/${gid}?inc=rels`, { headers: { Accept: 'application/json' } }); if (r.status === 429 || r.status === 503) { await _sleep(800); continue; } // rate limited → back off, don't cache a miss if (!r.ok) { await _sleep(500); continue; } const j = await r.json(); out = (j.relationships || j.relations || []) .filter(x => x.linkTypeID === DISCOGS_LINK_TYPE_ID && (x.target_type === 'url' || (x.target && x.target.entityType === 'url'))) .map(x => (x.target && (x.target.name || x.target.decoded || x.target.pretty_name)) || ''); break; } catch (e) { await _sleep(500); } } if (out !== null) { _artistRelsCache.set(gid, out); _dput('rels', gid, out); } // cache only a real response (a rate-limited miss stays uncached → retried) return out; } async function tagDiscogsAddable(slot, durl) { slot._discogsUrl = durl || null; slot._discogsConflict = null; slot._discogsMismatch = null; slot._discogsPending = false; slot._discogsChecked = false; if (!durl || SETTINGS.discogsUrlMatch === false) { slot._discogsAddable = false; return; } // #281: only a RESOLVED (committed) artist can meaningfully have/lack a Discogs // link — an unresolved slot has no MB artist to link, and on add-release that // littered every row with chains/warnings. Skip those (the + create button still // seeds slot._discogsUrl, so create-with-link is unaffected). Keep _discogsUrl set. if (!slot.committed || !slot.gid) { slot._discogsAddable = false; return; } // #306 — special-purpose artists ([no artist], Various Artists, [Disney], …) must // never carry a Discogs link (some can't even be edited), so never offer to add one. if (SPECIAL_PURPOSE_ARTISTS.has(slot.gid)) { slot._discogsAddable = false; return; } slot._discogsChecked = true; if (slot.gid) { // FREE check first: this artist's own Discogs links (internal endpoint) const own = await artistDiscogsUrls(slot.gid); if (own === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } // unknown const want = discogsIdOf(durl); if (own.some(u => discogsIdOf(u) === want)) { slot._discogsAddable = false; return; } // already linked to THIS Discogs page → legit, no badge if (own.length) { // #227: artist links a DIFFERENT Discogs page than the release credits → mismatch. slot._discogsAddable = true; // still offer to add the release's link (majkinetor) — flagged with ⚠ slot._discogsMismatch = own.find(u => discogsIdOf(u)) || own[0]; return; } // /ws/js found no Discogs link — confirm with the AUTHORITATIVE URL→artist // lookup before declaring it missing. This also catches a false-empty // /ws/js response (the artist actually owns the URL) so we never offer to // add a link it already has (#227). const hits = await resolveByDiscogsUrl(durl); if (hits === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } if (hits.some(h => h.gid === slot.gid)) { slot._discogsAddable = false; return; } // this artist already owns the URL → legit slot._discogsAddable = true; if (hits.length) slot._discogsConflict = hits[0]; // URL owned by a different artist → add anyway, but warn return; } // unresolved slot — need the URL→artist lookup to decide create vs. pick const hits = await resolveByDiscogsUrl(durl); if (hits === null) { slot._discogsAddable = false; slot._discogsPending = true; return; } slot._discogsAddable = hits.length === 0; if (hits.length) slot._discogsConflict = hits[0]; } // Tag every slot in the model — covers page-load 'set' artists, which skip the // match pass (#227). Updates each row as it resolves (real-time) and shows // progress in the always-visible toolbar status. Cached resolves are instant. // The release's Discogs link can land in the DOM a beat after we're invoked — // it lives on the Release-Information tab and switching to Tracklist first // races its render. Poll briefly (silently — blank badge, not "checking…") so // the check still runs on the FIRST visit instead of bailing to nothing. A // release that genuinely has no Discogs link just polls out and stays blank. async function discogsReleaseUrlSoon(maxMs = 8000) { const t0 = Date.now(); for (;;) { const u = discogsReleaseUrlFromPage(); if (u || Date.now() - t0 >= maxMs) return u || null; await _sleep(400); } } let _tagDiscogsRunning = false, _tagDiscogsQueued = false; let _discMapFailed = false, _discMapRetried = false; // #281: Discogs API unreachable → retry affordance async function tagDiscogsForAll() { if (!MODEL || SETTINGS.discogsUrlMatch === false) return; // #281: MB lazy-loads the rest of the tracks a beat after we start, which rebuilds // the model (fresh slots) and calls us again mid-run. The old guard DROPPED that // call, so the rebuilt model was never checked and the badge went blank until a // reload. Queue the call and re-run against the latest model when this run ends. if (_tagDiscogsRunning) { _tagDiscogsQueued = true; return; } _tagDiscogsRunning = true; try { // wait for the release Discogs link to exist before deciding there's nothing // to do — this is the #1 reason a first visit showed a blank badge. const relUrl = await discogsReleaseUrlSoon(); if (!relUrl) { _discMapFailed = false; setDiscProgress(''); return; } // genuinely no Discogs link on this release setDiscProgress('checking Discogs links…'); // now that we know there's work, show it const dmap = await loadDiscogsMap(); if (dmap === null) { // #281: couldn't reach the Discogs API (rate-limited / network) after retries. // DON'T show a misleading blank (which looked like "no links, all good") — flag // it so the badge offers a one-click retry, and auto-retry once shortly so it // usually self-heals without the user having to reload the page. _discMapFailed = true; if (!_discMapRetried) { _discMapRetried = true; setTimeout(() => { if (_discMapFailed) tagDiscogsForAll(); }, 6000); } return; } _discMapFailed = false; _discMapRetried = false; const jobs = []; const relArtists = (dmap && dmap.releaseArtists) || []; let posUsed = 0; const total = MODEL.tracks.length; if (dmap) MODEL.tracks.forEach((t, ti) => { const { urls, byPos } = discogsUrlsForTrack(dmap, t.title, ti, total); const durls = urls || []; const hasTrackArtists = durls.some(Boolean); if (byPos && hasTrackArtists) posUsed++; t.slots.forEach((s, i) => { // per-track Discogs artist (by title, else by position), else inherit the // release-level artist POSITIONALLY (#283/#287). Strictly relArtists[i]: a // single release artist only fills slot 0, NOT a track's feat. guests — // assigning the release artist to a guest slot caused false mismatches (#287). const durl = durls[i] || discogsFeatUrlFor(dmap, t.title, ti, total, s.creditedAs) || (!hasTrackArtists ? (relArtists[i] || null) : null); // #442 feat credit if (durl) { s._discByPos = !!(durls[i] && byPos); jobs.push([s, durl]); } }); }); if (!jobs.length) { setDiscProgress(''); // nothing to check (no per-track and no release-level artist links). Empty // model = still loading → stay silent; dedupe so re-runs don't repeat it. if (MODEL.tracks && MODEL.tracks.length && _lastDiscCheck !== 'none') { _lastDiscCheck = 'none'; Log.info('Discogs check: no artist links to verify'); } return; } const firstCheck = _discVerifyUrl !== relUrl; if (firstCheck) { _discVerifyUrl = relUrl; Log.info('Discogs check: verifying', jobs.length, 'artist link(s) across', total, total === 1 ? 'track' : 'tracks', (posUsed ? `(${posUsed} matched by position)` : '') + '…'); } let done = 0, lastRender = 0; for (const [s, durl] of jobs) { await tagDiscogsAddable(s, durl); if (firstCheck) Log.debug('Discogs:', (s.name || s.gid || 'slot'), '—', s._discogsPending ? 'pending (will re-check)' : !s._discogsAddable ? 'already linked' : discAddTooltip(s), s._discByPos ? '(matched by position)' : ''); done++; // update rows + the progress text together, throttled — set the text AFTER // rerender so refreshStatus can't blank it const now = Date.now(); if (now - lastRender > 300) { if (!isEditingNow()) rerender(); setDiscProgress(`checking Discogs links ${done}/${jobs.length}…`); lastRender = now; } } // A slot whose lookup returned null (cold cache / a transient rate-limit) is // left "pending" and never shows in the badge — that's why the FIRST visit // could read "0 links" while the SECOND (warm persistent cache) suddenly // revealed them. Retry the pending slots once now that the per-request gate // has paced out and the caches are partly warm, so the count settles on the // first visit. Anything still pending is surfaced by the badge (not hidden). const pend = jobs.filter(([s]) => s._discogsPending); if (pend.length) { let r = 0; for (const [s, durl] of pend) { setDiscProgress(`re-checking Discogs links ${++r}/${pend.length}…`); await tagDiscogsAddable(s, durl); } } const addable = jobs.filter(([s]) => s._discogsAddable).length; const pendLeft = jobs.filter(([s]) => s._discogsPending).length; const outcome = (addable === 0 ? `all ${jobs.length} artist link(s) already in MusicBrainz ✓` : `${addable} of ${jobs.length} link(s) can be added to MusicBrainz`) + (pendLeft ? ` (${pendLeft} pending)` : ''); if (outcome !== _lastDiscCheck) { _lastDiscCheck = outcome; Log.info('Discogs check:', outcome); } // dedupe identical re-run results if (!isEditingNow()) rerender(); } finally { _tagDiscogsRunning = false; // #281: if a rebuild raced us, re-run for the latest model (it repaints the // badge); otherwise settle the persistent badge now. (#227) if (_tagDiscogsQueued) { _tagDiscogsQueued = false; tagDiscogsForAll(); } else setDiscStat(); } } // chain glyph shown in place of the artist-type icon when a Discogs link can be added const DISCOGS_LINK_SVG = ''; // warning triangle shown when the Discogs URL links a DIFFERENT MB artist (#227) const DISCOGS_WARN_SVG = ''; // #—: a distinct glyph for a Discogs "mismatch" (artist links a different page) — a circle-! so it // reads apart from the "conflict" triangle at a glance (and not by colour alone). const DISCOGS_MISMATCH_SVG = ''; // after the artist's Discogs link changes (foreground return or background // postback): drop the stale caches and re-tag every slot crediting that artist. // // #479 (majkinetor: "it does change them, although probably due to rate limit // it fails to always do so"): a single artistDiscogsUrls() call right after the // edit landed can genuinely come back empty — not from a 429 (this hits MB's // internal /ws/js/entity endpoint, which the request itself already retries on // rate-limit), but from read-after-write lag: the edit was JUST committed, and // the internal endpoint's data can take a beat to catch up. artistDiscogsUrls // caches ANY successful response, including that stale empty one, so it and // every slot that reuses the cache (the tagDiscogsForAll() call right below) // then stay wrong until something else happens to drop the cache — the button // never flips to normal. Retry with force=true (bypassing the cache each time) // for a few seconds, since — unlike the general "does this artist have a // Discogs link at all" check elsewhere — we know FOR CERTAIN a write just // happened, so it's worth waiting for it to show up rather than accepting the // first answer. async function reTagAfterDiscogsLink(gid, url, name) { _discogsResolveCache.delete(url); _ddrop('resolve', url); const wantId = discogsIdOf(url); let own = null; for (let attempt = 0; attempt < 6; attempt++) { own = await artistDiscogsUrls(gid, true); if (own && own.some(u => discogsIdOf(u) === wantId)) break; if (attempt < 5) await _sleep(800 * (attempt + 1)); } if (own && own.some(u => discogsIdOf(u) === wantId)) { MODEL && MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.gid === gid) s._flash = true; })); discMsg(`added Discogs link to ${name}`); } else Log.warn('Discogs link for', name || gid, 'still not visible via /ws/js/entity after retrying — will keep showing as addable until the next check'); await tagDiscogsForAll(); if (_riArt.length) await riRetag(); // #652 } function addOrCreateDiscogsLink(slot, background) { const url = slot._discogsUrl; if (!url) return; if (slot.gid) { const gid = slot.gid; const p = new URLSearchParams({ 'edit-artist.url.0.text': url, 'edit-artist.url.0.link_type_id': DISCOGS_ARTIST_LINK_TYPE, 'edit-artist.edit_note': entityActionNote('Added Discogs link') }); const editUrl = `${ORIGIN}/artist/${gid}/edit?${p}`; // #273: right-click → add the link SILENTLY in a background tab + auto-submit. // The edit-page bootstrap clicks "Enter edit"; the saved entity page posts // `tc-edit-committed` back, and we re-tag + close the GM tab here (no focus // return happens for a background tab). if (background && typeof GM_openInTab === 'function' && ART_CHANNEL) { const bgTab = GM_openInTab(`${editUrl}#tc-autocommit`, { active: false, insert: true }); const onCommitted = (e) => { if (!e.data || e.data.type !== 'tc-edit-committed' || e.data.gid !== gid) return; ART_CHANNEL.removeEventListener('message', onCommitted); try { if (bgTab && typeof bgTab.close === 'function') bgTab.close(); } catch (x) {} reTagAfterDiscogsLink(gid, url, slot.name); }; ART_CHANNEL.addEventListener('message', onCommitted); Log.info('Discogs link (background) for', slot.name || gid, '→', url); return; } // foreground: open the edit form, flag it to auto-close after submit, verify on return const tab = W.open(editUrl, '_blank'); if (tab) { const trySet = () => { try { tab.sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) { setTimeout(trySet, 50); } }; trySet(); } Log.info('Discogs link: opening edit for', slot.name || gid, '→', url); const onReturn = async () => { if (document.visibilityState !== 'visible') return; document.removeEventListener('visibilitychange', onReturn); await reTagAfterDiscogsLink(gid, url, slot.name); }; document.addEventListener('visibilitychange', onReturn); } else { // no MB artist yet — create one seeded with the link (same as +) createArtist((slot.creditedAs || '').trim() || slot.name || '', slot, url, background); } } // #437 — the gids of artists already known on THIS release: a slot's own split // co-artists (strongest) + the release artist(s). These seed the credit-based // disambiguation below. Capped so a wide "Various Artists" release can't fan out. function releaseArtistGids() { try { return acArtistGids(u(release().artistCredit)); } catch (e) { return []; } } // #618: a special-purpose artist (Various Artists, [unknown], [traditional]…) is never a // co-credit context — no track is credited "alongside Various Artists", so a search seeded // with it can only cost a paced request per ambiguous name for nothing. Dropped BEFORE the // cap, so it can't take one of the six places either. const coCreditCtx = g => !!g && !SPECIAL_PURPOSE_ARTISTS.has(g); function slotContextGids(entry, idx) { const gids = []; if (entry && entry.slots) entry.slots.forEach((s, j) => { if (j !== idx && s && s.gid) gids.push(s.gid); }); releaseArtistGids().forEach(g => gids.push(g)); return [...new Set(gids)].filter(coCreditCtx).slice(0, 6); } // #437 — disambiguate a common name ("Joni") by CO-OCCURRENCE. A bare name search // is hopeless for a non-unique name, but if the release already credits a known // artist A (a split co-artist, or the release artist), one indexed search asks MB // for a recording that credits A *alongside* an artist credited as — that // co-credited artist is a far more confident hit than a name search (majkinetor: // rank above 'search'). ONLY an exact credited-as / name hit counts; fuzz would // just reintroduce the ambiguity. Ranked by how many distinct context artists // produce each candidate; a lone / clear winner auto-matches, a tie is surfaced. const _credCache = new Map(); // `${arid}\n${fold(name)}` → [{gid,name}] const _lucenePhrase = s => '"' + String(s).replace(/(["\\])/g, '\\$1') + '"'; async function resolveByCredit(creditedAs, contextGids) { const name = (creditedAs || '').trim(); if (contextGids && contextGids.some(g => g && !coCreditCtx(g))) { // #618 belt and braces — whatever a caller passes Log.debug('cred lookup: skipping special-purpose context artist(s)', contextGids.filter(g => g && !coCreditCtx(g)).join(', ')); contextGids = contextGids.filter(coCreditCtx); } if (!name || !contextGids || !contextGids.length) return { entity: null, candidates: [] }; const tally = new Map(); // candidate gid → { count, name } for (const ctx of contextGids) { const ck = ctx + '\n' + fold(name); let hits = _credCache.get(ck); if (!hits) { hits = []; const q = `arid:${ctx} AND artistname:${_lucenePhrase(name)}`; const res = await wsJson(`${ORIGIN}/ws/2/recording?query=${encodeURIComponent(q)}&inc=artist-credits&limit=25&fmt=json`, { label: 'cred lookup' }); if (!res.json) { Log.warn('cred lookup failed for', JSON.stringify(name), 'in', ctx, '— not cached'); continue; } // a throttled miss must not be cached (#555) for (const rec of (res.json.recordings || [])) { for (const c of (rec['artist-credit'] || [])) { const a = c.artist; if (!a || !a.id || a.id === ctx) continue; // the co-credited artist, not the context one if (sameName(c.name, name) || sameName(a.name, name)) hits.push({ gid: a.id, name: a.name }); } } _credCache.set(ck, hits); } // one vote per distinct context artist (dedupe repeats within its own hits) new Set(hits.map(h => h.gid)).forEach(gid => { const nm = (hits.find(h => h.gid === gid) || {}).name; const t = tally.get(gid) || { count: 0, name: nm }; t.count++; tally.set(gid, t); }); } if (!tally.size) return { entity: null, candidates: [] }; const ranked = [...tally.entries()].sort((a, b) => b[1].count - a[1].count); const unique = ranked.length === 1 || ranked[0][1].count > ranked[1][1].count; // clear winner? const candidates = []; for (const [gid] of ranked) { const e = await fetchEntity(gid); if (e && e.gid) candidates.push(e); } return { entity: unique ? candidates[0] : null, candidates, counts: ranked.map(([gid, v]) => ({ gid, name: v.name, count: v.count })), ctx: contextGids.length }; // counts/ctx: #654 tooltip } // #441 — pick the RG-sibling artist for a slot by NAME, not blind index. Editions // differ in how many artists they credit (a title that omits a co-feature — "feat. // Nile Rodgers" where the full credit is "feat. John Newman & Nile Rodgers" — has // fewer slots), so sib[i] by position grabbed the wrong artist. Prefer the positional // one only when its name/credited-as actually agrees; else find the sibling artist // whose name/credited-as matches this slot's credited name; else none (fall through). function pickSibArtist(sibArr, creditedAs, idx) { if (!Array.isArray(sibArr) || !sibArr.length) return null; const agrees = s => s && s.gid && (sameName(s.creditedAs, creditedAs) || sameName(s.name, creditedAs)); const at = sibArr[idx]; if (agrees(at)) return at; return sibArr.find(agrees) || null; } /* ── #626: the artist credited at this track's position on other editions ────── The Recordings pass links a track by the recording at its position on the release group's editions and on the duplicates (#440). The artist credited on that same track is just as good evidence for the track ARTIST, and it catches what the name lookups can't: a spelling variant ("The Revolution of St.Vincent" → "…of St. Vincent", "Juan Formel" → "Juan Formell", "Cedric Im Brooks" → "Cedric “Im” Brooks") and, with no release group linked, a name several MB artists share ("Duke", "Sambo", "Ophelia"). A position counts only when its track passes the Recordings test — a similar title and a length within tolerance (an exact title when either length is unknown) — and then the NAME only has to match loosely, because the position has already narrowed the choice to the one to three artists credited on that track. Loose against all of MusicBrainz would be reckless; loose among three is safe. */ const posNameKey = s => fold(s).replace(/&/g, ' and ').replace(/^the\s+/, '').replace(/[^\p{L}\p{N}]+/gu, ''); function posNameMatch(a, b) { const x = posNameKey(a), y = posNameKey(b); if (!x || !y) return false; return x === y || 1 - recLev(x, y) / Math.max(x.length, y.length) >= 0.85; } // position "." → the tracks there on other releases, with their credits. // Every release is kept (unlike the recordings' index, which keeps one entry per // recording), so "N of M editions" counts releases. function addReleaseToArtistPos(rel, idx, skipGid, from) { // from: 'rg' or 'dup' (#654: the card says which) if (!rel || !rel.id || rel.id === skipGid) return; // never the release being edited (rel.media || []).forEach(med => { const medLens = mediumLensOf(med); (med.tracks || []).forEach(t => { const ac = (t['artist-credit'] && t['artist-credit'].length) ? t['artist-credit'] : ((t.recording && t.recording['artist-credit']) || []); if (!ac.length) return; const key = (med.position || 1) + '.' + (t.position || 0); if (!idx.has(key)) idx.set(key, []); idx.get(key).push({ rel: rel.id, from: from || 'rg', relTitle: rel.title || '', relDate: rel.date || '', format: med.format || '', title: t.title || (t.recording && t.recording.title) || '', length: t.length || (t.recording && t.recording.length) || null, ac, medLens }); }); }); } let _artPosRg = { gid: null, self: null, idx: null }; let _artPosDup = { key: null, p: null }; let _artPosDupFromTab = false; // the duplicates came from the Duplicates tab, not our own search (the card says which) // the release group's editions (free: the rg source's own request) … async function artistPosRgIndex() { const rel = release(), self = rel ? u(rel.gid) || null : null; const rg = rel && u(rel.releaseGroup), rgGid = rg ? u(rg.gid) : null; if (!rgGid) return null; if (_artPosRg.gid === rgGid && _artPosRg.self === self && _artPosRg.idx) return _artPosRg.idx; const rels = await rgReleases(rgGid); if (!rels) return null; const idx = new Map(); rels.forEach(r => addReleaseToArtistPos(r, idx, self, 'rg')); _artPosRg = { gid: rgGid, self, idx }; Log.debug('artist position index: release group', rgGid, '→', rels.length, 'edition(s),', idx.size, 'position(s)'); return idx; } // … and, only when those don't settle a credit, the duplicates: first the ones the // Duplicates tab already fetched for its Similarity column (free), else the same search // the Recordings pass makes (shared, so the two passes run it once between them). function artistPosDupIndex() { const rel = release(), self = rel ? u(rel.gid) || null : null; const rg = rel && u(rel.releaseGroup), rgGid = rg ? u(rg.gid) : null; const title = rel ? (u(rel.name) || '') : ''; const artistGid = (acArtistGids(u(rel && rel.artistCredit)) || [])[0] || null; const key = [fold(title), artistGid, rgGid, self, _dupRawCache.size].join('|'); if (_artPosDup.key === key) return _artPosDup.p; const p = (async () => { let rels = [..._dupRawCache.values()], from = 'the Duplicates tab'; if (!rels.length) { rels = await duplicateReleases(title, artistGid, rgGid); from = 'a duplicate search'; } _artPosDupFromTab = from === "the Duplicates tab"; const idx = new Map(); rels.forEach(r => addReleaseToArtistPos(r, idx, self, 'dup')); Log.debug('artist position index: duplicates from', from, '→', rels.length, 'release(s),', idx.size, 'position(s)'); return idx; })(); _artPosDup = { key, p }; return p; } // the artists at this track's position that match the credited name → { artists: [{ gid, // name, votes }], of } — `of` = how many other editions' tracks passed the test there function tallyPosArtists(hits, creditedAs) { const rels = new Set(hits.map(h => h.rel)), by = new Map(); hits.forEach(h => h.ac.forEach(a => { const g = a.artist && a.artist.id; if (!g || SPECIAL_PURPOSE_ARTISTS.has(g)) return; if (!posNameMatch(creditedAs, a.name) && !posNameMatch(creditedAs, a.artist.name)) return; const v = by.get(g) || { gid: g, name: a.artist.name || a.name, rels: new Set() }; v.rels.add(h.rel); by.set(g, v); })); // #654: each edition, and whom it credits there, for the match tooltip const seen = new Set(), editions = []; hits.forEach(h => { if (seen.has(h.rel)) return; seen.add(h.rel); editions.push({ gid: h.rel, from: h.from || 'rg', title: h.relTitle, date: h.relDate || '', format: h.format || '', names: h.ac.map(a => (a.artist && a.artist.name) || a.name), gids: h.ac.map(a => a.artist && a.artist.id).filter(Boolean) }); }); return { artists: [...by.values()].map(v => ({ gid: v.gid, name: v.name, votes: v.rels.size })).sort((a, b) => b.votes - a.votes), of: rels.size, editions }; } async function positionArtists(entry, creditedAs) { if (!entry || !(creditedAs || '').trim()) return null; let key, len; try { const ko = koTrack(entry.mi, entry.ti); key = (u(mediums()[entry.mi].position) || (entry.mi + 1)) + '.' + (u(ko.position) || (entry.ti + 1)); len = u(ko.length) || null; } catch (e) { return null; } const agrees = c => (recSimilar(c.title, entry.title) && (c.length && len ? recLenGap(c.length, len) === 0 : recFold(c.title) === recFold(entry.title))) || (scriptsDiffer(c.title, entry.title) && mediumAligned(c.medLens, entry.mi)); // one title in another script (#626) const at = idx => ((idx && idx.get(key)) || []).filter(agrees); let hits = at(await artistPosRgIndex()); let res = tallyPosArtists(hits, creditedAs); if (res.artists.length !== 1) { hits = hits.concat(at(await artistPosDupIndex())); res = tallyPosArtists(hits, creditedAs); } Log.debug('position source:', JSON.stringify(creditedAs), 'at', key, '—', res.of, 'edition(s) agree on the track;', res.artists.length ? res.artists.map(a => a.name + ' ×' + a.votes).join(', ') : 'none credit a matching name'); return res.artists.length ? res : null; } // `pos` (#626): a function returning positionArtists() for this slot — called only once the // Discogs link and the rg source have had their turn, so a slot they settle costs nothing. /* #654 (majkinetor: a richer tooltip "that contains not only description but also more match details visible in the log"): every match result carries `why`, what each stage it reached found — the Discogs link, the release-group sibling (which release and track), the other editions at this position, the name/alias search, the co-credits — and when. The slot keeps it as _why and the badge's card shows it. Nothing extra is fetched for it. */ async function matchSlot(creditedAs, sib, discogsUrl, contextGids, pos, platUrl) { const why = { at: Date.now() }; const m = await matchSlotCore(creditedAs, sib, discogsUrl, contextGids, pos, why, platUrl); m.why = why; return m; } async function matchSlotCore(creditedAs, sib, discogsUrl, contextGids, pos, why, platUrl) { const who = creditedAs || '(track artist)'; let posInfo = null; // { of, artists: [{ entity, votes }] } — kept on the slot for the picker's section // #224: a Discogs artist-link match outranks the name search. if (SETTINGS.discogsUrlMatch !== false && discogsUrl) { const hits = await resolveByDiscogsUrl(discogsUrl); why.discogs = { url: discogsUrl, n: hits ? hits.length : null }; if (hits && hits.length === 1) { const e = await fetchEntity(hits[0].gid); if (e && e.gid) { Log.info('Match:', who, '→', e.name, '— via Discogs URL'); return { entity: e, source: 'discogs', confidence: 'high', candidates: [e] }; } } else if (hits && hits.length > 1) { // ambiguous — surface every linked artist plus the name-search hits and let the user pick const named = await searchArtist(creditedAs); const ents = []; for (const h of hits) { const e = await fetchEntity(h.gid); if (e && e.gid) ents.push(e); } const merged = [...ents, ...named.filter(c => !ents.some(e => e.gid === (c.gid || c.id)))]; if (ents.length) { Log.info('Match:', who, '—', ents.length, 'MB artists link that Discogs URL; pick one'); return { entity: ents[0], source: 'discogs', confidence: 'low', candidates: merged }; } } // couldn't resolve via the URL → fall back to name search. 0 hits = the // Discogs URL the release credits isn't linked to any MB artist (e.g. the // mismatch case); null = the lookup was rate-limited. if (hits && hits.length === 0) Log.debug('Match:', who, '— Discogs URL not linked to any MB artist → name search'); else if (hits == null) Log.debug('Match:', who, '— Discogs URL lookup unavailable (rate-limited) → name search'); } // #651: the artist's page on the platform First Contact imported from — as certain as Discogs if (platUrl) { const p = platformOf(platUrl) || { abbr: 'link', name: 'platform' }; const hits = await resolveByPlatformUrl(platUrl); why.plat = { url: platUrl, abbr: p.abbr, name: p.name, n: hits ? hits.length : null }; if (hits && hits.length === 1) { const e = await fetchEntity(hits[0].gid); if (e && e.gid) { Log.info('Match:', who, '→', e.name, '— via', p.name, 'link', platUrl); return { entity: e, source: 'plat', confidence: 'high', candidates: [e] }; } } else if (hits && hits.length > 1) { const named = await searchArtist(creditedAs); const ents = []; for (const h of hits) { const e = await fetchEntity(h.gid); if (e && e.gid) ents.push(e); } const merged = [...ents, ...named.filter(c => !ents.some(e => e.gid === (c.gid || c.id)))]; if (ents.length) { Log.info('Match:', who, '—', ents.length, 'MB artists link', p.name, platUrl + '; pick one'); return { entity: ents[0], source: 'plat', confidence: 'low', candidates: merged }; } } if (hits && hits.length === 0) Log.debug('Match:', who, '—', p.name, 'link', platUrl, 'not in MusicBrainz → the next stages'); else if (hits == null) Log.debug('Match:', who, '—', p.name, 'link lookup unavailable → the next stages'); } let candidates = await searchArtist(creditedAs); let entity = null, source = 'search', confidence = 'low'; if (sib && sib.gid) { // the sibling release names the EXACT artist (gid) — use it. Prefer a search hit (richer data), but // if the gid isn't in the results (ambiguous/duplicate name like "Eva", or a case-only difference // vs the recording artist), resolve the gid directly so the RG match never gets lost. let hit = candidates.find(c => c.gid === sib.gid) || (await fetchEntity(sib.gid)); if (hit && hit.gid) { entity = hit; source = 'rg'; confidence = 'high'; } } why.rg = sib && sib.gid ? { src: sib.src || null, creditedAs: sib.creditedAs || '', found: !!entity } : null; // #626: the artist at this track's position on other editions. One artist → confident; // several (the editions disagree) → none is committed, they head the picker instead. const posRes = !entity && typeof pos === 'function' ? await pos() : null; if (!entity && typeof pos === 'function') why.pos = posRes ? { of: posRes.of, artists: posRes.artists, editions: posRes.editions || [] } : { of: 0, artists: [], editions: [] }; if (posRes && posRes.artists.length) { const ents = []; for (const a of posRes.artists) { const e = candidates.find(c => (c.gid || c.id) === a.gid) || (await fetchEntity(a.gid)); if (e && e.gid) ents.push({ entity: e, votes: a.votes }); } if (ents.length) posInfo = { of: posRes.of, artists: ents, editions: posRes.editions || [] }; if (ents.length === 1) { const e = ents[0].entity; Log.info('Match:', who, '→', e.name, `— via the same position on ${ents[0].votes} of ${posRes.of} other edition${posRes.of === 1 ? '' : 's'}`); return { entity: e, source: 'pos', confidence: 'high', candidates: [e, ...candidates.filter(c => (c.gid || c.id) !== e.gid)], pos: posInfo }; } if (ents.length > 1) { Log.info('Match:', who, '— other editions credit', ents.length, 'different matching artists at this position (' + ents.map(x => x.entity.name + ' ×' + x.votes).join(', ') + '); pick one'); candidates = [...ents.map(x => x.entity), ...candidates.filter(c => !ents.some(x => x.entity.gid === (c.gid || c.id)))]; } } if (!entity) { let top = candidates[0] || null; // #445: UNIFIED exact-identity match. An alias is just an alternative name, so name and // alias resolve together with ONE unambiguity check — confident (auto-committed) only // when EXACTLY ONE MB artist carries the credited string as its name OR an alias. This // is alias-AWARE (mirroring MB's own duplicate/dedup check): a name that is ALSO another // artist's alias is now ambiguous → a candidate, not a confident auto-link (that was the // old alias-BLIND name check's error surface). Only the label differs — 'via name (exact)' // vs 'via exact alias'. It also resolves an exact name the fast /ws/js search under-ranked // below a look-alike (e.g. "Tee Vee" below "Tee-vee", #445) and an alias-only credit // (e.g. "Don Abi" → the artist named "Abiodun", #442), neither of which /ws/js can. const idHit = await resolveByExactAlias(creditedAs); // {entity, via} — unique name-or-alias, else null (ambiguous OR none) why.ident = aliasSeen(creditedAs) || { status: 'unknown' }; if (idHit && idHit.entity && idHit.entity.gid) { const e = idHit.entity, rest = candidates.filter(c => (c.gid || c.id) !== e.gid); Log.info('Match:', who, '→', e.name, idHit.via === 'alias' ? '— via exact alias' : '— via name (exact)'); return { entity: e, source: idHit.via === 'alias' ? 'alias' : 'search', confidence: 'high', candidates: [e, ...rest], pos: posInfo }; } // #437: no unique exact identity → try credit co-occurrence against the release's known // artists (the common-name case). A clear winner is confident; a tie seeds the picker. if (contextGids && contextGids.length) { const cred = await resolveByCredit(creditedAs, contextGids); why.cred = { counts: cred.counts || [], ctx: cred.ctx || 0 }; if (cred.entity) { Log.info('Match:', who, '→', cred.entity.name, '— via existing artist credits'); return { entity: cred.entity, source: 'cred', confidence: 'high', candidates: [cred.entity, ...candidates.filter(c => (c.gid || c.id) !== cred.entity.gid)], pos: posInfo }; } if (cred.candidates.length) { candidates = [...cred.candidates, ...candidates.filter(c => !cred.candidates.some(e => e.gid === (c.gid || c.id)))]; top = candidates[0]; } } if (!top) return { entity: null, source: 'none', confidence: 'none', candidates: [], pos: posInfo }; entity = top; confidence = 'low'; // no unique exact identity and no co-occurrence winner → user picks } return { entity, source, confidence, candidates: [entity, ...candidates.filter(c => c.gid !== entity.gid)], pos: posInfo }; } async function buildModel(onProgress) { const tl = readTracklist(); const siblings = await loadSiblingMap(); const dmap = await loadDiscogsMap(); const tracks = []; const todo = tl.filter(t => t.names.some(n => !n.artistGid)); let done = 0; for (let ti = 0; ti < tl.length; ti++) { const t = tl[ti]; const sib = siblings.get(fold(t.title)) || null; const durls = discogsUrlsForTrack(dmap, t.title, ti, tl.length).urls; // title, else by position (#283) const slots = []; for (let i = 0; i < t.names.length; i++) { const n = t.names[i]; if (n.artistGid) { slots.push({ creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: 'set', entity: null, gid: n.artistGid, name: n.artistName, candidates: [], committed: true }); } else { const ctxGids = [...new Set(slots.map(s => s.gid).filter(Boolean).concat(releaseArtistGids()))].filter(coCreditCtx).slice(0, 6); // #437 co-artists so far + release artist(s), never special-purpose (#618) const dUrl = (durls && durls[i]) || discogsFeatUrlFor(dmap, t.title, ti, tl.length, n.creditedAs); // #442 fall back to the Discogs "Featuring" credit for a feat slot const m = await matchSlot(n.creditedAs, sib && pickSibArtist(sib, n.creditedAs, i), dUrl, ctxGids, undefined, fcCreditUrl((fcTrackFor(t) || {}).credit, i, n.creditedAs, t.names.length)); const status = slotStatusOf(m); const slot = { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status, entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, committed: false, _pos: m.pos || null, _why: m.why || null }; await tagDiscogsAddable(slot, dUrl); // #227 slots.push(slot); } } const te = { mi: t.mi, ti: t.ti, number: t.number, title: t.title, length: t.length, slots }; te.slots.forEach(s => { s._entry = te; }); te.guessTitle = guessTitleStr(te); tracks.push(te); if (t.names.some(n => !n.artistGid)) { done++; if (onProgress) onProgress(done, todo.length); } } return { tracks }; } /* ── live commit / reset / structural ops (no apply phase — every change writes through) ── */ // write a whole track's artist credit from its slots: committed slots use the picked entity, // uncommitted ones stay as unresolved credited text. // #483: guarded — #472 started watching track.artistCredit individually, so // every ordinary commit (matching, auto-commit, a picked artist) tripped the // "external change" watcher too, without this. function commitTrack(entry) { if (entry && entry.release) return riCommit(entry); // #652: the release artist cell const track = koTrack(entry.mi, entry.ti), live = liveNames(track); // #366 map each already-set slot to the live full entity by GID, NOT by position. Positional lookup // (live[i]) broke after a slot was removed/reordered: every later slot picked up the previous artist's // entity — resurrecting a just-removed artist under the next slot's credited-as and dropping the last. const liveByGid = new Map(); live.forEach(n => { const a = u(n.artist); const g = a && u(a.gid); if (g && !liveByGid.has(g)) liveByGid.set(g, a); }); _selfEdit = true; try { track.artistCredit({ names: entry.slots.map(s => { if (s.status === 'set') { const a = (s.gid && liveByGid.get(s.gid)) || s.entity || { name: s.name || s.creditedAs }; return { artist: a, name: s.creditedAs, joinPhrase: s.joinPhrase }; } if (s.committed && s.entity) return { artist: s.entity, name: s.creditedAs, joinPhrase: s.joinPhrase }; return { artist: { name: s.creditedAs }, name: s.creditedAs, joinPhrase: s.joinPhrase }; }) }); } finally { _selfEdit = false; } } // on load, immediately write the confident matches (RG/HIGH) — that's the "no apply phase" behaviour // #580: never commit over a slot the user has focused — that is the write that // used to replace a half-typed name with the matched artist. const autoCommittable = s => !s._editing && (s.status === 'rg' || s.status === 'high' || s.status === 'disc' || s.status === 'plat' || s.status === 'cred' || s.status === 'alias' || s.status === 'pos'); function autoCommitTrack(t) { let any = false; t.slots.forEach(s => { if (autoCommittable(s)) { s.committed = true; any = true; } }); if (any) commitTrack(t); } // build the table model WITHOUT matching (instant) — unresolved slots are flagged _pending function buildShell() { snapshotMissing(); // capture page-load state for any lazily-loaded medium before matching touches it // a rebuild re-reads the live model, where every linked artist looks identical — so without this we'd // collapse all match badges (rg / name / user) back to "set". Carry the match source forward by gid. // #626: per SLOT first — one artist is often matched differently on different tracks ("pos" on // the misspelled one, "rg" on the exact ones), and a gid-only key let the last one relabel them // all. The gid alone stays the fallback, for a slot that moved (track reordered / removed). const prevStatus = new Map(), slotKey = (mi, ti, j, gid) => mi + '.' + ti + '.' + j + '.' + gid; if (MODEL && MODEL.tracks) MODEL.tracks.forEach(t => t.slots.forEach((s, j) => { if (s.gid && s.committed && s.status && s.status !== 'set') { const v = { status: s.status, entity: s.entity, candidates: s.candidates, pos: s._pos || null, why: s._why || null, at: s._at || null }; prevStatus.set(slotKey(t.mi, t.ti, j, s.gid), v); prevStatus.set(s.gid, v); } })); const tracks = readTracklist().map(t => { const slots = t.names.map((n, j) => { if (!n.artistGid) return { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false, _pending: true }; const carry = prevStatus.get(slotKey(t.mi, t.ti, j, n.artistGid)) || prevStatus.get(n.artistGid); // preserve rg / name / user across the rebuild; genuine page-load links stay "set" return { creditedAs: n.creditedAs, joinPhrase: n.joinPhrase, status: carry ? carry.status : 'set', entity: carry ? carry.entity : null, gid: n.artistGid, name: n.artistName, candidates: carry ? (carry.candidates || []) : [], _pos: carry ? carry.pos : null, _why: carry ? carry.why : null, _at: carry ? carry.at : null, committed: true }; }); const te = { mi: t.mi, ti: t.ti, number: t.number, title: t.title, length: t.length, slots }; te.slots.forEach(s => { s._entry = te; }); te.guessTitle = guessTitleStr(te); return te; }); // #638: say what a rebuild kept, so a badge that went missing can be traced from the log. // A match badge (rg, pos, disc, alias, cred, name, a pick) is carried forward above; one that // turns into plain "set" for the same artist in the same slot was lost, and that is a bug worth a // report from the wild: a toast, once per page, offers the log. if (MODEL && MODEL.tracks) { const prev = MODEL.tracks.flatMap(t => t.slots.map((s, j) => (t.ti + 1) + '.' + (j + 1) + '=' + (s.status || '?') + (s.committed ? '' : '(uncommitted)') + (s._pending ? '(pending)' : ''))); const now = tracks.flatMap(t => t.slots.map((s, j) => (t.ti + 1) + '.' + (j + 1) + '=' + s.status)); Log.debug('table rebuilt: before ' + prev.join(' ') + ' → after ' + now.join(' ')); const was = new Map(); MODEL.tracks.forEach(t => t.slots.forEach((s, j) => { if (s.gid && s.status && !['set', 'none', 'low'].includes(s.status)) was.set(t.mi + '.' + t.ti + '.' + j, s); })); const lost = []; tracks.forEach(t => t.slots.forEach((s, j) => { const p = was.get(t.mi + '.' + t.ti + '.' + j); if (p && s.status === 'set' && s.gid === p.gid) lost.push('track ' + (t.ti + 1) + ' "' + s.creditedAs + '" lost ' + p.status.toUpperCase() + (p.committed ? '' : ' (it was uncommitted)') + (p._pending ? ' (it was pending)' : '')); })); if (lost.length) { Log.warn('#638 a table rebuild lost ' + lost.length + ' match badge(s): ' + lost.join('; ')); if (!_badgeLossToasted) { _badgeLossToasted = true; mbuToast('⚠ Apollo lost ' + lost.length + ' match badge' + (lost.length === 1 ? '' : 's') + ' (#638). The links are fine — please copy the log to the issue.', { action: { label: 'Copy log', onClick: b => Log.copy(b) } }); } } } return { tracks }; } let _badgeLossToasted = false; // #638: the toast once per page; every loss is still logged // match the _pending slots, updating the table row-by-row as results come in async function matchModel(onProgress) { const isEditing = isEditingNow; // don't rebuild rows (and orphan the search popup) while the user is in a field // #577: a fresh pass starts un-stopped — but only if the OTHER pass is not // mid-stop. #575 round 4: the flag is shared, and whichever pass exited // first used to clear it, so a Stop pressed on the Recordings pass was // wiped by the tracklist pass finishing and the recordings loop carried // straight on. That is the "Recordings Match is unresponsive" report. if (!_autoMatching) _matchStop = false; setMatching(true); // hoisted out of the try so the finally can report how far it got (#577) let stopped = false, done = 0, planned = 0; try { const siblings = await loadSiblingMap(); const dmap = await loadDiscogsMap(); const total = MODEL.tracks.length; // ti is the track's position in the release, which discogsUrlsForTrack // needs (#283 falls back to matching by position), so carry it along // rather than re-deriving it from a filtered list. const todo = MODEL.tracks.map((t, ti) => ({ t, ti })).filter(x => x.t.slots.some(s => s._pending)); planned = todo.length; await prewarmPlatformLinks(todo.map(x => x.t)); // #651: First Contact's platform links, a few batched lookups up front /* #575 (majkinetor, "Matching takes too long"): this loop used to await each track before starting the next. The /ws/2 pacer allows four reads in flight — measured on a 14-track fixture against production, the pass never got past TWO, and 17 requests took 124 seconds: about 7 seconds per request, all of it spent waiting on MusicBrainz rather than on our own rate limit. A sequential caller turns a slow server day into a multi- minute wait, which is exactly what his 4-minute log shows. So: run the tracks through a small pool instead. This adds no delay and does not raise the REQUEST RATE — the pacer still releases one start per second, which is the published guidance — it only stops the lanes the pacer already allows from sitting idle. Slots within a track stay sequential, so a track's artists still commit together. */ const MATCH_LANES = 4; // == WS_MAX_INFLIGHT; the pacer is what bounds the rate let nextIdx = 0; const lane = async () => { for (;;) { if (_matchStop) { stopped = true; return; } // #577 const job = todo[nextIdx++]; if (!job) return; const { t, ti } = job; const sib = siblings.get(fold(t.title)) || null; const durls = discogsUrlsForTrack(dmap, t.title, ti, total).urls; // title, else by position (#283) for (let i = 0; i < t.slots.length; i++) { if (_matchStop) { stopped = true; break; } // #577: a track can hold several artists, each its own round trip const s = t.slots[i]; if (!s._pending) continue; if (s._editing) { Log.debug('match: slot skipped — the user is editing it (#580)'); continue; } // stays _pending, so leaving the field lets a later pass have it const dUrl = (durls && durls[i]) || discogsFeatUrlFor(dmap, t.title, ti, total, s.creditedAs); // #442 fall back to the Discogs "Featuring" credit for a feat slot const m = await matchSlot(s.creditedAs, sib && pickSibArtist(sib, s.creditedAs, i), dUrl, slotContextGids(t, i), () => positionArtists(t, s.creditedAs), fcPlatformUrl(t, i, s.creditedAs)); // #437, #626, #651 Object.assign(s, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, _pos: m.pos || null, _why: m.why || null }); delete s._pending; await tagDiscogsAddable(s, dUrl); // #227 } autoCommitTrack(t); if (!isEditing()) rerender(); done++; /* #575: the counter used to be this loop's own index, so it read "5/14" with one row left on screen — the Discogs pass resolves slots too, and those tracks never showed up in it. Count what is actually still queued instead, so the number matches the table. */ if (onProgress) { const left = todo.filter(x => x.t.slots.some(s => s._pending)).length; onProgress(todo.length - left, todo.length); } } }; await Promise.all(Array.from({ length: Math.min(MATCH_LANES, todo.length) }, lane)); if (!isEditing()) rerender(); // #577: say so, and say what survived — a silent stop looks like a crash. // Announced before the finally's refreshStatus, which would overwrite it, // so the message is set after instead. if (stopped) Log.info('matching stopped after ' + done + ' of ' + planned + ' track' + (planned === 1 ? '' : 's') + ' — what matched is kept'); // #575: the artist pass had no end marker, so the recordings pass's summary // looked like the whole thing finishing while artists were still resolving. else if (planned) Log.info('tracklist match: ' + planned + ' track' + (planned === 1 ? '' : 's') + ' matched — Discogs link checks and alias enrichment continue in the background'); } finally { setMatching(false); refreshStatus(); // set the final per-medium badges once the pass is done if (stopped) updateStatus('matching stopped — ' + done + '/' + planned + ' done, the rest are still unmatched'); if (!_autoMatching) _matchStop = false; // #575: leave it set while the recordings pass is still winding down } // #227: tag/resolve Discogs links AFTER the match finally (so its summary // message isn't overwritten by refreshStatus) — covers 'set' artists too. // Not awaited: tagDiscogsForAll may now poll a few seconds for the release // Discogs link to load, and we don't want that holding up the caller's // alias-enrichment. It owns the badge and updates it when it settles. tagDiscogsForAll(); tagPlatformForAll(); // #651: 🔗 / ⚠ for First Contact's platform links, 'set' artists too } // (re-)match every still-unmatched slot — the "Match" button / used when auto-match is off async function matchAll() { if (!MODEL) return; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.status !== 'set' && !s.committed) s._pending = true; })); await matchModel((d, n) => updateStatus(`matching ${d}/${n}…`)); } // has this track changed from its page-load state (title/#/length or any artist credit)? function trackChanged(entry) { const orig = ORIGINALS.get(origKey(entry.mi, entry.ti)); if (!orig) return false; const t = koTrack(entry.mi, entry.ti); if ((u(t.name) || '') !== orig.title || String(u(t.number)) !== String(orig.number) || (u(t.formattedLength) || '') !== orig.length) return true; if (entry.slots.length !== orig.names.length) return true; for (let i = 0; i < entry.slots.length; i++) { const s = entry.slots[i], o = orig.names[i]; const curGid = (s.committed && s.gid) ? s.gid : ''; const origGid = (o.artist && u(o.artist.gid)) || ''; if (curGid !== origGid || (s.creditedAs || '') !== (o.creditedAs || '') || (s.joinPhrase || '') !== (o.joinPhrase || '')) return true; } return false; } function resetTrack(entry, restoreDataFlag) { const orig = ORIGINALS.get(origKey(entry.mi, entry.ti)); if (!orig) return; const t = koTrack(entry.mi, entry.ti); _selfEdit = true; try { t.artistCredit({ names: orig.names.map(o => ({ artist: o.artist, name: o.creditedAs, joinPhrase: o.joinPhrase })) }); try { t.name(orig.title); } catch (e) {} try { t.number(orig.number); } catch (e) {} try { if (typeof t.formattedLength === 'function') t.formattedLength(orig.length); } catch (e) {} /* #586 — the data-track flag is restored only on a whole-tracklist revert. A data section has to stay a TRAILING, contiguous block, and putting one row back on its own can break that (revert the middle of three and the section is split), so a single ↺ leaves the boundary to ⤓/⤒. */ if (restoreDataFlag && typeof t.isDataTrack === 'function' && !!t.isDataTrack() !== !!orig.isDataTrack) t.isDataTrack(!!orig.isDataTrack); } finally { _selfEdit = false; } if (!restoreDataFlag && typeof t.isDataTrack === 'function' && !!t.isDataTrack() !== !!orig.isDataTrack) Log.info('reset track ' + entry.number + ': it is still ' + (t.isDataTrack() ? 'a data track' : 'an audio track') + ' — use ⤒/⤓ to move the data-track boundary, which has to stay one trailing block'); Log.info('reset track', entry.number, 'to original (all cells)'); } let _selfEdit = false; // true while WE mutate the tracklist, so the change-watcher ignores it // a medium with a CD disc ID (TOC) has a fixed track count — native MB locks adding/removing/ // reordering its tracks. Mirror that so Apollo never silently corrupts the disc-ID association. #125 function mediumLocked(mi) { try { const m = mediums()[mi]; return !!(m && typeof m.hasToc === 'function' && m.hasToc()); } catch (e) { return false; } } // #329/#330: classify a track. Pregap = position 0; data track = isDataTrack(); else audio. function trackKind(entry) { try { const ko = koTrack(entry.mi, entry.ti); if (typeof ko.position === 'function' && ko.position() === 0) return 'pregap'; if (typeof ko.isDataTrack === 'function' && !!ko.isDataTrack()) return 'data'; } catch (e) {} return 'audio'; } /* #584 follow-up (majkinetor: "also show in recording table") — the video marker existed only on the Recordings tab; the Tracklist showed nothing, so the very tracks you go looking for (his release marks its last three as video, and #586 is about pushing exactly those into the data section) were invisible in the table where you do the work. Read live off the linked recording, which does carry .video in the release editor's own model (verified in test/probe-584-video-flag.mjs — 3 of 13 true, none undefined). */ function trackRecIsVideo(entry) { try { const t = koTrack(entry.mi, entry.ti); if (typeof t.hasNewRecording === 'function' && u(t.hasNewRecording)) return false; // #655: unset → a new recording, not a video const rec = u(t.recording); return !!(rec && u(rec.video)); } catch (e) { return false; } } /* #655 (chaban-mb): "When unsetting a wrongly matched video recording the video indicator is not removed from the tracklist." The Tracklist is built once and refreshed once per visit, so a recording linked or unset on the Recordings tab (or by MB itself) left its rows showing the old marker. Bring each row's marker in line with the live recording, in place: a rebuild would cost the match badges nothing, but it is far more work than one icon. */ function syncTlVideoMarks() { let added = 0, removed = 0; document.querySelectorAll('.tc-mirror tr[data-tk]').forEach(tr => { const want = trackRecIsVideo({ mi: +tr.dataset.mi, ti: +tr.dataset.ti }); const mk = tr.querySelector('.t-wrap > .tc-rec-video'); if (want && !mk) { const wrap = tr.querySelector('.t-wrap'), before = wrap && wrap.querySelector(':scope > .t-title-disp, :scope > .t-title'); if (!before) return; before.insertAdjacentHTML('beforebegin', VIDEO_MARK_PRE); added++; } else if (!want && mk) { mk.remove(); removed++; } }); if (added || removed) Log.debug('tracklist video markers: ' + added + ' added, ' + removed + ' removed (a recording link changed)'); } // #329: on a disc-ID (TOC) medium the audio tracks' lengths are fixed by the TOC, so // native MB makes them read-only. Pregap + data tracks aren't covered by the audio TOC // and stay editable — mirror that for Apollo's length cells. function trackLenLocked(entry) { return mediumLocked(entry.mi) && trackKind(entry) === 'audio'; } function removeTrack(entry) { if (mediumLocked(entry.mi)) { Log.info('medium', entry.mi + 1, 'disc-ID locked — remove blocked'); return; } _selfEdit = true; try { getEditor().removeTrack(koTrack(entry.mi, entry.ti)); } finally { _selfEdit = false; } Log.info('removed track', entry.number); } function moveTrack(entry, dir) { if (mediumLocked(entry.mi)) { Log.info('medium', entry.mi + 1, 'disc-ID locked — move blocked'); return; } const ed = getEditor(); const t = koTrack(entry.mi, entry.ti); _selfEdit = true; try { (dir < 0 ? ed.moveTrackUp : ed.moveTrackDown).call(ed, t); } finally { _selfEdit = false; } } // move a track to a target index WITHIN its medium by stepping MB's own up/down ops — never touches the // model array directly, so the editor can't diverge (drag-to-reorder rides on this) function moveTrackToIndex(entry, destTi) { if (mediumLocked(entry.mi)) { Log.info('medium', entry.mi + 1, 'disc-ID locked — reorder blocked'); return false; } const ed = getEditor(), t = koTrack(entry.mi, entry.ti); const n = (u(mediums()[entry.mi].tracks) || []).length; destTi = Math.max(0, Math.min(n - 1, destTi)); let cur = entry.ti; if (cur === destTi) return false; _selfEdit = true; try { while (cur > destTi) { ed.moveTrackUp.call(ed, t); cur--; } while (cur < destTi) { ed.moveTrackDown.call(ed, t); cur++; } } catch (e) { Log.warn('move-to-index failed', e.message); } finally { _selfEdit = false; } Log.info('moved track', entry.number, 'from', entry.ti, '→', destTi, 'in medium', entry.mi + 1); return true; } // add N blank tracks to a medium by driving MB's own "Add tracks" control (the green +) function addTracks(mi, n) { if (mediumLocked(mi)) { Log.info('medium', mi + 1, 'disc-ID locked — add blocked'); return; } const btns = [...document.querySelectorAll('button[data-click="addNewTracks"]')]; const inputs = [...document.querySelectorAll('input[data-bind*="addTrackCount"]')]; const btn = btns[mi] || btns[btns.length - 1]; const inp = inputs[mi] || inputs[inputs.length - 1]; if (!btn) { Log.warn('no native add-tracks button found'); return; } const med = mediums()[mi]; const before = med ? (u(med.tracks) || []).length : 0; _selfEdit = true; try { if (inp) { inp.value = String(n); inp.dispatchEvent(new Event('input', { bubbles: true })); inp.dispatchEvent(new Event('change', { bubbles: true })); } btn.click(); } finally { _selfEdit = false; } // MB seeds each new track with the *previous* track's artist credit — clear it so new tracks are blank. // #483: this ran AFTER the _selfEdit guard above already reset to false — its own guard now. if (med) { const tks = u(med.tracks) || []; _selfEdit = true; try { for (let i = before; i < tks.length; i++) try { tks[i].artistCredit({ names: [{ artist: null, name: '', joinPhrase: '' }] }); } catch (e) {} } finally { _selfEdit = false; } } Log.info('added', n, 'track(s) to medium', mi + 1); // refresh immediately (blank tracks need no matching) instead of the 400ms watcher + match pass MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); } // #483: was unguarded — since #472 started watching track.name individually // (to catch bookmarklets editing tracks in place), every normal title edit // through Apollo's own mirror input ALSO tripped that watcher, scheduling a // full mirror rebuild ~400ms later that yanked focus wherever the user had // since moved it (tab/click to the next field). _selfEdit tells the watcher // this write is ours, same guard already used for remove/move/reset/etc. function setTitle(entry, v) { _selfEdit = true; try { koTrack(entry.mi, entry.ti).name(v); } finally { _selfEdit = false; } } function setNumber(entry, v) { try { koTrack(entry.mi, entry.ti).number(v); } catch (e) { Log.warn('set number failed', v, e.message); } } function setLength(entry, v) { const t = koTrack(entry.mi, entry.ti); try { if (typeof t.formattedLength === 'function') t.formattedLength(v); else { const ed = getEditor(); const ms = ed.utils && ed.utils.unformatTrackLength ? ed.utils.unformatTrackLength(v) : null; if (ms != null && !isNaN(ms)) t.length(ms); } } catch (e) { Log.warn('set length failed', v, e.message); } } // MB guess case: preview into track.previewName (no mutation) to detect the diff; click-type to apply function guessTitleStr(entry) { const ed = getEditor(), t = koTrack(entry.mi, entry.ti); try { ed.guessCaseTrackName(t, { type: 'mouseenter', buttons: 0 }); const g = u(t.previewName); ed.guessCaseTrackName(t, { type: 'mouseleave' }); return (g == null) ? u(t.name) : g; } catch (e) { return u(t.name); } } function applyGuessTitle(entry) { try { getEditor().guessCaseTrackName(koTrack(entry.mi, entry.ti), { type: 'click' }); } catch (e) { Log.warn('guess case failed', e.message); } } // Lazily get the absolute overlay that hosts a title cell's action buttons // (Aa / ⋔), so they don't reserve flex width and shrink the input. #153 function tActions(wrap) { let a = wrap.querySelector('.t-actions'); if (!a) { a = document.createElement('span'); a.className = 't-actions'; wrap.appendChild(a); } return a; } /* ── create artist ── */ // the sort-name guess is shared with Credit Hoarder (mbmGuessSortName, the ST-MATCH block) const guessSortName = name => mbmGuessSortName(name); // open MB's create-artist form; when it's saved, the new artist page posts the MBID back over the // channel (handshake via sessionStorage token) and closes itself, and we drop it into the slot. function createArtist(name, slot, discogsUrl, background) { let url = `${ORIGIN}/artist/create?edit-artist.name=${encodeURIComponent(name)}&edit-artist.sort_name=${encodeURIComponent(guessSortName(name))}`; // #227: seed the Discogs link so the new artist is born already linked if (discogsUrl) { url += `&edit-artist.url.0.text=${encodeURIComponent(discogsUrl)}&edit-artist.url.0.link_type_id=${DISCOGS_ARTIST_LINK_TYPE}`; _discogsResolveCache.delete(discogsUrl); } // #651: and First Contact's platform link, unless another artist already has it const platUrl = slot && slot._platUrl && !slot._platConflict && slot._platUrl !== discogsUrl ? slot._platUrl : null; if (platUrl) { const k = discogsUrl ? 1 : 0, pt = (platformOf(platUrl) || {}).artistLinkType; url += `&edit-artist.url.${k}.text=${encodeURIComponent(platUrl)}` + (pt ? `&edit-artist.url.${k}.link_type_id=${pt}` : ''); forgetPlatformUrl(platUrl); Log.info('create-artist: seeding the', (platformOf(platUrl) || { name: 'platform' }).name, 'link', platUrl); } url += `&edit-artist.edit_note=${encodeURIComponent(entityActionNote('Created this artist'))}`; // proper attribution on the created artist const token = (slot && ART_CHANNEL) ? ('tc-' + Date.now() + '-' + (++_createSeq)) : null; // #273: right-click → create SILENTLY in a background tab and auto-submit. // GM_openInTab gives no tab handle to write sessionStorage on, so we carry the // postback token in the URL hash; the create-page bootstrap stores it as the // pending marker, waits for the seeded form to render, and clicks "Enter edit". // The saved /artist/ page posts the MBID back (existing handler) and we // close the GM tab here on that postback. if (background && token && typeof GM_openInTab === 'function') { const bgTab = GM_openInTab(`${url}#tc-autocommit=${encodeURIComponent(token)}`, { active: false, insert: true }); _pendingCreates.set(token, { slot, bgTab }); Log.info('create-artist (background) for', JSON.stringify(name), '— will auto-insert on save'); return; } const tab = W.open(url, '_blank'); // NOT noopener — we set a token on the new tab's sessionStorage if (tab && token) { _pendingCreates.set(token, { slot }); const trySet = () => { try { tab.sessionStorage.setItem(PENDING_KEY, token); } catch (e) { setTimeout(trySet, 50); } }; trySet(); Log.info('create-artist for', JSON.stringify(name), '— will auto-insert on save'); } else { Log.info('open MB create-artist for', JSON.stringify(name)); } } // runs on a freshly-saved /artist/ page opened by createArtist: post the MBID back, then close function handleArtistPageCallback() { const m = location.pathname.match(new RegExp('^/artist/(' + MBID_RE.source + ')', 'i')); if (!m) return false; let token = null; try { token = sessionStorage.getItem(PENDING_KEY); } catch (e) {} if (!token) return false; try { sessionStorage.removeItem(PENDING_KEY); } catch (e) {} const gid = m[1].toLowerCase(); fetchEntity(gid).then(ent => { if (ART_CHANNEL) ART_CHANNEL.postMessage({ type: 'tc-artist-created', token, gid, id: ent ? ent.id : null, name: ent ? ent.name : '' }); setTimeout(() => { try { W.close(); } catch (e) {} }, 80); }); return true; } // a tab opened just to ADD a Discogs link to an existing artist closes itself // once the edit submits and redirects to the clean /artist/ page. Guarded // to the clean entity page ($-anchored, so NOT /artist//edit) — it never // closes before the user submits. The opener re-verifies on focus return. function handleEditLinkClose() { const m = location.pathname.match(new RegExp('^/artist/(' + MBID_RE.source + ')$', 'i')); if (!m) return false; let mark = null; try { mark = sessionStorage.getItem(CLOSE_KEY); } catch (e) {} if (!mark) return false; try { sessionStorage.removeItem(CLOSE_KEY); } catch (e) {} // #273: tell the opener the link edit committed so a BACKGROUND add-link (which // never regains focus) can re-tag the slots + close this GM tab. A foreground // add-link has no listener for it — harmless there (it uses focus-return). if (ART_CHANNEL) { try { ART_CHANNEL.postMessage({ type: 'tc-edit-committed', gid: m[1].toLowerCase() }); } catch (e) {} } setTimeout(() => { try { W.close(); } catch (e) {} }, 80); return true; } // #273: on the MB create/edit form opened in a BACKGROUND tab (hash flag), store // the right marker (so the saved entity page posts back / closes), wait for the // seeded change to RENDER — the "Enter edit" button shows before MB's React // external-links editor applies the seeded URL, so an early click would submit // an empty edit — then click it. function handleAutoCommit() { const onCreate = /\/(artist|label|place)\/create\b/i.test(location.pathname); const onEdit = new RegExp('/(artist|label|place)/' + MBID_RE.source + '/edit\\b', 'i').test(location.pathname); if (!onCreate && !onEdit) return false; const hm = location.hash.match(/tc-autocommit(?:=([^&]+))?/); if (!hm) return false; if (onCreate) { let tok = ''; try { tok = decodeURIComponent(hm[1] || ''); } catch (e) { tok = hm[1] || ''; } try { sessionStorage.setItem(PENDING_KEY, tok); } catch (e) {} } else { try { sessionStorage.setItem(CLOSE_KEY, '1'); } catch (e) {} } const et = (location.pathname.match(/\/(artist|label|place)\//) || [])[1] || 'artist'; const seedUrl = new URLSearchParams(location.search).get(`edit-${et}.url.0.text`) || ''; const seedKey = seedUrl ? seedUrl.replace(/^https?:\/\//i, '').replace(/^www\./i, '').replace(/\/+$/, '').toLowerCase() : ''; let tries = 0; const submit = () => { const seedReady = !seedKey || document.body.innerHTML.toLowerCase().includes(seedKey); const btn = document.querySelector('button.submit.positive') || [...document.querySelectorAll('button[type="submit"]')].find(b => /enter edit/i.test(b.textContent || '')); if (seedReady && btn && !btn.disabled) { btn.click(); return; } if (tries++ < 100) setTimeout(submit, 200); // ~20s grace (background tabs are slower) }; if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', submit); else submit(); return true; } /* ════════════════════════ UI ════════════════════════ */ // mbRestackCorner, the #468 corner slots, is in the ST-UI block (dev/ui/ui-components.mjs). const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/apollo_editor/README.md'; const VERSION = '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale) const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; // shared attribution header (same shape as the other scripts' edit notes) const apolloAttribution = () => { const s = (typeof GM_info !== 'undefined' && GM_info.script) || {}; return (s.name || 'Apollo Editor') + ' v' + scriptVersion() + ' by ' + (s.author || 'majkinetor') + ' - ' + (s.homepageURL || s.homepage || HELP_URL); }; // edit note for an artist Apollo creates or links from a credit slot — // attribution + what was done + which release was being edited function entityActionNote(action) { const releaseUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, ''); return apolloAttribution() + '\n\n' + action + ' while editing ' + releaseUrl; } // Apollo Editor — a launching rocket in the theme purple (recreated from the requested clipart) const ICON = ''; // window // outline person / group type icons (use currentColor so they take the link colour) const PERSON_SVG = ''; const GROUP_SVG = ''; const typeSvg = c => { const t = ((c && c.typeName) || '').toLowerCase(); return (t === 'group' || t === 'orchestra' || t === 'choir') ? GROUP_SVG : PERSON_SVG; }; const JOIN_OPTIONS = [ { label: '&', value: ' & ' }, { label: ',', value: ', ' }, { label: 'feat.', value: ' feat. ' }, { label: 'ft.', value: ' ft. ' }, { label: 'featuring', value: ' featuring ' }, { label: 'and', value: ' and ' }, { label: 'vs.', value: ' vs. ' }, { label: 'x', value: ' x ' }, { label: 'with', value: ' with ' }, { label: '/', value: ' / ' }, { label: '·', value: ' · ' }, { label: 'presents', value: ' presents ' }, ]; const COLS = [{ k: 'mv', w: 32, label: '' }, { k: 'num', w: 38, label: '#' }, { k: 'title', w: 360, label: 'Title' }, { k: 'art', w: 380, label: 'Artist' }, { k: 'len', w: 52, label: 'Length' }, { k: 'badge', w: 56, label: 'Match' }, { k: 'act', w: 44, label: '' }]; const badgeText = s => s.status === 'plat' ? ((s._why && s._why.plat && s._why.plat.abbr) || 'link') : ({ rg: 'rg', disc: 'disc', pos: 'pos', cred: 'cred', alias: 'alias', high: 'name', user: 'user', set: 'set', low: 'low' })[s.status] || ''; // #654: the track-actions column is fixed at two icons wide; it is never resized const colW = (k, d) => (k !== 'act' && SETTINGS.colWidths && SETTINGS.colWidths[k]) || d; function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } // The shared design tokens (#562). Values live in dev/tokens/design-tokens.mjs and are // inlined here by dev/tokens/sync-tokens.mjs — edit them THERE, never in this block. // — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}'; // // The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs // and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here. // — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(720px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min{font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x{display:none}#mbu-logpop.min{max-height:none;width:auto}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}#mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}'; // Help link markup. Every script's help link is this, pointing at its own README. // `name` is the userscript folder, e.g. mbuHelpHref('art_station'). function mbuHelpHref(name) { return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md'; } function mbuHelpHtml(name, label) { return '' + (label || '? Help') + ''; } function mbuHelpEl(name, label) { var a = document.createElement('a'); a.className = 'mbu-help'; a.href = mbuHelpHref(name); a.target = '_blank'; a.rel = 'noopener'; a.title = 'open the README in a new tab'; a.textContent = label || '? Help'; return a; } // The config (settings) icon every script's settings button shows: the gear in text // presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650 var MBU_CFG_ICON = '\u2699\uFE0E'; // HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a // plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy // passes the string through; the markup is the script's own. Elsewhere it is the string. var _mbuTT; function mbuHtml(s) { if (_mbuTT === undefined) { _mbuTT = null; try { var tt = (typeof window !== 'undefined' && window.trustedTypes) || null; if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } }); } catch (e) { /* the page allows no new policy: plain strings, as before */ } } return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s); } // The first line of every script's log: the script, its version, and what runs it, so a // pasted log says which manager and browser it came from (#282 started it in Art Station): // Log.info(mbuStartupInfo('Fusion')); // -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win) // Inside String Theory GM_info describes the bundle, so the line names it: // -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win) function mbuStartupInfo(name) { var g = null; try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ } var s = (g && g.script) || {}, p = (g && g.platform) || {}; var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?'; var line = !name ? (host || 'Script') + ' v' + ver : (host && host !== name) ? name + ' (' + host + ' v' + ver + ')' : name + ' v' + ver; if (g) line += ' · ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : ''); if (p.browserName) line += ' · ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : ''); else { try { line += ' · ' + navigator.userAgent; } catch (e) { /* no navigator */ } } return line; } // One copy per page (#653). With String Theory and a standalone install of the same script // both on, two copies build the same element ids and fight over them: each settings window // fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the // one with the higher version, and the other stays off without a word; only the running // copy notes it in its log (majkinetor: "disable copy that has lower version without any // info (except in log of active copy)"): // if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script // The claim is a data- attribute on , which every copy sees whatever its sandbox. // It is decided at once, so no script starts late. The copy that starts first takes the // page. When it is the older one, the newer copy stays off for this page and leaves a note // in localStorage, and from the next page load the older copy finds the note and steps // aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the // page loads, so it runs again from the load after. function mbuClaimVer(v) { return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; }); } function mbuClaimCmp(a, b) { var x = mbuClaimVer(a), y = mbuClaimVer(b); for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; } return 0; } function mbuClaim(key, label) { var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {}; var name = String(info.name || label || key), ver = String(info.version || '0'); var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver; var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key; var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } }; var note = null; try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ } // A note older than this copy is spent. This copy's own note stays: it is what keeps the // older copy aside on every later load, not only the next one (#671). var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1; if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } } if (noteCmp <= 0) note = null; var held = root && root.getAttribute(attr); var off = function (why) { // tell the running copy (any sandbox hears a DOM event), or the copy that runs after // this one (it reads the attribute), and stay quiet try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ } try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ } return false; }; if (held) { var heldVer = (root.getAttribute(attr + '-ver') || '0'); if (mbuClaimCmp(ver, heldVer) > 0) { try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ } return off('newer, from the next page load'); } return off('older or the same'); } if (note) { // a newer copy said it is installed: leave the page to it, unless it never shows up var watch = function () { setTimeout(function () { if (root.getAttribute(attr)) return; try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ } }, 3000); }; if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true }); return off('older: a newer copy runs'); } if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); } var told = function (o) { log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off' + (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.'); }; document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); }); // a copy that stepped aside before this one started (it found a note): log it once the script's log is up var before = root && root.getAttribute(attr + '-off'); if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0); return true; } // Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }). // // An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable, // stays up longer (12 s unless ms says otherwise), and closes when the button is used. // // Severity is inferred from a leading warning/tick glyph when not given — Art // Station already did that and it is why its toasts reached its log with the // right level. Set mbuToast.log = function (level, message) {...} once at // startup and every toast mirrors into that script's own log; leave it unset // and the toast still shows. var _mbuToastT = null; function mbuToast(msg, opts) { opts = opts || {}; var s = String(msg); var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info'); try { if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, '')); } catch (e) { /* a broken log sink must never swallow the toast */ } var el = document.getElementById('mbu-toast'); if (!el) { el = document.createElement('div'); el.id = 'mbu-toast'; (document.body || document.documentElement).appendChild(el); } el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : ''); el.textContent = s; if (opts.action) { var b = document.createElement('button'); b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK'; b.onclick = function () { try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900); }; el.appendChild(b); } // Anchor above a click point when asked, clamped into the viewport; otherwise // fall back to the centred default by clearing the inline placement. if (opts.at) { var w = el.offsetWidth, h = el.offsetHeight; el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px'; el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px'; el.style.bottom = 'auto'; el.style.transform = 'none'; } else { el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = ''; } clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600)); return el; } // Config-window title bar. // // mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2', // icon:'' | '', log:true, logClass:'as-setup-logbtn' }) // // Returns the markup for the whole bar. 'log' adds the Log button; a script with // no log window leaves it out rather than shipping a dead control. logClass / // logId are carried through IN ADDITION to the shared class so a script's // existing click handler keeps working — adopting the component must not mean // rewiring every listener at the same time. function mbuCfgHeader(o) { o = o || {}; var esc = function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; var html = '
'; if (o.icon) html += '' + o.icon + ''; html += '' + esc(o.name) + ''; if (o.version) html += 'v' + esc(o.version) + ''; html += ''; if (o.log) { html += ''; } html += mbuHelpHtml(o.script); return html + '
'; } // Test hooks. A script puts its test hook on window only when the test harness has // marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs): // if (mbuTestHooks()) window.__fooTest = { … }; // On a user's page the hooks are never built. (#623) function mbuTestHooks() { try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; } } // Corner slots (#468). Every floating launcher (Apollo Editor, Art Station, Falcon, // Fusion, Scribe) tags its element with data-mb-corner (which screen corner: 'br', // 'bl', 'tr', 'tl') and data-mb-corner-order (lower sits closer to the corner), and // calls mbRestackCorner(corner) right after it shows, hides, creates or removes it. // That recomputes every element in the corner, whichever script owns it and // whatever order they loaded in, so two launchers never land on the same pixel. // Orders in use: Apollo and Art Station 10 (never on the same page), Falcon 20, // Fusion above Falcon. Scribe is not on the shared block and keeps a copy of this. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var els = Array.prototype.slice.call(document.querySelectorAll('[data-mb-corner="' + corner + '"]')) // offsetParent is always null for position:fixed, so it can't tell visibility here .filter(function (el) { return getComputedStyle(el).display !== 'none'; }) .sort(function (a, b) { return (Number(a.dataset.mbCornerOrder) || 0) - (Number(b.dataset.mbCornerOrder) || 0); }); var pos = 14; els.forEach(function (el) { el.style[bottom ? 'bottom' : 'top'] = pos + 'px'; el.style[right ? 'right' : 'left'] = '14px'; pos += el.getBoundingClientRect().height + 8; }); } // Activity log: the session's log lines plus the floating window that shows them // (#283's viewer, shared since X12 of #623). A script makes its log once: // // var LOG = mbuLog({ name: 'Fusion', version: VERSION, key: 'fusion.logwin' }); // LOG.info('…'); LOG.warn(…); LOG.err(…) (or .error); LOG.ok(…); LOG.debug(…) // LOG.open(); LOG.close(); LOG.reopen() // reopen: only if it was left open // LOG.markdown(); LOG.copy(btn); LOG.clear(); LOG.lines(); LOG.messages(); LOG.counts() // // o.name / o.version the Markdown summary's title (version may be a function) // o.subtitle optional function; its text follows the title (e.g. the release) // o.header the window's title (default 'Activity log') // o.key storage key for the window's open/minimised/position state // o.load / o.save that storage (default GM_getValue / GM_setValue) // o.before called before the window opens (e.g. to inject the script's CSS) // o.max lines kept (default 20000: about 4 MB; 2000 dropped a long session's start) // // A long run keeps only the last o.max lines, and the Markdown says how many went // before them; the copies this replaced grew for the whole session. An open // window appends each new line and drops the oldest row past the cap; the copies // rebuilt the whole list with innerHTML on every line, which is quadratic over a // long matching run. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, win = null; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + pad(d.getMilliseconds(), 3); }; var str = function (v) { if (typeof v === 'string') return v; if (v instanceof Error) return v.message || String(v); if (v && v.nodeType) return '<' + (v.tagName || 'node').toLowerCase() + '>'; try { return typeof v === 'object' ? JSON.stringify(v) : String(v); } catch (e) { return String(v); } }; var esc = function (s) { return String(s).replace(/[&<>"]/g, function (c) { return { '&': '&', '<': '<', '>': '>', '"': '"' }[c]; }); }; // escape, then make http(s) URLs clickable, keeping trailing punctuation out of them var linkify = function (s) { return esc(s).replace(/(https?:\/\/[^\s<]+)/g, function (m) { var t = (m.match(/[.,;:!?)\]]+$/) || [''])[0]; var url = m.slice(0, m.length - t.length); return '' + url + '' + t; }); }; var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } }; var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } }; var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } }; var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } }; var tally = function (e, d) { if (e.sev === 'warn') warn += d; else if (e.sev === 'error') error += d; }; var PRE = { info: '', ok: 'OK ', warn: 'WARN ', error: 'ERR ', debug: 'DBG ' }; var line = function (e) { return ts(e.t) + ' ' + (PRE[e.sev] || '') + e.msg; }; function add(sev, args) { var msg = Array.prototype.map.call(args, str).join(' ').replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; buf.push(e); tally(e, 1); // trim in chunks, not one shift per line if (buf.length > max + Math.ceil(max / 10)) { var gone = buf.splice(0, buf.length - max); gone.forEach(function (g) { tally(g, -1); }); dropped += gone.length; } if (win) win.append(e); } function title() { var v = typeof o.version === 'function' ? (function () { try { return o.version(); } catch (e) { return ''; } })() : o.version; var t = (o.name || 'Log') + (v ? ' v' + v : ''); try { var s = o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } function markdown() { var body = buf.length ? buf.map(line).join('\n') : '(no activity logged)'; if (dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (warn || error) ? ' (' + warn + ' warning' + (warn === 1 ? '' : 's') + ', ' + error + ' error' + (error === 1 ? '' : 's') + ')' : ''; var fence = String.fromCharCode(96, 96, 96); return '
' + title() + ' — session log' + n + '\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n
'; } function copy(btn) { var md = markdown(); var done = function (ok) { if (!btn) return; var was = btn.dataset.lbl || btn.textContent; btn.dataset.lbl = was; btn.textContent = ok ? 'Copied ✓' : 'Copy failed'; setTimeout(function () { btn.textContent = was; }, 1500); }; var fallback = function () { var ok = false; try { var ta = document.createElement('textarea'); ta.value = md; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); ta.remove(); } catch (x) { /* nothing left to try */ } done(ok); }; try { navigator.clipboard.writeText(md).then(function () { done(true); }, fallback); } catch (e) { fallback(); } } function open() { close(true); if (typeof o.before === 'function') { try { o.before(); } catch (e) { /* the window still opens */ } } remember({ open: true }); var st = state(); var pop = document.createElement('div'); pop.id = 'mbu-logpop'; pop.className = 'mbu-logpop'; pop.innerHTML = mbuHtml('
' + esc(o.header || 'Activity log') + ' ' + '' + '' + '' + '
' + '
'); document.body.appendChild(pop); if (st.left != null) { pop.style.left = st.left; pop.style.top = st.top; pop.style.right = 'auto'; pop.style.transform = 'none'; } var restore = { left: pop.style.left, top: pop.style.top, right: pop.style.right, bottom: pop.style.bottom, transform: pop.style.transform }; var list = pop.querySelector('.mbu-log-list'), badge = pop.querySelector('.mbu-log-badge'); var row = function (e) { var d = document.createElement('div'); d.className = 'mbu-log-li mbu-log-' + e.sev; d.innerHTML = mbuHtml('' + ts(e.t) + '' + linkify(e.msg) + ''); return d; }; var showBadge = function () { badge.textContent = '(' + buf.length + ')' + (warn || error ? ' · ' + warn + '⚠ ' + error + '✖' : ''); }; // the rows, once; later lines are appended one by one var frag = document.createDocumentFragment(); buf.forEach(function (e) { frag.appendChild(row(e)); }); if (buf.length) list.appendChild(frag); else list.innerHTML = mbuHtml('
No activity yet.
'); showBadge(); list.scrollTop = list.scrollHeight; // badge and scroll once per frame, however many lines arrive in it var queued = false, follow = true; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; var onKey = function (e) { if (e.key === 'Escape') close(); }; win = { el: pop, append: function (e) { var empty = list.querySelector('.mbu-log-empty'); if (empty) empty.remove(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, off: function () { document.removeEventListener('keydown', onKey); }, cleared: function () { list.innerHTML = mbuHtml('
No activity yet.
'); showBadge(); }, }; pop.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; pop.querySelector('.mbu-logpop-copy').onclick = function () { copy(pop.querySelector('.mbu-logpop-copy')); }; var minBtn = pop.querySelector('.mbu-logpop-min'); var setMin = function (m) { minBtn.textContent = m ? '▢' : '–'; minBtn.title = m ? 'Restore' : 'Minimize'; if (m) { pop.style.left = '14px'; pop.style.bottom = '14px'; pop.style.top = 'auto'; pop.style.right = 'auto'; pop.style.transform = 'none'; } // dock to the bottom else Object.assign(pop.style, restore); }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); setMin(m); remember({ min: m }); }; if (st.min) { pop.classList.add('min'); setMin(true); } pop.querySelector('.mbu-logpop-x').onclick = function () { close(); }; // floating and non-modal: dragged by its header pop.querySelector('.mbu-logpop-h').addEventListener('mousedown', function (e) { if (e.target.closest('button')) return; e.preventDefault(); var r = pop.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.transform = 'none'; var ox = e.clientX - r.left, oy = e.clientY - r.top; var mv = function (ev) { pop.style.left = Math.max(0, Math.min(window.innerWidth - pop.offsetWidth, ev.clientX - ox)) + 'px'; pop.style.top = Math.max(0, Math.min(window.innerHeight - 36, ev.clientY - oy)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); if (!pop.classList.contains('min')) { restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top }); } }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); document.addEventListener('keydown', onKey); return pop; } // empty the log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (win) win.cleared(); } // quiet: closing to reopen, so the remembered "open" stays as it is function close(quiet) { var stray = document.getElementById('mbu-logpop'); if (win) { win.off(); win.el.remove(); win = null; if (!quiet) remember({ open: false }); } if (stray) stray.remove(); // another script's window: one log window at a time } var api = { info: function () { add('info', arguments); }, warn: function () { add('warn', arguments); }, err: function () { add('error', arguments); }, error: function () { add('error', arguments); }, ok: function () { add('ok', arguments); }, debug: function () { add('debug', arguments); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1)); }, open: open, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: markdown, copy: copy, clear: clear, lines: function () { return buf.map(line); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, }; mbuLog.active = api; // the script's own log, for helpers that note things in it (mbuClaim) return api; } // Dismiss-on-outside-click, with the trailing click SWALLOWED. // // var off = mbuDismissOn(popoverEl, close); // off() to detach early // // #305: a popover torn down on mousedown removes what was under the cursor, so // the click that follows lands on whatever the page reflowed into that spot and // activates it. Tearing down on click instead just moves the problem. So: close // on outside mousedown, then eat exactly one click in the capture phase. This is // the single most repeated interaction bug in these scripts and it belongs in // one place — it is why #563 says interaction is part of the contract. // // Esc closes too, innermost first: the handler is registered in capture and stops // propagation, so a popover inside a modal does not close the modal as well. function mbuDismissOn(el, close, opts) { opts = opts || {}; var closed = false; var onDown = function (e) { if (closed || !el || el.contains(e.target)) return; if (opts.ignore && e.target.closest && e.target.closest(opts.ignore)) return; finish(); // swallow the click this mousedown will produce, once var eat = function (ev) { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', eat, true); }; document.addEventListener('click', eat, true); setTimeout(function () { document.removeEventListener('click', eat, true); }, 400); }; var onKey = function (e) { if (closed || e.key !== 'Escape') return; e.stopPropagation(); finish(); }; function finish() { if (closed) return; closed = true; document.removeEventListener('mousedown', onDown, true); document.removeEventListener('keydown', onKey, true); try { close(); } catch (err) { /* a throwing closer must not leave listeners behind */ } } document.addEventListener('mousedown', onDown, true); document.addEventListener('keydown', onKey, true); return finish; } // Collapse a toolbar to icon-only when its buttons would wrap. // // mbuFitToolbar(barEl) // call on build, and on resize // // Measured by SUMMING child widths rather than reading scrollWidth or comparing // offsetTop: a bar with flex:1 spacers never overflows its own scroll box, so // both of those report "fits" right up until it visibly wraps. Art Station // learned that the hard way (#234) and it is the only reason this is a helper // rather than one CSS rule. // // opts.gap inter-item gap in px (default 11) // opts.pad horizontal padding to leave (default 24) // opts.spacer selector for flexible spacers, which must not count (default .mbu-sp) function mbuFitToolbar(bar, opts) { if (!bar) return false; opts = opts || {}; var gap = opts.gap == null ? 11 : opts.gap; var pad = opts.pad == null ? 24 : opts.pad; var spacer = opts.spacer || '.mbu-sp'; bar.classList.remove('mbu-compact'); // measure at full labels var kids = [].slice.call(bar.children); var need = gap * Math.max(0, kids.length - 1); for (var i = 0; i < kids.length; i++) { if (kids[i].matches && kids[i].matches(spacer)) continue; need += kids[i].offsetWidth; } var compact = need > bar.clientWidth - pad; bar.classList.toggle('mbu-compact', compact); return compact; } // Publish the components on a shared namespace. Three reasons, in order: // // 1. it is the cross-userscript contract #563 is about — another script (or a // future one) gets the standard widgets without copying them, the same way // Mammoth already exposes its field-memory through a documented convention; // 2. it makes the components testable from outside, which is the only way to // assert the *behaviour* half of the contract rather than just the markup; // 3. it costs nothing when several scripts do it — the definitions are // byte-identical, so first writer wins and the rest are no-ops. // // Guarded per key, never clobbering: a script that loaded first keeps its copy, // and a page that defines an unrelated window.MBU is left alone. // Theme recognition. #564: "we don't have to conform to Stylus vars, we could // probably use them as a recognition signal to enable our own dark theme." // // That is the right way round. Reading --background/--text and hoping every // derived colour lands somewhere readable is guesswork that fails one token at a // time; knowing WHICH theme we are in lets the token set say so outright, and // lets us hand the browser the one thing CSS variables cannot express — // color-scheme, which is what actually paints a checkbox dark instead of leaving // a white (Firefox: black) box on a dark panel. // // The signal is the rendered page, not a particular userstyle's variable names: // whatever painted the body, we measure its luminance. So this works for Stylus, // for a browser extension, for MusicBrainz shipping its own dark mode one day, // and for a user who just set --background by hand. // // · an explicit --mbu-theme (light|dark) always wins — the escape hatch; // · otherwise the page background decides; // · re-checked when stylesheets arrive, because Stylus often lands after us. function mbuThemeOf(bg) { var m = /rgba?\((\d+),\s*(\d+),\s*(\d+)(?:,\s*([\d.]+))?/.exec(bg || ''); if (!m) return null; if (m[4] !== undefined && +m[4] < 0.5) return null; // transparent tells us nothing var f = function (v) { v /= 255; return v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4); }; var L = 0.2126 * f(+m[1]) + 0.7152 * f(+m[2]) + 0.0722 * f(+m[3]); return L < 0.35 ? 'dark' : 'light'; } // #569 (chaban-mb) — write only when the value actually changes. // // The DOM does not do this for you. classList.add of a token already present, // classList.toggle to the state it is already in, setAttribute with the value it // already has: each one re-sets the attribute and dispatches a mutation record. // Harmless once; these run from 2Hz heartbeats and from observers that react to // each other, and the measured idle cost on the release editor was 66 records a // second, of which 93% came from writes that changed nothing (see // dev/ui/measure-569-idle-mutations.mjs). // // Semantically these are exact no-ops: they skip a write ONLY when the value is // already the one being written, so nothing that reads the DOM afterwards can // tell the difference. That is the whole reason they are safe to sprinkle around // a 2Hz loop. function mbuCls(el, token, on) { if (!el || !el.classList) return; if (el.classList.contains(token) !== !!on) el.classList.toggle(token, !!on); } function mbuAttr(el, name, value) { if (!el) return; if (value === null || value === undefined || value === false) { if (el.hasAttribute(name)) el.removeAttribute(name); } else if (el.getAttribute(name) !== String(value)) { el.setAttribute(name, String(value)); } } // For IDL properties (disabled, title, textContent, style.display …). Reading // them is cheap; writing them is not, and textContent in particular replaces // every child node. // // ⚠ textContent is the one to think twice about: its getter concatenates the // text of ALL descendants, so on an element with child ELEMENTS the comparison // can match while the DOM shape is wrong, and the guard then skips a write that // would have flattened it. Only use it where the target holds text and nothing // else. function mbuProp(obj, prop, value) { if (!obj) return; if (obj[prop] !== value) obj[prop] = value; } // #569: the one element mbuTheme resolves --background through. Looked up by id // rather than kept in a variable, so the seven scripts of a bundle share ONE // probe instead of adding seven, and so it heals itself if anything removes it. // It lives in : a permanent stray node under , outside head and // body, is the sort of thing another script's document scan trips over. function mbuProbe() { var p = document.getElementById('mbu-theme-probe'); if (p) return p; if (!document.body) return null; p = document.createElement('span'); p.id = 'mbu-theme-probe'; p.setAttribute('aria-hidden', 'true'); p.style.cssText = 'position:absolute;left:-9999px;top:0;width:1px;height:1px;pointer-events:none;background:var(--background)'; document.body.appendChild(p); return p; } function mbuTheme() { var root = document.documentElement; try { var cs = getComputedStyle(root); var forced = (cs.getPropertyValue('--mbu-theme') || '').trim(); var t = (forced === 'dark' || forced === 'light') ? forced : (mbuThemeOf(getComputedStyle(document.body).backgroundColor) || mbuThemeOf(cs.backgroundColor) || mbuThemeOf(cs.getPropertyValue('--mbu-bg')) || 'light'); if (root.getAttribute('data-mbu-theme') !== t) root.setAttribute('data-mbu-theme', t); // Should we adopt the userstyle's OWN shades, or use our own palette? // Only if its --background actually agrees with the theme we detected. // A userstyle can paint the page dark with ordinary rules and still leave // --background at a light value for its own purposes; taking that on // trust hands us a light surface under a correct dark theme, which is // indistinguishable from the bug it looks like. Measured, not assumed. var seed = null; var raw = (cs.getPropertyValue('--background') || '').trim(); if (raw) { // Resolved through a real element, because --background may itself be // a var(), a named colour, or anything else CSS accepts. // // #569 (chaban-mb): this used to CREATE and REMOVE that element on // every call, as a direct child of . mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on , and // those class changes wake mbuTheme again — a self-feeding loop, // measured at 12 root-node mutations a second on an idle page, which // is what makes DevTools blink. // // One element, created once and left in place, breaks it: the value // is still resolved LIVE on every call (a cached reading would freeze // the theme at whatever it was before Stylus injected, which is the // bug this whole function exists to avoid) but nothing is added to or // removed from the DOM to read it. var probe = mbuProbe(); var got = null; if (probe) { got = mbuThemeOf(getComputedStyle(probe).backgroundColor); } else { // No yet — document-start. Fall back to the transient // element for these first one or two calls; the idle loop this // avoids cannot exist before the page has a body anyway. var tmp = document.createElement('span'); tmp.style.cssText = 'position:absolute;left:-9999px;width:1px;height:1px;background:var(--background)'; document.documentElement.appendChild(tmp); got = mbuThemeOf(getComputedStyle(tmp).backgroundColor); tmp.remove(); } if (got === t) seed = 'theme'; } // guarded: setAttribute dispatches a mutation record even when the value // is unchanged, and this runs several times a second if (seed) { if (root.getAttribute('data-mbu-seed') !== seed) root.setAttribute('data-mbu-seed', seed); } else if (root.hasAttribute('data-mbu-seed')) root.removeAttribute('data-mbu-seed'); return t; } catch (e) { return 'light'; } } // A document-start script runs before the document is parsed: documentElement can // still be null, and and don't exist. Observing a null root threw, the // catch below swallowed it, and nothing (the watches, the re-checks) was ever set up, // so such a script never read the theme at all (#625). It starts on the parsed page. function mbuThemeStart() { try { mbuTheme(); // Stylus and friends inject after us often enough that a one-shot read is // wrong about half the time. Watch for stylesheets ARRIVING — head childList // plus the root's own attributes — and never the whole subtree: this runs on // the release editor, where a subtree observer calling getComputedStyle is a // layout thrash on every keystroke. var _mbuThemeT = 0; var _mbuThemeSoon = function () { clearTimeout(_mbuThemeT); _mbuThemeT = setTimeout(mbuTheme, 150); }; var _mbuThemeObs = new MutationObserver(_mbuThemeSoon); _mbuThemeObs.observe(document.documentElement, { attributeFilter: ['style', 'class'] }); // ⚠ #569: characterData, not just childList. Until the idle thrash was fixed // this function ran several times a second whether or not anything had // changed — Apollo re-added a body class at 2Hz, which woke this observer, // which is how a theme change was ever noticed. That accidental polling was // LOAD-BEARING: with the thrash gone and only head-childList watched, a // userstyle that REWRITES ITSELF (Stylus editing it live, or one switching // palette) adds and removes no nodes, so nothing woke us and the theme went // stale. Caught by verify-569-theme-still-tracks.mjs, which passes on the // pre-fix build and failed on the first version of this one. if (document.head) _mbuThemeObs.observe(document.head, { childList: true, subtree: true, characterData: true }); if (document.body) _mbuThemeObs.observe(document.body, { attributeFilter: ['style', 'class'] }); // …and the case that produces no DOM mutation at all: the OS flipping to dark // under a userstyle with a prefers-color-scheme query. Nothing above can see // that, and nothing did before either — it was simply never noticed while the // page was re-checking itself several times a second. try { var _mbuMq = matchMedia('(prefers-color-scheme: dark)'); if (_mbuMq.addEventListener) _mbuMq.addEventListener('change', _mbuThemeSoon); else if (_mbuMq.addListener) _mbuMq.addListener(_mbuThemeSoon); } catch (e) {} setTimeout(mbuTheme, 400); setTimeout(mbuTheme, 2000); } catch (e) { /* no observer, no theme switching — the light defaults still apply */ } } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mbuThemeStart, { once: true }); else mbuThemeStart(); try { var _mbuNs = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); if (!_mbuNs.MBU) _mbuNs.MBU = {}; if (!_mbuNs.MBU.theme) _mbuNs.MBU.theme = mbuTheme; if (!_mbuNs.MBU.helpHref) _mbuNs.MBU.helpHref = mbuHelpHref; if (!_mbuNs.MBU.helpHtml) _mbuNs.MBU.helpHtml = mbuHelpHtml; if (!_mbuNs.MBU.helpEl) _mbuNs.MBU.helpEl = mbuHelpEl; if (!_mbuNs.MBU.toast) _mbuNs.MBU.toast = mbuToast; if (!_mbuNs.MBU.cfgHeader) _mbuNs.MBU.cfgHeader = mbuCfgHeader; if (!_mbuNs.MBU.dismissOn) _mbuNs.MBU.dismissOn = mbuDismissOn; if (!_mbuNs.MBU.fitToolbar) _mbuNs.MBU.fitToolbar = mbuFitToolbar; } catch (e) { /* a locked-down page must not stop the script loading */ } //
const css = MBU_TOKENS + MBU_UI_CSS + ` .tc-badge{font-size:10px;font-weight:bold;border-radius:9px;padding:1px 7px;color:var(--mbu-text-on-accent);white-space:nowrap} .tc-badge.rg{background:#1f8a4c}.tc-badge.set{background:#6c757d}.tc-badge.high{background:#2f6fd6}.tc-badge.disc{background:#0a7a8c} .tc-badge.low{background:#e0a800}.tc-badge.user{background:var(--mbu-accent)}.tc-badge.none{background:#c0392b}.tc-badge.cred{background:#b5179e}.tc-badge.alias{background:#1f8a7a}.tc-badge.pos{background:#a0522d}.tc-badge.plat{background:#0b6e99} .tc-btn{padding:4px 11px;border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial;color:var(--mbu-text)} .tc-btn:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-border)} .tc-btn.primary{color:var(--mbu-accent-text);font-weight:bold}.tc-btn.primary:hover{background:linear-gradient(#7a52df,var(--mbu-accent));color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} /* #577: while a pass runs, Match IS the stop button — it has to stop looking like the thing you press to start one. Tokens, not literals, so a dark userstyle gets a readable pair (#564). */ .tc-btn.tc-stopping,#tc-recwrap .tc-rec-am.busy{color:var(--mbu-error);border-color:var(--mbu-error-border);background:var(--mbu-error-bg);font-weight:bold} .tc-btn.tc-stopping:hover,#tc-recwrap .tc-rec-am.busy:hover{background:var(--mbu-error);color:var(--mbu-text-on-accent);border-color:var(--mbu-error)} .tc-tbsep{width:1px;height:18px;background:var(--mbu-bg-sunken);flex:none;margin:0 2px} /* vertical divider before the Match cluster, shared by both toolbars */ .tc-btn:disabled,.tc-btn:disabled:hover{color:var(--mbu-text-weak);background:transparent;border-color:transparent;cursor:default;font-weight:normal} .tc-btn.mini{padding:1px 6px;font-size:11px} .tc-icon{cursor:pointer;border:none;background:none;font-size:13px;padding:0 2px;color:var(--mbu-text-dim)} #tc-panel a,#tc-mirror-wrap a{color:var(--mbu-accent-deep-text);text-decoration:none}#tc-panel a:hover,#tc-mirror-wrap a:hover{text-decoration:underline} .tc-mirror{table-layout:fixed;width:100%;border-collapse:collapse;font:13px Arial,Helvetica,sans-serif;background:var(--mbu-bg)} /* clean "normal" look, shared with the Recordings table: light header, no column fill/borders, soft row rule */ .tc-mirror th{position:relative;background:transparent;border-bottom:1px solid var(--mbu-border);text-align:left;padding:4px 7px;font-size:11px;font-weight:bold;color:var(--mbu-text-dim);overflow:hidden} .tc-mirror th:last-child{border-right:none} .tc-mirror td{padding:4px 7px;vertical-align:middle;overflow:hidden;background:var(--mbu-bg)} .tc-mirror.gridrows td{border-bottom:1px solid var(--mbu-border)} /* row line BETWEEN tracks (a track is one row, never between its artists) */ .tc-mirror td.c-art{vertical-align:top;padding-top:0;padding-bottom:0} /* green matched boxes touch row-to-row (no white gap) */ .tc-mirror td.c-badge{vertical-align:top} .tc-mirror td.c-badge{position:relative;padding:0;text-align:center} .tc-mirror .tc-resizer{position:absolute;right:-1px;top:0;height:100%;width:9px;cursor:col-resize;border-right:2px solid transparent} .tc-mirror th:hover .tc-resizer,.tc-mirror .tc-resizer:hover{border-right-color:var(--mbu-accent)} .tc-mirror .c-num{color:var(--mbu-text-weak);font-variant-numeric:tabular-nums;text-align:center} .tc-mirror th.c-len{text-align:right} .tc-mirror .c-mv{white-space:nowrap;text-align:center} /* #564: these inputs are created WITHOUT a type attribute, which is exactly what kellnerd's "Dark Side of MusicBrainz" targets -- its rule input:not([type]):not(.lookup-performed):not(.required):not([style*=background]) paints them --background-dimmed (#222) at specificity (0,4,1), outranking every rule below, so in a dark theme they read as black boxes floating on the row instead of being invisible as they are in light. Measured rather than guessed: test/probe-564-tracklist-bg.mjs prints the computed colour of each one under the real userstyle. !important is what outranks it without specificity golf, and the state fills need it too -- an !important base would otherwise bury the diff/preview/pending colours that actually carry meaning. */ .tc-mirror input.t-title,.tc-mirror input.t-len,.tc-mirror input.t-num{width:100%;box-sizing:border-box;border:1px solid transparent;background:transparent!important;font:13px Arial;padding:3px 2px} .tc-mirror input.t-len,.tc-mirror input.t-num{text-align:right;color:var(--mbu-text-dim)} .tc-mirror input.t-num{text-align:center} .tc-mirror input.t-title:hover,.tc-mirror input.t-title:focus,.tc-mirror input.t-len:hover,.tc-mirror input.t-len:focus,.tc-mirror input.t-num:hover,.tc-mirror input.t-num:focus{border-color:var(--mbu-border);background:var(--mbu-bg)!important} .tc-mirror input.t-len[readonly]{color:var(--mbu-text-weak);cursor:default} /* #329: TOC-fixed length — not editable */ .tc-mirror input.t-len[readonly]:hover,.tc-mirror input.t-len[readonly]:focus{border-color:transparent;background:transparent!important} .tc-mirror .t-wrap{display:flex;align-items:center;gap:3px;position:relative}.tc-mirror .t-wrap input.t-title{flex:1;min-width:0;width:auto} /* In-cell action buttons (Aa / ⋔) overlay the input's right edge instead of sitting in the flex flow, so they don't reserve width and shrink the input — otherwise "Fit" sizes the column to the text but the reserved button space clips long titles. #153 */ .tc-mirror .t-actions{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:flex;align-items:center;gap:3px;pointer-events:none} .tc-mirror .t-actions>*{pointer-events:auto} .tc-mirror input.t-title.diff{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn);border-radius:3px} .tc-mirror input.t-title.gcpreview{background:var(--mbu-ok-bg)!important;border-color:var(--mbu-ok);border-radius:3px} /* #203: rich title display — read-only styled text (confusable chars enlarged) shown when the title isn't being edited; clicking/tabbing into it shows the native input. Mirrors the input's diff/gcpreview/hasfeat backgrounds so the cell looks unchanged. */ .tc-mirror .t-title-disp{flex:1;min-width:0;min-height:1.55em;box-sizing:border-box;border:1px solid transparent;border-radius:3px;background:transparent;font:13px Arial;padding:3px 2px;white-space:pre;overflow:hidden;cursor:text;display:flex;align-items:center} .tc-mirror .t-title-ph{color:var(--mbu-text-weak);font-style:italic} /* #357: empty-title placeholder in the rich display */ .tc-mirror .t-title-disp:hover{border-color:var(--mbu-border);background:var(--mbu-bg)} .tc-mirror .t-title-disp.diff{background:var(--mbu-warn-bg);border-color:var(--mbu-warn)} .tc-mirror .t-title-disp.gcpreview{background:var(--mbu-ok-bg);border-color:var(--mbu-ok)} .tc-mirror .t-title-disp.hasfeat{background:var(--mbu-bg-raised);border-color:var(--mbu-info)} .tc-mirror .t-title-disp.tc-hidden{display:none} /* #376 mirror MB's native gold "modification pending" mark — color ONLY the pending field (the title for a recording edit, the artist for an artist edit), not the whole row */ .tc-mirror tr.tc-rec-pending td.c-title .t-title-disp,.tc-mirror tr.tc-rec-pending td.c-title input.t-title{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn)} .tc-mirror td.c-art .tc-search.tc-slot-pending{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn)} /* #376 per-slot: only a selected artist with pending edits golds, never a free-text slot */ .tc-mirror.compact .t-title-disp{padding:0 2px;font-size:12px} .tc-mirror input.t-title.tc-eml:not(.tc-editing){position:absolute;width:1px;height:1px;min-width:0;padding:0;margin:0;border:0;opacity:0;pointer-events:none} /* MB medium-format select made to read as plain text — click still opens the native dropdown */ select.tc-fmt-flat{-webkit-appearance:none;-moz-appearance:none;appearance:none;border:1px solid transparent;background:transparent;font:bold 15px Arial;color:var(--mbu-text);padding:2px 5px;cursor:pointer} select.tc-fmt-flat:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);border-radius:3px} /* #154: theme the native medium header (legend · collapse · format · "Medium title" · move/remove) to match Apollo while the tracklist takeover is on. Scoped to body.tc-tl-on so the original look returns the instant you switch back to the native editor — AND every button rule is further scoped to fieldset.advanced-medium, because tc-tl-on lives on (stays on while you visit other tabs) and remove-item / guesscase-title are generic classes that also exist on the Release-information tab (external-link ✕, title Aa). Without the medium scope they leaked their glyphs onto that tab (#160). */ body.tc-tl-on fieldset.advanced-medium{border:1px solid var(--mbu-accent);border-radius:8px;background:var(--mbu-bg-raised);margin:0 0 12px;padding:3px 12px 6px} body.tc-tl-on fieldset.advanced-medium > legend{font:700 12px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text)!important;padding:0 6px;margin-left:2px} body.tc-tl-on table.advanced-format{width:100%;border-collapse:collapse;margin:0} body.tc-tl-on table.advanced-format > tbody > tr > td{padding:3px 5px;vertical-align:middle;border:none} body.tc-tl-on table.advanced-format td.format{display:flex;align-items:center;gap:8px;flex-wrap:wrap} /* the flat ▸ / ▾ icon buttons (collapse, move, remove) — drop MB's sprite, draw a themed glyph */ body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down, body.tc-tl-on fieldset.advanced-medium button.icon.remove-item{background:none!important;border:none;width:30px;height:28px;padding:0;margin:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;border-radius:5px;color:var(--mbu-accent-text);font-size:18px;line-height:1} /* the triangle glyphs render small for their font-size — pump the arrows up so they read clearly (#154) */ body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium::before,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium::before, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up::before,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down::before{font-size:21px} body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium:hover,body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium:hover, body.tc-tl-on fieldset.advanced-medium button.icon.medium-up:hover,body.tc-tl-on fieldset.advanced-medium button.icon.medium-down:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)} body.tc-tl-on fieldset.advanced-medium button.icon.expand-medium::before{content:'▸'} body.tc-tl-on fieldset.advanced-medium button.icon.collapse-medium::before{content:'▾'} body.tc-tl-on fieldset.advanced-medium button.icon.medium-up::before{content:'▴'} body.tc-tl-on fieldset.advanced-medium button.icon.medium-down::before{content:'▾'} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item{color:var(--mbu-error);margin-left:14px!important} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item::before{content:'✕';font-weight:bold} body.tc-tl-on fieldset.advanced-medium button.icon.remove-item:hover{background:var(--mbu-error-bg);color:var(--mbu-error)} /* "Medium title:" label + input + the Aa guess-case button */ body.tc-tl-on table.advanced-format td.format > label[for^="medium-title"]{font:12px Arial;color:var(--mbu-accent-text);margin-left:6px} body.tc-tl-on input[id^="medium-title-"]{flex:1 1 220px;min-width:200px;border:1px solid var(--mbu-accent);border-radius:4px;padding:3px 7px;font:13px Arial;background:var(--mbu-bg);box-shadow:none} body.tc-tl-on input[id^="medium-title-"]:focus{border-color:var(--mbu-accent);outline:none} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title{background:none!important;border:1px solid var(--mbu-accent);border-radius:4px;width:auto;height:auto;min-width:0;padding:2px 7px;margin:0;cursor:pointer;color:var(--mbu-accent-text);font:bold 11px Arial;line-height:1.4} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title::before{content:'Aa'} body.tc-tl-on fieldset.advanced-medium button.icon.guesscase-title:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);color:var(--mbu-accent-text)} .tc-mirror .t-gc{flex:none;cursor:pointer;border:1px solid var(--mbu-warn);background:var(--mbu-warn-bg);color:var(--mbu-warn);font:bold 10px Arial;border-radius:3px;padding:1px 4px;visibility:hidden}.tc-mirror .t-gc:hover{background:var(--mbu-warn-bg)} .tc-mirror tr:hover .t-gc{visibility:visible} .tc-mirror input.t-title.hasfeat{background:var(--mbu-bg-raised)!important;border-color:var(--mbu-info);border-radius:3px} .tc-mirror .t-feat{flex:none;cursor:pointer;border:1px solid var(--mbu-info);background:var(--mbu-bg-raised);color:var(--mbu-info);font:bold 12px Arial;border-radius:3px;padding:0 4px;line-height:16px;visibility:hidden}.tc-mirror .t-feat:hover{background:var(--mbu-info-bg)} .tc-mirror tr:hover .t-feat{visibility:visible} .tc-mirror .mv{cursor:pointer;color:var(--mbu-accent-text);font-size:12px;padding:0 1px} /* drag-to-reorder: ⠿ handle + drop indicators (a purple line at the row edge you'll drop against) */ .tc-mirror .tc-drag{cursor:grab;color:var(--mbu-accent-text);font-size:15px;line-height:1;padding:0 3px;user-select:none} .tc-mirror .tc-drag:hover{color:var(--mbu-accent-text)}.tc-mirror .tc-drag:active{cursor:grabbing} .tc-mirror tr.tc-dragging td{opacity:.45} .tc-mirror tr.tc-drop-before td{box-shadow:inset 0 2px 0 #5f3ec0} .tc-mirror tr.tc-drop-after td{box-shadow:inset 0 -2px 0 #5f3ec0} /* alternate row colors / grid (toggled in ⚙) */ .tc-mirror.alt tbody tr:nth-child(even) td{background:var(--mbu-bg-raised)} .tc-mirror.gridcols td{border-right:1px solid var(--mbu-border)}.tc-mirror.gridcols td:last-child{border-right:none} /* density layouts: compact (tight) · normal (default, shared with Recordings) · cozy (airy) */ .tc-mirror.cozy th{padding:7px 7px}.tc-mirror.cozy td{padding:8px 7px} .tc-mirror.compact th{padding:2px 6px} .tc-mirror.compact td{padding:0 6px} .tc-mirror.compact .tc-aslot,.tc-mirror.compact .tc-bl{height:21px} .tc-mirror.compact input.t-title,.tc-mirror.compact input.t-len,.tc-mirror.compact input.t-num{padding:0 2px;font-size:12px} .tc-mirror.compact .tc-search{padding:0 5px}.tc-mirror.compact .tc-search .nm{padding:1px 0;font-size:12px} .tc-mirror.compact .tc-cred{padding:0 4px 0 15px} /* badge column: pills per artist line; on row hover the track ↺/✕ show at the cell's right edge. Only the buttons take the pointer — the rest of the overlay lets it through — so the pills stay visible and their tooltips (pos: on how many editions) reachable; a cell narrow enough that a button lands on a pill gives the button a backing so it still reads (majkinetor: "those 2 icons covering Match column"). */ .tc-bl{height:28px;box-sizing:border-box;display:flex;align-items:center;justify-content:center} .tc-mirror td.c-act{vertical-align:top;padding:0} .tc-mirror th.c-badge{text-align:center} /* #654: over its centred pills */ .tc-mirror .tc-badge[data-slot]{cursor:help} /* #654: the match card, a badge's rich tooltip */ #tc-mtip{position:absolute;z-index:var(--mbu-z-pop);width:380px;max-width:94vw;background:var(--mbu-bg);color:var(--mbu-text);border:1px solid var(--mbu-border);border-radius:9px;box-shadow:var(--mbu-shadow-lg);font:13px/1.4 var(--mbu-font);overflow:hidden} #tc-mtip .tc-mt-h{display:flex;align-items:center;gap:8px;padding:8px 11px;border-bottom:1px solid var(--mbu-border-soft);background:var(--mbu-bg-raised)} #tc-mtip .tc-mt-st{margin-left:auto;font-size:11px;color:var(--mbu-text-weak);white-space:nowrap} #tc-mtip .tc-mt-bd{padding:8px 11px;display:flex;flex-direction:column;gap:6px} #tc-mtip a{color:var(--mbu-accent-text)} #tc-mtip .tc-mt-who a{font-weight:600;text-decoration:none} #tc-mtip .tc-mt-meta{display:block;font-size:11px;color:var(--mbu-text-weak)} #tc-mtip .tc-mt-dim{color:var(--mbu-text-weak)} #tc-mtip .tc-mt-means{color:var(--mbu-text-dim);font-size:12px} #tc-mtip .tc-mt-why{color:var(--mbu-text-dim)} #tc-mtip .tc-mt-alt{font-size:12px;color:var(--mbu-text-dim)} #tc-mtip .tc-mt-tbl{border-collapse:collapse;width:100%;font-size:12px} #tc-mtip .tc-mt-tbl td{padding:2px 4px;border-top:1px solid var(--mbu-divider);vertical-align:top} #tc-mtip .tc-mt-tbl td.ok{color:var(--mbu-ok);font-weight:bold;width:14px} #tc-mtip .tc-mt-tbl td.no{color:var(--mbu-error);font-weight:bold;width:14px} #tc-mtip .tc-mt-tbl td.w{color:var(--mbu-text-weak);text-align:right} #tc-mtip .tc-mt-ft{display:flex;gap:10px;padding:6px 11px;border-top:1px solid var(--mbu-border-soft);font-size:11px;color:var(--mbu-text-weak)} #tc-mtip .tc-mt-ft .k{margin-left:auto} .tc-trackacts{height:28px;display:flex;align-items:center;justify-content:center;gap:0;visibility:hidden} .tc-mirror tr:hover .tc-trackacts{visibility:visible} .tc-trackacts button{cursor:pointer;border:none;border-radius:4px;width:22px;padding:2px 0;background:none;font-size:16px;line-height:1} .tc-trackacts button:hover{background:var(--mbu-bg-hover)} .tc-trackacts button.void{visibility:hidden} /* the slot keeps its width, so ✕ never moves */ .tc-trackacts .trev{color:var(--mbu-accent-text)}.tc-trackacts .trev:hover{color:var(--mbu-accent-text)} .tc-trackacts .rm{color:var(--mbu-error);font-weight:bold}.tc-trackacts .rm:hover{color:var(--mbu-error)} /* #564 (majkinetor): "you should also change the blue color on the left that marks modified row, it doesn't look very visible". It was a hard-coded #5f3ec0 — the LIGHT theme's accent — so on a dark row it was a dark violet sliver against a dark background. --mbu-accent-text carries the theme (#b9a7f0 on dark), and 4px rather than 3 gives it a little more presence. Same marker in all three places it is drawn: tracklist rows, the mirror's own row rule, and the Recordings tab. */ .tc-mirror tr.tc-changed td:first-child{box-shadow:inset 4px 0 0 var(--mbu-accent-text)} /* a track that differs from its page-load state */ /* one artist = one aligned fixed-height line: credited-as · icon · search box · acts (no line between artists) */ .tc-aslot{display:flex;align-items:center;gap:5px;height:28px;box-sizing:border-box} .tc-cred{flex:none;width:130px;text-align:right;box-sizing:border-box;font:11px Arial;color:var(--mbu-text);border:1px solid transparent;background:transparent!important;padding:1px 4px 1px 15px;transition:color .12s} .tc-cred::placeholder{color:var(--mbu-text-weak)} .tc-credwrap{position:relative;flex:none;display:inline-flex;align-items:center} .tc-cred-clr{position:absolute;left:2px;top:50%;transform:translateY(-50%);z-index:2;display:none;border:none;background:none;color:var(--mbu-text-weak);cursor:pointer;font-size:12px;line-height:1;padding:0} .tc-aslot.tc-has-cred:hover .tc-cred-clr{display:block} .tc-cred-clr:hover{color:var(--mbu-error)} .tc-cred:hover,.tc-cred:focus{border-color:var(--mbu-accent);background:var(--mbu-bg)!important;color:var(--mbu-text)} .tc-aslot.tc-can-split .tc-cred{background:var(--mbu-warn-bg)!important;border-color:var(--mbu-warn);border-radius:3px;color:var(--mbu-warn)} .tc-aslot.tc-can-split .tc-cred::placeholder{color:var(--mbu-warn)} .tc-tic{flex:none;width:18px;height:16px;display:inline-flex;align-items:center;justify-content:center;color:var(--mbu-accent-text);text-decoration:none} .tc-tic.link{cursor:pointer}.tc-tic.link:hover{color:var(--mbu-accent-deep-text)}.tc-tic.dim{color:var(--mbu-accent-text)} .tc-tic.discogs-add{color:var(--mbu-info);cursor:pointer;background:var(--mbu-info-bg);border-radius:4px}.tc-tic.discogs-add:hover{color:var(--mbu-info);background:var(--mbu-info-bg)} .tc-tic.discogs-conflict{color:var(--mbu-error);cursor:pointer;background:var(--mbu-error-bg);border-radius:4px}.tc-tic.discogs-conflict:hover{color:var(--mbu-error);background:var(--mbu-error-bg)} .tc-tic.discogs-mismatch{color:var(--mbu-warn);cursor:pointer;background:var(--mbu-warn-bg);border-radius:4px}.tc-tic.discogs-mismatch:hover{color:var(--mbu-warn);background:var(--mbu-warn-bg)} /* one fixed-width search box per artist (so all lines align); name fills it, + + join sit at the right */ .tc-search{flex:1 1 0;min-width:0;align-self:stretch;display:flex;align-items:center;gap:4px;border:none;border-radius:4px;background:var(--mbu-bg);padding:0 6px;overflow:hidden;transition:box-shadow .12s} /* unmatched = plain white; the green fill marks a match. transition: fade the #284 hover-highlight ring in/out */ .tc-nm-clr{flex:none;display:none;align-items:center;border:none;background:none;color:var(--mbu-text-weak);cursor:pointer;font-size:16px;line-height:1;padding:0 2px;visibility:hidden} /* a little bigger */ .tc-search.tc-has-nm .tc-nm-clr{display:inline-flex} /* a filled slot always reserves the × slot (invisible) → hover doesn't shift the text */ .tc-search.tc-has-nm:hover .tc-nm-clr{visibility:visible} .tc-search.tc-has-nm .nm:not(:focus){flex:0 1 auto} /* when not editing, size the input to the name (its size attr) so the × sits right after it. Focused stays full-width for comfortable typing */ .tc-nm-clr:hover{color:var(--mbu-error)} .tc-search:focus-within{box-shadow:inset 0 0 0 1px #b9a4e0} .tc-search.matched{background:var(--mbu-ok-bg)} /* #284: hover-highlight every instance of the same artist (ring keeps the underlying matched/unmatched colour) */ /* #284 hover-highlight: a soft 1px ring + faint inset wash (box-shadow, so it doesn't disturb the matched-green BACKGROUND), and no bold (which reflowed → the "jump"). The base .tc-search/.tc-cred carry a short transition so it fades in AND out. */ .tc-aslot.tc-arthl .tc-search{box-shadow:inset 0 0 0 1px #b79ee8, inset 0 0 0 100px rgba(123,79,208,.06)} .tc-aslot.tc-arthl .tc-cred{color:var(--mbu-accent-text)} /* "Alternate row colors": tint the matched box a touch deeper on every other track (per row, so a multi-artist group stays one shade) — the only way the banding shows through the green fill */ .tc-mirror.alt tbody tr:nth-child(even) .tc-search.matched{background:var(--mbu-ok-bg)} /* group ALL of a track's artist boxes under ONE border: collapse adjacent boxes, round only the outer corners; the inner borders become subtle dividers between the individual artists. #119 */ .tc-mirror td.c-art .tc-aslot .tc-search{border-radius:0} .tc-mirror td.c-art .tc-aslot:first-child .tc-search{border-top-left-radius:4px;border-top-right-radius:4px} .tc-mirror td.c-art .tc-aslot:last-child .tc-search{border-bottom-left-radius:4px;border-bottom-right-radius:4px} .tc-mirror td.c-art .tc-aslot:not(:first-child) .tc-search{border-top:none} .tc-mirror td.c-art .tc-aslot:not(:last-child) .tc-search{border-bottom:none} /* no horizontal line between a track's artists — one seamless box */ /* split/multi-artist cue: a faint purple left stripe on tracks that have 2+ artists */ .tc-mirror td.c-art:has(.tc-aslot ~ .tc-aslot){box-shadow:inset 2px 0 0 #d8cbf0} @keyframes tcflash{0%{box-shadow:0 0 0 3px #e0a800}70%{box-shadow:0 0 0 3px #e0a800}100%{box-shadow:0 0 0 0 rgba(224,168,0,0)}} .tc-search.tc-flash{animation:tcflash 1.5s ease-out} .tc-search.tc-marked{border:2px solid var(--mbu-warn)} /* persists when a pick changed several tracks */ .tc-search .nm{flex:1 1 0;min-width:0;border:none;background:transparent!important;font:13px Arial;padding:3px 0;outline:none} .tc-search .tc-bar-aka{flex:0 1 auto;min-width:0;max-width:55%;margin-left:2px;color:var(--mbu-ok);font-size:11px;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none} .tc-search .tc-bar-disamb{flex:0 1 auto;min-width:0;max-width:55%;margin-left:4px;color:var(--mbu-text-weak);font-size:11px;font-style:italic;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;pointer-events:none} /* artist disambiguation, grey like native #195 */ .tc-search .mk{flex:none;order:9;cursor:pointer;border:none;background:none;color:var(--mbu-ok);font-weight:bold;font-size:15px;line-height:1;padding:0 2px}.tc-search .mk:hover{color:var(--mbu-ok)} /* order:9 keeps + pinned at the far right, past the join-phrase */ /* #580: + belongs to an unresolved slot, but it must also be reachable on a RESOLVED one the moment you focus it — that is how you create an artist that happens to share its name with the matched one. Hidden again on blur, so a settled table stays quiet. */ .tc-search.matched .mk{display:none} .tc-search.matched:has(input.nm:focus) .mk{display:inline-block} /* the name field specifically — not :focus-within, which the join-phrase input would also trip */ /* push + to the right edge only when there's NO join-phrase; with a join, .tc-joinwrap's margin-left:auto already pushes the right-group and + (order:9) follows it (two auto-margins would split the gap). #298 follow-up */ .tc-search:not(:has(.tc-joinwrap)) .mk{margin-left:auto} .tc-joinwrap{flex:none;margin-left:auto;display:flex;align-items:center;gap:0} .tc-join{width:auto;text-align:right;border:1px solid transparent;background:transparent!important;color:var(--mbu-text-dim);font:italic 900 12px var(--mbu-font);padding:1px 2px;border-radius:3px} .tc-join:hover,.tc-join:focus{border-color:var(--mbu-border);background:var(--mbu-bg)!important;color:var(--mbu-text)} /* #419: the caret needs a REAL hit target (padding-grown), not a bare 10px glyph */ .tc-joinarrow{cursor:pointer;border:none;background:none;color:var(--mbu-accent-text);font-size:12px;padding:3px 5px;margin:-3px 0;line-height:1;border-radius:3px} .tc-joinarrow:hover{color:var(--mbu-accent-text);background:var(--mbu-bg-hover)} /* #208 join-phrase spacing flags: ␣ where a space is missing, ␣?␣ when the phrase is missing entirely */ .tc-joinwrap.tc-jp-bad .tc-join{border-color:var(--tc-hl,#e53935);background:var(--mbu-error-bg)!important;color:var(--mbu-error)} .tc-jp-nolead::before,.tc-jp-notrail::after,.tc-jp-nophrase::before{background:var(--tc-hl,#e53935);color:var(--mbu-text-on-accent);border-radius:2px;padding:0 1px;font:700 11px Arial;line-height:1} .tc-jp-nolead::before{content:'␣'} .tc-jp-notrail::after{content:'␣'} .tc-jp-nophrase::before{content:'␣?␣'} .tc-joinpop .tc-acrow{justify-content:space-between;gap:14px}.tc-joinpop .cmt{color:var(--mbu-text-weak)} .tc-acts{flex:none;width:76px;display:flex;align-items:center;justify-content:flex-start;gap:4px;padding-left:4px} .tc-enter,.tc-slotx,.tc-splitb,.tc-slotgrab{cursor:pointer;border:none;background:none;padding:0 1px;visibility:hidden;line-height:1} .tc-enter{color:var(--mbu-accent-text);font-size:19px}.tc-enter:hover{color:var(--mbu-accent-text)} .tc-splitb{color:var(--mbu-accent-text);font-size:16px;font-weight:bold}.tc-splitb:hover{color:var(--mbu-accent-text)} .tc-aslot:not(.tc-can-split) .tc-splitb{display:none} .tc-slotgrab{cursor:grab;color:var(--mbu-accent-text);font-size:13px;user-select:none}.tc-slotgrab:hover{color:var(--mbu-accent-text)}.tc-slotgrab:active{cursor:grabbing} /* #150: drag to reorder this artist within the credit */ .tc-aslot.tc-slotdragging{opacity:.45} .tc-aslot.tc-slotdrop-before{box-shadow:inset 0 2px 0 #5f3ec0}.tc-aslot.tc-slotdrop-after{box-shadow:inset 0 -2px 0 #5f3ec0} .tc-slotx{color:var(--mbu-error);font-size:13px}.tc-slotx:hover{color:var(--mbu-error)} .tc-mirror tr:hover .tc-enter,.tc-mirror tr:hover .tc-slotx,.tc-mirror tr:hover .tc-splitb,.tc-mirror tr:hover .tc-slotgrab{visibility:visible} .tc-acpop{position:fixed;z-index:100002;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:4px;box-shadow:0 6px 22px rgba(40,20,80,.3);max-height:300px;overflow:auto;font:12px Arial;min-width:210px} .tc-acrow{display:flex;align-items:center;gap:7px;padding:4px 9px;cursor:pointer} .tc-acrow:hover,.tc-acrow.hi{background:var(--mbu-bg-hover)} .tc-acrow .tic{flex:none;width:17px;display:inline-flex;align-items:center;justify-content:center;color:var(--mbu-accent-text)} .tc-acrow .nm{font-weight:600;color:var(--mbu-text)}.tc-acrow .cmt{color:var(--mbu-text-weak);font-size:11px} .tc-acrow .tc-aka{color:var(--mbu-ok);font-size:11px;font-style:italic} .tc-acrow.none{color:var(--mbu-text-weak);font-style:italic;cursor:default} .tc-acsec{padding:5px 9px 2px;font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--mbu-text-weak);cursor:default}.tc-acsec:not(:first-child){border-top:1px solid var(--mbu-border-soft);margin-top:3px} .tc-acrow .tc-acvotes{margin-left:auto;padding-left:10px;font-size:10px;color:var(--mbu-text-weak);white-space:nowrap} .tc-acmore{justify-content:center;font-style:italic;color:var(--mbu-accent-text);border-top:1px solid var(--mbu-accent);position:sticky;bottom:0;background:var(--mbu-bg-raised)} .tc-acrow.exact{background:var(--mbu-ok-bg)}.tc-acrow.exact .nm{color:var(--mbu-ok)}.tc-acrow.exact:hover,.tc-acrow.exact.hi{background:var(--mbu-ok-bg)} .tc-toolbar{padding:5px 4px;font-size:12px;color:var(--mbu-text-dim);display:flex;align-items:center;gap:6px} .tc-toolbar select{font:12px Arial;padding:1px} .tc-medhdr{background:var(--mbu-bg-hover);font-weight:bold;color:var(--mbu-accent-deep-text);padding:4px 8px} #tc-panel{position:fixed;top:90px;right:18px;width:720px;max-width:96vw;max-height:84vh;background:var(--mbu-bg); border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 8px 34px rgba(40,20,80,.32);z-index:99999; display:flex;flex-direction:column;font:13px/1.4 Arial,Helvetica,sans-serif;color:var(--mbu-text)} #tc-hdr{display:flex;align-items:center;gap:8px;padding:8px 11px;background:var(--mbu-bg-hover);border-bottom:1px solid var(--mbu-accent);border-radius:6px 6px 0 0;cursor:move;user-select:none} #tc-hdr b{flex:1;color:var(--mbu-accent-text);font-size:14px}#tc-hdr .meta{font-size:12px;color:var(--mbu-text-dim)} #tc-body{flex:1;overflow:auto} #tc-foot{display:flex;align-items:center;gap:8px;padding:8px 11px;border-top:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:0 0 6px 6px} #tc-foot .sp{flex:1} /* the global toolbar stays pinned at the top while scrolling the tracklist */ #tc-mirror-wrap{margin:4px 0 6px;position:sticky;top:0;z-index:50;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-accent);box-shadow:0 3px 8px rgba(40,20,80,.07)} /* #412: MusicBrainz can take a while to save a big edit — pulse the pinned Apollo bar the user actually presses Enter-edit on (the compact nav bar), so it's obvious the save is in flight. inset box-shadow tints without disturbing the bar's own background. Also covers the tracklist / recordings toolbars when the compact nav is off. */ @keyframes tc-saving-pulse{0%,100%{box-shadow:inset 0 0 0 9999px rgba(28,111,214,0)}50%{box-shadow:inset 0 0 0 9999px rgba(28,111,214,.18)}} body.tc-saving #tc-nav-bar,body.tc-saving #tc-bar,body.tc-saving #tc-recwrap .tc-recbar{animation:tc-saving-pulse 1.1s ease-in-out infinite;border-radius:5px} .tc-medsec{margin:2px 0 14px} #tc-bar{display:flex;align-items:center;gap:8px;padding:6px 4px;flex-wrap:nowrap;min-width:0} /* #280: never wrap → toast / "added link" text can't push the toolbar taller */ #tc-bar b{color:var(--mbu-accent-text)}#tc-bar .sp{flex:1 1 0;min-width:0} #tc-bar > .tc-btn{flex:none;white-space:nowrap} /* #280: Match / ▾ never shrink → never wrap to 2 lines when toast text appears */ .tc-globalstat{flex:none;font-size:12px;color:var(--mbu-text-weak);font-style:italic;white-space:nowrap} .tc-am-lbl{flex:none;display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--mbu-text-dim);white-space:nowrap}.tc-am-lbl select{font:12px Arial;padding:1px 3px} .tc-globalstat.tc-unres{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#d6342c;padding:1px 8px;border-radius:9px} /* #227: persistent "N missing Discogs links" badge (teal, like the DISC match badge) */ .tc-discstat{flex:none;font-size:12px;color:var(--mbu-text-weak);font-style:italic;white-space:nowrap} .tc-discstat.tc-disc-badge{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#0a7a8c;padding:1px 8px;border-radius:9px} .tc-discstat.tc-disc-ok{font-style:normal;font-weight:bold;color:var(--mbu-ok)} .tc-discstat.tc-disc-pend{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#c98a00;padding:1px 8px;border-radius:9px} .tc-tablewrap{overflow-x:auto} .tc-addrow{padding:8px 4px;font-size:13px;color:var(--mbu-text-dim);display:flex;align-items:center;gap:6px} .tc-addrow input.tc-addn{width:54px;font:13px Arial;padding:2px 4px;border:1px solid var(--mbu-border);border-radius:3px} .tc-addbtn{width:22px;height:22px;border-radius:50%;border:1px solid var(--mbu-accent);background:transparent;color:var(--mbu-accent-text);font:bold 15px/1 Arial;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} .tc-addbtn:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text);border-color:var(--mbu-accent)} /* #330: pregap/data-track toggles + row markers */ .tc-medopts{display:inline-flex;align-items:center;gap:12px;margin-left:14px} .tc-mmo{display:inline-flex;align-items:center;flex-wrap:wrap;gap:2px 8px}.tc-mmo label{display:inline-flex;align-items:center;gap:2px;cursor:pointer;font-size:12px}.tc-mmo-lab{font-size:12px;color:var(--mbu-text-dim);margin-right:2px} /* #615 */ .tc-spo{display:inline-flex;align-items:center;gap:6px} /* #615 */ .tc-medopt{display:inline-flex;align-items:center;gap:3px;font-size:12px;color:var(--mbu-text-dim);cursor:pointer;white-space:nowrap} .tc-medopt:has(input:disabled){color:var(--mbu-text-weak);cursor:default} /* #330: disc-ID medium — toggle shown but locked */ .tc-trkkind{flex:none;font-size:10px;font-weight:600;text-transform:uppercase;letter-spacing:.04em;color:var(--mbu-ok);background:var(--mbu-ok-bg);border:1px solid var(--mbu-ok);border-radius:8px;padding:0 5px;margin-right:5px} .tc-mirror tr.tc-row-pregap td,.tc-mirror tr.tc-row-data td{background:var(--mbu-bg-raised)} /* #584 — the video marker also rides in the tracklist; its own rules live in recStyle(), which only runs once the Recordings tab has been opened, so repeat them here rather than leaving an unstyled glyph on first paint. */ .tc-mirror .tc-rec-video{display:inline-flex;flex:none;vertical-align:-2px;color:var(--mbu-accent-text);margin-right:4px} .tc-mirror .tc-rec-video svg{display:block} .tc-mirror tr.tc-datadiv td{background:var(--mbu-bg-hover);color:var(--mbu-accent-deep-text);font-size:11px;font-weight:600;letter-spacing:.04em;padding:3px 8px} /* #586 data-track boundary buttons — hover-revealed, but always in layout */ .tc-mirror .tc-dtmv{visibility:hidden;cursor:pointer;background:none;border:none;padding:0 2px;margin-left:2px;font-size:13px;line-height:1;color:var(--mbu-text-weak)} .tc-mirror tr:hover .tc-dtmv,.tc-mirror tr.tc-row-data .tc-dtmv{visibility:visible} .tc-mirror .tc-dtmv:hover{color:var(--mbu-accent-text)} .tc-mirror .tc-dtmv.void{visibility:hidden!important;cursor:default} /* holds the slot on the last track */ .tc-mirror th .tc-hstatus{font-weight:normal;font-style:italic;color:var(--mbu-text-weak);margin-left:12px;font-size:11px} .tc-mirror th .tc-hstatus.tc-unres{font-style:normal;font-weight:bold;color:var(--mbu-text-on-accent);background:#d6342c;padding:1px 7px;border-radius:9px;font-size:11px} .tc-mirror th .tc-hdr-am{float:right;font-weight:normal;font-style:normal;font-size:11px;color:var(--mbu-text);margin-right:14px;max-width:140px} .tc-tools{display:flex;align-items:center;gap:6px 8px;flex:1 1 auto;min-width:0;flex-wrap:wrap} /* #280: label + tools wrap together; wrapped rows start at the left (under the label) */ .tc-toolslabel{flex:none;font:800 11px Arial;letter-spacing:.03em;text-transform:uppercase;color:var(--mbu-accent-text);cursor:pointer;white-space:nowrap;border-bottom:1px dotted var(--mbu-accent);line-height:1.5} .tc-toolslabel:hover{color:var(--mbu-accent-deep-text)} .tc-toolbtns{display:contents} /* #280: its buttons/groups are direct flex items of .tc-tools so they wrap to the left edge */ .tc-toolbtn{display:inline-flex;align-items:center;gap:5px;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:5px;padding:4px 9px;font:13px Arial;color:var(--mbu-text-dim);cursor:pointer;white-space:nowrap;flex:none} .tc-toolbtn:hover{border-color:var(--mbu-accent);background:var(--mbu-bg-hover)} .tc-toolbtn.active{background:var(--mbu-accent);border-color:var(--mbu-accent);color:var(--mbu-text-on-accent);font-weight:600} .tc-toolbtn .tc-tbic{font-size:13px;line-height:1} .tc-icimg{height:14px;width:auto;vertical-align:-2px;display:inline-block} /* image-based tool icon (bridged tools can ship their own) */ .tc-mi-ic .tc-icimg{height:15px} .tc-tc-ic .tc-icimg{height:16px} .tc-tc-seg.cb-icon .tc-icimg{height:13px} .tc-toolbtn.instant .tc-tbic{color:var(--mbu-ok)} .tc-toolbtn.active .tc-tbic{color:var(--mbu-text-on-accent)} .tc-toolbtn.tc-more{font-weight:bold;color:var(--mbu-accent-text);border-style:dashed} .tc-toolopts{display:flex;align-items:center;gap:6px;min-width:0} .tc-gco,.tc-sro,.tc-colso,.tc-medo{display:flex;align-items:center;gap:8px} .tc-colso{gap:4px} .tc-colbtn{font:12px Arial;padding:2px 9px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-text)} /* #455 track length parser panel — centred, draggable, textarea+chooser | list */ .tc-lppop{position:fixed;z-index:100003;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:7px;box-shadow:0 8px 26px rgba(40,20,80,.28);font:12px Arial;color:var(--mbu-text);width:832px;height:64vh;min-width:520px;min-height:300px;max-width:94vw;max-height:88vh;display:flex;flex-direction:column;resize:both;overflow:hidden} .tc-lp-hd{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);cursor:move;user-select:none} .tc-lp-back{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:600 11px Arial;border-radius:5px;padding:3px 9px;white-space:nowrap}.tc-lp-back:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-lp-t{font:700 11px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text);flex:1} .tc-lp-err{font:bold 11px Arial;color:var(--mbu-text-on-accent);background:var(--mbu-error-bg);border-radius:var(--mbu-radius-lg);padding:2px 9px;white-space:nowrap;box-shadow:0 1px 3px rgba(197,48,48,.4)} /* #455.3 header invalid badge */ .tc-lp-med,.tc-lp-med1{font:12px Arial;color:var(--mbu-text);cursor:default} .tc-lp-x{border:none;background:none;cursor:pointer;font-size:14px;color:var(--mbu-text-weak);padding:0 2px}.tc-lp-x:hover{color:var(--mbu-text)} /* body: left (textarea + chooser overlay) | list */ .tc-lp-body{flex:1;min-height:0;display:flex;gap:8px;padding:8px 10px} .tc-lp-left{flex:0 0 45%;position:relative;display:flex} .tc-lp-ta{flex:1;resize:none;font:12px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:5px;padding:6px 8px} /* chooser — overlaid on the empty textarea (#455 round 3) */ .tc-lp-choose{position:absolute;inset:0;background:var(--mbu-bg-raised);border:1px solid var(--mbu-accent);border-radius:5px;display:flex;flex-direction:column;gap:12px;align-items:center;justify-content:center;padding:16px} .tc-lp-crow{display:flex;gap:10px;flex-wrap:wrap;justify-content:center} .tc-lp-cbtn{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-deep-text);font:600 13px Arial;border-radius:7px;padding:11px 16px}.tc-lp-cbtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-lp-clbl{font:600 12px Arial;color:var(--mbu-accent-text);margin-top:6px} .tc-lp-favs{display:flex;gap:10px;flex-wrap:wrap;justify-content:center;align-items:center;min-height:34px} .tc-lp-fav{width:32px;height:32px;cursor:pointer;border:1px solid var(--mbu-accent);border-radius:7px;padding:4px;background:var(--mbu-bg)}.tc-lp-fav:hover{border-color:var(--mbu-accent);background:var(--mbu-bg-hover);transform:scale(1.08)} .tc-lp-favtxt{cursor:pointer;border:1px solid var(--mbu-accent);border-radius:7px;padding:6px 9px;background:var(--mbu-bg);font:600 11px Arial;color:var(--mbu-accent-deep-text);max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.tc-lp-favtxt:hover{border-color:var(--mbu-accent);background:var(--mbu-bg-hover)} .tc-lp-favload,.tc-lp-nofav{font:12px Arial;color:var(--mbu-text-weak);font-style:italic} .tc-lp-list{flex:1;min-width:0;overflow:auto;border:1px solid var(--mbu-divider);border-radius:5px;padding:3px} .tc-lp-row{display:flex;align-items:center;gap:6px;padding:2px 3px;border-radius:3px}.tc-lp-row:hover{background:var(--mbu-bg-raised)} .tc-lp-tk{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--mbu-text);font-size:12px} .tc-lp-tk.none{color:var(--mbu-text-weak);font-style:italic} .tc-lp-val{width:80px;font:12px ui-monospace,Consolas,monospace;text-align:right;border:1px solid var(--mbu-border);border-radius:4px;padding:2px 5px} .tc-lp-val.bad{border-color:var(--mbu-error);background:var(--mbu-bg-raised);color:var(--mbu-error)} .tc-lp-add,.tc-lp-del{border:1px solid transparent;background:none;cursor:pointer;font-size:13px;line-height:1;border-radius:4px;padding:2px 5px} .tc-lp-add{color:var(--mbu-ok)}.tc-lp-add:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-ok)} .tc-lp-del{color:var(--mbu-error)}.tc-lp-del:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-error)} .tc-lp-addend{display:block;width:calc(100% - 6px);margin:4px 3px 2px;border:1px dashed var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);cursor:pointer;font:12px Arial;border-radius:5px;padding:5px}.tc-lp-addend:hover{background:var(--mbu-bg-hover)} .tc-lp-ft{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--mbu-accent)} .tc-lp-cnt{flex:1;font:11px Arial;color:var(--mbu-text-dim)} .tc-lp-cnt.ok{color:var(--mbu-ok)}.tc-lp-cnt.warn{color:var(--mbu-warn)}.tc-lp-cnt.bad{color:var(--mbu-error);font-weight:bold} .tc-lp-acts{display:flex;gap:8px} .tc-lp-cancel{cursor:pointer;border:1px solid var(--mbu-border);background:var(--mbu-bg);font:12px Arial;border-radius:4px;padding:4px 12px} .tc-lp-ok{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:4px;padding:4px 12px}.tc-lp-ok:hover:not(:disabled){background:var(--mbu-ok-bg)}.tc-lp-ok:disabled{opacity:.5;cursor:not-allowed} /* #456 pattern Track parser */ .tc-tpppop{position:fixed;top:9vh;left:50%;transform:translateX(-50%);z-index:100003;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:7px;box-shadow:0 8px 26px rgba(40,20,80,.28);font:12px Arial;color:var(--mbu-text);width:920px;height:70vh;min-width:640px;min-height:340px;max-width:96vw;max-height:88vh;display:flex;flex-direction:column;resize:both;overflow:hidden} .tc-tpp-hd{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);cursor:move;user-select:none} .tc-tpp-t{font:700 11px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text);flex:1} .tc-tpp-med,.tc-tpp-med1{font:12px Arial;color:var(--mbu-text)} .tc-tpp-max{border:none;background:none;cursor:pointer;font-size:13px;color:var(--mbu-text-weak);padding:0 2px}.tc-tpp-max:hover{color:var(--mbu-accent-text)} .tc-tpp-x{border:none;background:none;cursor:pointer;font-size:14px;color:var(--mbu-text-weak);padding:0 2px}.tc-tpp-x:hover{color:var(--mbu-text)} .tc-tpp-pat{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);flex-wrap:wrap} .tc-tpp-plbl{font:700 11px Arial;color:var(--mbu-accent-text)} .tc-tpp-piwrap{flex:1;min-width:160px;position:relative;display:flex} .tc-tpp-pi{flex:1;min-width:0;font:13px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:5px;padding:5px 24px 5px 8px;color:var(--mbu-accent-deep-text)} .tc-tpp-piclr{position:absolute;right:5px;top:50%;transform:translateY(-50%);display:none;border:none;background:none;cursor:pointer;color:var(--mbu-accent-text);font-size:12px;line-height:1;padding:2px}.tc-tpp-piclr:hover{color:var(--mbu-error)} .tc-tpp-piwrap.has .tc-tpp-piclr{display:block} .tc-tpp-presets{display:flex;gap:5px;flex-wrap:wrap} .tc-tpp-chip{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:12px ui-monospace,Consolas,monospace;border-radius:4px;padding:3px 7px}.tc-tpp-chip:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-tpp-split{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:11px Arial;border-radius:4px;padding:3px 8px;white-space:nowrap}.tc-tpp-split:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)}.tc-tpp-split b{color:var(--mbu-accent-text)} .tc-tpp-freeze{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:11px Arial;border-radius:4px;padding:3px 8px;white-space:nowrap}.tc-tpp-freeze:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-tpp-chipbar{position:fixed;z-index:100005;display:flex;gap:2px;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 4px 16px rgba(40,20,80,.32);padding:3px} .tc-tpp-chipbar button{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);font:bold 12px ui-monospace,Consolas,monospace;border-radius:4px;padding:3px 8px;min-width:22px}.tc-tpp-chipbar button:hover{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} .tc-tpp-chipbar button[data-clr]{color:var(--mbu-error)}.tc-tpp-chipbar button[data-clr]:hover{background:#c0392b;color:var(--mbu-text-on-accent);border-color:var(--mbu-error)} .tc-tpp-body{flex:1;min-height:0;display:flex;flex-direction:column;gap:8px;padding:8px 10px} .tc-tpp-src{flex:0 0 auto;display:flex;flex-direction:column;gap:4px} .tc-tpp-srctgl{align-self:flex-start;cursor:pointer;border:none;background:none;color:var(--mbu-accent-text);font:600 11px Arial;padding:2px}.tc-tpp-srctgl:hover{color:var(--mbu-accent-deep-text);text-decoration:underline} .tc-tpp-src.tc-collapsed .tc-tpp-ta{display:none} .tc-tpp-ta{height:15vh;min-height:44px;resize:vertical;font:12px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:5px;padding:6px 8px} .tc-tpp-tblwrap{flex:1;min-height:0;overflow:auto;border:1px solid var(--mbu-divider);border-radius:5px} .tc-tpp-tbl{width:100%;border-collapse:collapse;font-size:12px} .tc-tpp-tbl th{position:sticky;top:0;background:var(--mbu-bg-raised);text-align:left;font:600 10px Arial;letter-spacing:.03em;text-transform:uppercase;color:var(--mbu-accent-text);padding:4px 6px;border-bottom:1px solid var(--mbu-accent)} .tc-tpp-tbl td{padding:2px 6px;border-bottom:1px solid var(--mbu-accent);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:220px} .tc-tpp-tr.notrk{opacity:.5} .tc-tpp-dot{width:14px;padding-left:8px!important}.tc-tpp-dot span{display:inline-block;width:8px;height:8px;border-radius:50%} .tc-tpp-pcell{width:120px} .tc-tpp-ovwrap{position:relative;display:inline-flex;width:112px} .tc-tpp-ov{width:100%;font:11px ui-monospace,Consolas,monospace;border:1px solid var(--mbu-accent);border-radius:4px;padding:2px 16px 2px 5px;color:var(--mbu-accent-text);background:var(--mbu-bg-raised)}.tc-tpp-ov:placeholder-shown{color:var(--mbu-text-weak);border-style:dashed} .tc-tpp-ovclr{position:absolute;right:2px;top:50%;transform:translateY(-50%);display:none;border:none;background:none;cursor:pointer;color:var(--mbu-accent-text);font-size:10px;line-height:1;padding:1px}.tc-tpp-ovclr:hover{color:var(--mbu-error)} .tc-tpp-ovwrap.has .tc-tpp-ovclr{display:block} /* #456: raw stays fully visible/selectable — no ellipsis clipping (you select spans here to bind fields). The td.tc-tpp-raw specificity beats the generic .tc-tpp-tbl td ellipsis rule above. */ .tc-tpp-tbl td.tc-tpp-raw{color:var(--mbu-text-dim);font:11px ui-monospace,Consolas,monospace;max-width:320px;cursor:text;user-select:text;white-space:normal;overflow:visible;text-overflow:clip;overflow-wrap:anywhere} .tc-tpp-empty{color:var(--mbu-text-weak);font-style:italic} .tc-tpp-c{color:var(--mbu-text)} .tc-tpp-ft{display:flex;align-items:center;gap:10px;padding:7px 12px;border-top:1px solid var(--mbu-accent)} .tc-tpp-cnt{flex:1;font:11px Arial;color:var(--mbu-text-dim)}.tc-tpp-cnt.ok{color:var(--mbu-ok)}.tc-tpp-cnt.warn{color:var(--mbu-warn)} .tc-tpp-acts{display:flex;gap:0;position:relative} .tc-tpp-ok{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:4px 0 0 4px;padding:4px 12px}.tc-tpp-ok:hover:not(:disabled){background:var(--mbu-ok-bg)}.tc-tpp-ok:disabled{opacity:.5;cursor:not-allowed} .tc-tpp-menu{cursor:pointer;border:1px solid var(--mbu-ok);border-left:none;background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:0 4px 4px 0;padding:4px 7px}.tc-tpp-menu:hover:not(:disabled){background:var(--mbu-ok-bg)}.tc-tpp-menu:disabled{opacity:.5;cursor:not-allowed} .tc-tpp-mpop{position:absolute;bottom:calc(100% + 4px);right:0;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 22px rgba(40,20,80,.3);padding:4px 0;min-width:200px;z-index:5} .tc-tpp-mi{display:block;width:100%;text-align:left;border:none;background:none;cursor:pointer;font:12px Arial;color:var(--mbu-text);padding:6px 12px}.tc-tpp-mi:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)} .tc-tpp-mhr{height:1px;background:var(--mbu-bg-sunken);margin:4px 0} .tc-colbtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} /* #152/#375: Search & Replace — RE toggle, search caret, invalid-regex flag, Saved & History popup */ .tc-srbtn{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);font:bold 11px Arial;border-radius:4px;padding:3px 8px;white-space:nowrap} .tc-srbtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-srbtn.on{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} .tc-sr-find.tc-sr-bad{border-color:var(--mbu-error)!important;background:var(--mbu-bg-raised)} /* #409 chain mode — the read-only chain chip replaces the search/replace inputs */ .tc-sro-chain .tc-sr-find,.tc-sro-chain .tc-sr-rep,.tc-sro-chain .tc-sr-re{display:none} .tc-sr-chainchip{display:none;align-items:center;gap:6px;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:4px;padding:3px 6px 3px 9px;color:var(--mbu-accent-text);font:bold 12px Arial;white-space:nowrap} .tc-sro-chain .tc-sr-chainchip{display:inline-flex} .tc-sr-chainx{border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;line-height:1;padding:0 2px} .tc-sr-chainx:hover{color:var(--mbu-error)} .tc-sr-star{color:var(--mbu-warn);font-size:13px;line-height:1;padding:3px 8px} /* #375 fav-star button opens Saved & History */ .tc-sr-star:hover{color:var(--mbu-warn);background:var(--mbu-bg-raised);border-color:var(--mbu-warn)} .tc-srtpl{position:fixed;z-index:100003;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:7px;box-shadow:0 8px 26px rgba(40,20,80,.28);font:12px Arial;color:var(--mbu-text);min-width:460px;max-width:680px;max-height:70vh;overflow:auto} .tc-srtpl-hd{display:flex;align-items:center;gap:8px;padding:7px 12px;border-bottom:1px solid var(--mbu-accent);position:sticky;top:0;background:var(--mbu-bg);z-index:1} .tc-srtpl-hdt{font:700 11px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text)} .tc-srtpl-savebtn{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 11px Arial;border-radius:4px;padding:3px 9px;white-space:nowrap} .tc-srtpl-savebtn:hover{background:var(--mbu-ok-bg);border-color:var(--mbu-ok)} .tc-srtpl-savewrap{display:flex;align-items:center;gap:6px;flex:1} .tc-srtpl-saveok{cursor:pointer;border:1px solid var(--mbu-ok);background:var(--mbu-bg-raised);color:var(--mbu-ok);font:bold 12px Arial;border-radius:4px;padding:2px 8px} .tc-srtpl-saveok:hover{background:var(--mbu-ok-bg)} /* #409 redesign: Import/Export button pinned right; JSON textarea; section headers right-aligned */ .tc-srtpl-iobtn{margin-left:auto;cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);font:600 11px Arial;border-radius:4px;padding:3px 10px;white-space:nowrap} .tc-srtpl-iobtn:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-srtpl-impok{margin-left:auto} .tc-srtpl-io{width:calc(100% - 24px);margin:10px 12px;min-height:260px;box-sizing:border-box;border:1px solid var(--mbu-accent);border-radius:4px;padding:8px;font:12px ui-monospace,Consolas,Menlo,monospace;color:var(--mbu-text);resize:vertical} .tc-srtpl-io:focus{outline:none;border-color:var(--mbu-accent)} .tc-srtpl-empty{padding:12px;color:var(--mbu-text-weak);font-style:italic} .tc-srtpl-sec{font:700 10px Arial;letter-spacing:.05em;text-transform:uppercase;color:var(--mbu-accent-text);background:var(--mbu-bg-raised);padding:5px 12px;border-top:1px solid var(--mbu-accent);border-bottom:1px solid var(--mbu-accent);text-align:right} .tc-srtpl-sectog:hover{color:var(--mbu-accent-text);background:var(--mbu-bg-raised)} .tc-srtpl-caret{display:inline-block;color:var(--mbu-accent-text);font-size:9px} .tc-srtpl-row{display:grid;grid-template-columns:1.1fr 1.4fr 1.4fr 24px 82px;align-items:center;gap:8px;padding:5px 12px;cursor:pointer;border-bottom:1px solid var(--mbu-accent)} .tc-srtpl-defrow{background:var(--mbu-bg-raised)} .tc-srtpl-def{visibility:hidden;border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-def,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-def{visibility:visible}.tc-srtpl-def:hover{color:var(--mbu-warn)} .tc-srtpl-def.on{visibility:visible;color:var(--mbu-warn)} .tc-srtpl-row:hover,.tc-srtpl-row.tc-srtpl-sel{background:var(--mbu-bg-hover)} .tc-srtpl-nm{font-weight:600;color:var(--mbu-accent-deep-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-srtpl-f,.tc-srtpl-r{font-family:ui-monospace,Consolas,'Liberation Mono',Menlo,monospace;color:var(--mbu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* #375: curly quotes render cleanly (Courier New mangled them) */ .tc-srtpl-re{font:bold 10px Arial;color:var(--mbu-accent-text);text-align:center} .tc-srtpl-tail{display:inline-flex;gap:5px;align-items:center;justify-content:flex-end} .tc-srtpl-x{visibility:hidden;border:none;background:none;color:var(--mbu-error);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-x,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-x{visibility:visible}.tc-srtpl-x:hover{color:var(--mbu-error)} /* #409 chains */ .tc-srtpl-chainadd{visibility:hidden;border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-chainadd,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-chainadd{visibility:visible}.tc-srtpl-chainadd:hover{color:var(--mbu-accent-text)} .tc-srtpl-rename{visibility:hidden;border:none;background:none;color:var(--mbu-accent-text);cursor:pointer;font-size:12px;padding:0;line-height:1} .tc-srtpl-row:hover .tc-srtpl-rename,.tc-srtpl-row.tc-srtpl-sel .tc-srtpl-rename{visibility:visible}.tc-srtpl-rename:hover{color:var(--mbu-accent-text)} .tc-srtpl-renameinp{width:100%;box-sizing:border-box;border:1px solid var(--mbu-accent);border-radius:3px;padding:1px 5px;font:600 12px Arial;color:var(--mbu-accent-deep-text)} .tc-srtpl-renameinp:focus{outline:none;border-color:var(--mbu-accent)} .tc-srtpl-chnm{color:var(--mbu-accent-text)} .tc-srtpl-chm{font-family:Arial !important;color:var(--mbu-accent-text) !important;font-style:italic} .tc-srtpl-cpick{position:absolute;z-index:5;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 18px rgba(40,20,80,.22);padding:4px;min-width:130px} .tc-srtpl-cpick-row{padding:4px 9px;cursor:pointer;border-radius:4px;color:var(--mbu-accent-deep-text);white-space:nowrap} .tc-srtpl-cpick-row:hover{background:var(--mbu-bg-hover)} .tc-srtpl-cpick-empty{padding:6px 9px;color:var(--mbu-text-weak);font-style:italic;white-space:nowrap} .tc-srtpl-name{flex:1;min-width:120px;box-sizing:border-box;border:1px solid var(--mbu-accent);border-radius:4px;padding:3px 7px;font:13px Arial} .tc-srtpl-name:focus{border-color:var(--mbu-accent);outline:none} .tc-toolopts label,.tc-opt label{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text-dim)} .tc-toolopts input[type=text],.tc-opt input[type=text]{font:12px Arial;padding:2px 5px;border:1px solid var(--mbu-border);border-radius:3px;width:120px} .tc-toolopts input[type=text]::placeholder,.tc-opt input[type=text]::placeholder{color:var(--mbu-text-weak)} .tc-toolopts select,.tc-opt select{font:12px Arial;padding:1px} /* #280 — pinned tools' params on the 2nd toolbar row (scrolls if they overflow) */ .tc-bar2{display:flex;align-items:center;gap:10px;padding:2px 4px 7px;flex-wrap:nowrap;overflow-x:auto;scrollbar-width:thin} .tc-opt{display:inline-flex;align-items:center;gap:7px;background:var(--mbu-bg-raised);border:1px solid var(--mbu-accent);border-radius:7px;padding:3px 9px;flex:none} .tc-optname{font:700 12px Arial;color:var(--mbu-accent-text);display:inline-flex;align-items:center;gap:4px;white-space:nowrap} .tc-opttrig{cursor:pointer;border-radius:5px;padding:2px 5px;border:1px solid transparent} .tc-opttrig:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} .tc-opt .tc-tbic{font-size:13px} /* #280 — right-click a tool name to collapse its params; they flyout on hover/focus */ .tc-opt.tc-collapsed{position:relative} .tc-opt.tc-collapsed > .tc-optname{border-bottom:1px dotted var(--mbu-accent)} /* flyout TOUCHES the name (top:100% over the group's padding-bottom) so the hover area is contiguous — no dead gap that would drop :hover before you reach it */ .tc-opt.tc-collapsed > :not(.tc-optname){display:none;position:absolute;top:100%;left:-1px;z-index:100000;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:0 7px 7px 7px;box-shadow:0 6px 22px rgba(40,20,80,.3);padding:7px 10px} .tc-opt.tc-collapsed:hover > :not(.tc-optname),.tc-opt.tc-collapsed:focus-within > :not(.tc-optname){display:flex} /* The flyout is positioned against the collapsed group, which is only as wide as its icon — so it shrank to that and every label wrapped a word per line ("into / first / ticked:"). Size it to its content instead, capped to the viewport. And a flyout kept open only by :focus-within (a ticked checkbox keeps focus) closes while you hover another collapsed tool — two flyouts overlapped. */ .tc-opt.tc-collapsed > :not(.tc-optname){width:max-content;max-width:min(720px,92vw);white-space:nowrap;flex-wrap:wrap;align-items:center;row-gap:4px} .tc-tools:has(.tc-opt.tc-collapsed:hover) .tc-opt.tc-collapsed:focus-within:not(:hover) > :not(.tc-optname){display:none} /* #280 — Customize tools popover */ .tc-toolcfg{position:fixed;z-index:100002;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:9px;box-shadow:0 10px 30px rgba(40,20,80,.32);padding:8px 0 4px;font:13px Arial;width:340px} .tc-tc-h{font-weight:800;color:var(--mbu-accent-deep-text);padding:3px 14px 8px} .tc-tc-list{max-height:62vh;overflow:auto} .tc-tc-row{display:flex;align-items:center;gap:8px;padding:5px 12px} .tc-tc-row:hover{background:var(--mbu-bg-raised)} .tc-tc-row.off{opacity:.6} .tc-tc-row.over-bottom{box-shadow:inset 0 -2px 0 #8a72c8} .tc-tc-row.over-top{box-shadow:inset 0 2px 0 #8a72c8} .tc-tc-row.drag{opacity:.4} .tc-tc-grab{color:var(--mbu-accent-text);cursor:grab;font-size:15px;line-height:1;flex:none;user-select:none} /* same ⠿ grip as the tracklist drag handle (.tc-drag) */ .tc-tc-grab:hover{color:var(--mbu-accent-text)}.tc-tc-grab:active{cursor:grabbing} .tc-tc-ic{width:26px;text-align:center;color:var(--mbu-accent-text);flex:none} .tc-tc-lab{flex:1;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .tc-tc-onbar{display:inline-flex;flex:none}.tc-tc-onbar input{accent-color:#6f42c1} .tc-tc-dens{display:inline-flex;flex:none;border:1px solid var(--mbu-accent);border-radius:5px;overflow:hidden} .tc-tc-seg{flex:none;width:30px;box-sizing:border-box;border:none;border-left:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);font:bold 12px Arial;height:22px;padding:0;cursor:pointer;display:inline-flex;align-items:center;justify-content:center} /* #280: fixed width → the [icon|T] groups line up across rows */ .tc-tc-seg:first-child{border-left:none} .tc-tc-seg:hover{background:var(--mbu-bg-hover)} .tc-tc-seg.on{background:var(--mbu-accent);color:var(--mbu-text-on-accent)} .tc-tc-seg.on:hover{background:var(--mbu-accent)} .tc-tc-pin{flex:none;box-sizing:border-box;width:22px;text-align:center;border:none;background:none;cursor:pointer;font-size:13px;line-height:1;filter:grayscale(1);opacity:.35;padding:1px 0;display:inline-flex;align-items:center;justify-content:center} /* fixed width so the column aligns even when a tool has no pin */ .tc-tc-pin.on{filter:none;opacity:1} .tc-tc-pin.none{visibility:hidden} .tc-tc-hint{font-size:11px;color:var(--mbu-text-weak);padding:8px 14px 2px;border-top:1px solid var(--mbu-accent);margin-top:5px} .tc-mi-ic{flex:0 0 auto;display:inline-flex;justify-content:center;min-width:18px;text-align:center;color:var(--mbu-accent-text);margin-right:8px;white-space:nowrap} .tc-menu{position:fixed;z-index:100001;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 22px rgba(40,20,80,.3);padding:4px 0;font:13px Arial;min-width:170px} .tc-menu.tc-mini{width:max-content;max-width:260px}.tc-menu.tc-mini .tc-mi{white-space:nowrap} .tc-menu .tc-mi{display:flex;align-items:center;padding:6px 15px;cursor:pointer;color:var(--mbu-text);font-weight:bold}.tc-menu .tc-mi:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-deep-text)} .tc-menu .tc-sep{border-top:1px solid var(--mbu-accent);margin:4px 0} .tc-settings label{display:flex;gap:8px;align-items:center;margin:7px 0;color:var(--mbu-text)}.tc-settings label.opt{font-size:12px} .tc-settings input[type=text],.tc-settings #tc-sr-find,.tc-settings #tc-sr-rep{font:13px Arial;padding:3px 5px;border:1px solid var(--mbu-border);border-radius:3px} .tc-settings .srrow{display:flex;align-items:center;gap:8px;margin-top:8px}.tc-settings .srrow span{flex:1;color:var(--mbu-text-dim);font-size:12px} @keyframes tctitleflash{0%{background:var(--mbu-warn-bg)}100%{background:transparent}} .tc-mirror input.t-title.srflash{animation:tctitleflash 1.8s ease-out} #tc-settings{position:fixed;z-index:100001;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 6px 24px rgba(40,20,80,.3);padding:11px 13px;font:13px Arial;color:var(--mbu-text);width:340px} /* #283 activity-log popup */ /* floating, movable, NON-modal window (no backdrop) */ #tc-settings label{display:flex;gap:8px;align-items:center;margin:7px 0;color:var(--mbu-text)} #tc-settings label input[type=checkbox]{margin:0;flex:none} #tc-settings .hint{color:var(--mbu-text-dim);font-size:11px;margin:0 0 4px 24px} #tc-settings .tc-s-sec{font-weight:bold;color:var(--mbu-text);margin:12px 0 5px} #tc-settings .tc-s-group{padding-left:8px} #tc-settings .tc-s-top{padding-left:0;margin-top:2px} #tc-settings .tc-s-sub{font-weight:bold;color:var(--mbu-text);margin:0} #tc-settings div.tc-s-sub{margin:8px 0 3px} #tc-settings .tc-s-row{display:flex;align-items:center;gap:12px;margin:7px 0;color:var(--mbu-text)} #tc-settings .tc-s-rad{display:inline-flex;align-items:center;gap:4px;margin:0;font-weight:normal;cursor:pointer} #tc-settings .tc-s-row input[type=radio]{margin:0} #tc-settings #tc-s-lentol,#tc-settings #tc-s-titletol,#tc-settings #tc-s-punctsize{width:48px;font:13px Arial;padding:2px 5px;border:1px solid var(--mbu-border);border-radius:3px} #tc-settings #tc-s-hlcolor{width:34px;height:22px;padding:0;border:1px solid var(--mbu-border);border-radius:3px;cursor:pointer;background:none} /* #294 tabbed config: one short pane at a time instead of one tall column */ #tc-settings .tc-tabs{display:flex;gap:2px;margin:0 0 8px;border-bottom:1px solid var(--mbu-accent)} #tc-settings .tc-tab-btn{flex:1;font:600 12px Arial;color:var(--mbu-text-weak);background:none;border:none;border-bottom:2px solid transparent;margin-bottom:-1px;padding:5px 4px 7px;cursor:pointer} #tc-settings .tc-tab-btn:hover{color:var(--mbu-accent-text)} #tc-settings .tc-tab-btn.active{color:var(--mbu-accent-text);border-bottom-color:var(--mbu-accent)} #tc-settings .tc-tab-pane{display:flow-root} /* #407: contain child margins so the pinned height captures the full box (constant dialog height across tabs) */ #tc-settings .tc-tab-pane[hidden]{display:none} #tc-settings .tc-tab-pane .tc-s-top{margin-top:0} #tc-settings .tc-s-row.lentol{gap:7px} #tc-launch{position:fixed;bottom:14px;right:14px;z-index:var(--mbu-z-pop);display:inline-flex;align-items:stretch;background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-radius:20px;font:bold 13px Arial;box-shadow:0 3px 12px rgba(40,20,80,.3);overflow:hidden} #tc-launch .tc-launch-lbl{padding:8px 13px;cursor:pointer} #tc-launch .tc-launch-lbl:hover{background:rgba(255,255,255,.13)} #tc-launch .tc-launch-gear{padding:8px 11px;cursor:pointer;font-size:14px;display:flex;align-items:center;border-left:1px solid rgba(255,255,255,.28)} #tc-launch .tc-launch-gear:hover{background:rgba(255,255,255,.13)} #tc-btn,#tc-gear-btn{vertical-align:middle} /* ────────────────────────────────────────────────────────────────── MOBILE / NARROW VIEWPORTS — Tracklist MusicBrainz serves width=device-width, so the fixed-layout mirror (Title 360px + #/len/badge ≈ 540px of fixed columns) overflows the viewport and collapses the flexible Artist column to a few pixels — the artist search box becomes unusable. Below ~700px each track becomes a full-width card: #/title/length/match on the top line and the artist credit(s) spanning the FULL width beneath. Hover-only controls are revealed (touch screens have no hover). 820px covers phones and small tablets (the artist column only gets comfortable above ~1000px desktop width). ────────────────────────────────────────────────────────────────── */ @media (max-width: 820px) { /* tools bar: wrap so "⚡ Match" never gets pushed off the edge */ #tc-bar{flex-wrap:wrap;gap:6px 8px} #tc-bar > .sp{flex-basis:100%;height:0} /* the spacer forces the Match cluster onto its own line */ .tc-am-lbl{white-space:normal} .tc-tablewrap{overflow-x:visible} .tc-mirror{display:block} .tc-mirror > colgroup,.tc-mirror > thead{display:none} .tc-mirror > tbody{display:block} .tc-mirror > tbody > tr{display:grid;grid-template-columns:20px 34px 1fr 46px 30px 44px; align-items:center;column-gap:6px;row-gap:3px;padding:7px 8px;background:transparent} .tc-mirror.gridrows > tbody > tr{border-bottom:1px solid var(--mbu-border)} .tc-mirror > tbody > tr > td{display:block;padding:0;border:none!important;background:transparent;overflow:visible} .tc-mirror td.c-mv{grid-column:1;grid-row:1} .tc-mirror td.c-num{grid-column:2;grid-row:1} .tc-mirror td.c-title{grid-column:3;grid-row:1} .tc-mirror td.c-len{grid-column:4;grid-row:1} .tc-mirror td.c-badge{grid-column:5;grid-row:1} .tc-mirror td.c-act{grid-column:6;grid-row:1} .tc-mirror td.c-art{grid-column:1 / -1;grid-row:2;width:auto} .tc-mirror input.t-len{width:100%;text-align:right} /* the medium header keeps its own full-width line */ .tc-mirror > tbody > tr:has(.tc-medhdr){display:block;padding:0} .tc-mirror td.tc-medhdr{display:block} /* zebra + changed-marker move to the row (cells are transparent now) */ .tc-mirror.alt > tbody > tr:nth-child(even){background:var(--mbu-bg-raised)} .tc-mirror > tbody > tr.tc-changed{box-shadow:inset 4px 0 0 var(--mbu-accent-text)} .tc-mirror > tbody > tr.tc-changed td:first-child{box-shadow:none} /* trim the fixed credited-as + actions so the search box gets the width */ .tc-cred{width:84px} .tc-acts{width:auto;gap:3px;padding-left:3px} /* touch = no hover: reveal the per-row / per-artist controls permanently */ .tc-mirror tr .t-gc,.tc-mirror tr .t-feat, .tc-enter,.tc-splitb,.tc-slotgrab,.tc-slotx,.tc-trackacts{visibility:visible} .tc-aslot:not(.tc-can-split) .tc-splitb{display:none} } `; function style() { if (document.getElementById('tc-css')) return; const s = document.createElement('style'); s.id = 'tc-css'; s.textContent = css; document.head.appendChild(s); } /* ── settings popover (view options) ── */ function applyViewClasses() { const layout = SETTINGS.layout || 'normal'; document.querySelectorAll('.tc-mirror, .tc-rectbl').forEach(t => { // both tables share the layout/alt/grid options t.classList.toggle('alt', !!SETTINGS.altRows); t.classList.toggle('gridcols', !!SETTINGS.gridCols); t.classList.toggle('gridrows', SETTINGS.gridRows !== false); t.classList.remove('compact', 'cozy', 'normal'); t.classList.add(layout); }); } function openSettings(anchor) { style(); let s = document.getElementById('tc-settings'); if (s) { s.remove(); return; } s = document.createElement('div'); s.id = 'tc-settings'; s.innerHTML = `${mbuCfgHeader({ script: 'apollo_editor', name: 'Apollo Editor', version: scriptVersion(), icon: ICON, log: true, logClass: 'tc-logbtn' })}
`; document.body.appendChild(s); const r = anchor ? anchor.getBoundingClientRect() : { left: 60, right: 60, bottom: 80 }; // keep it fully on-screen — right-align to the gear if it would overflow (uses the real width), and // clamp/scroll vertically so a tall dialog never runs off the bottom (#119). Re-run on each tab // switch since panes differ in height. const maxH = window.innerHeight - 16; s.style.maxHeight = maxH + 'px'; s.style.overflowY = 'auto'; const place = () => { s.style.left = Math.max(8, Math.min(r.right - s.offsetWidth, window.innerWidth - s.offsetWidth - 10)) + 'px'; const h = Math.min(s.offsetHeight, maxH); let top = r.bottom + 6; if (top + h > window.innerHeight - 8) top = Math.max(8, window.innerHeight - h - 8); s.style.top = top + 'px'; }; // tabs: show one pane at a time so the panel stays short (#294) const tabBtns = s.querySelectorAll('.tc-tab-btn'), panes = s.querySelectorAll('.tc-tab-pane'); // #407: pin every pane to the TALLEST pane's height so the dialog keeps a constant // height as you switch tabs (it used to grow/shrink, which was jumpy). Show all panes // at once and take the max — run in a rAF so the read happens after layout has settled // (a synchronous read right after append under-measured the tallest pane by a few px). const pinPaneHeight = () => { const prev = [...panes].map(p => p.hidden); panes.forEach(p => { p.style.minHeight = ''; p.hidden = false; }); // reveal all, unpinned let max = 0; panes.forEach(p => { if (p.offsetHeight > max) max = p.offsetHeight; }); panes.forEach((p, i) => { p.hidden = prev[i]; p.style.minHeight = max + 'px'; }); place(); // re-anchor now the constant height is known }; const showTab = name => { _cfgTab = name; tabBtns.forEach(b => b.classList.toggle('active', b.dataset.tab === name)); panes.forEach(p => { p.hidden = p.dataset.pane !== name; }); place(); }; tabBtns.forEach(b => b.onclick = () => showTab(b.dataset.tab)); showTab(_cfgTab); requestAnimationFrame(pinPaneHeight); const am = s.querySelector('#tc-s-automatch'), amRec = s.querySelector('#tc-s-automatchrec'), amLbl = s.querySelector('#tc-s-automatchlabel'), amArt = s.querySelector('#tc-s-automatchartist'), alt = s.querySelector('#tc-s-alt'), gridcols = s.querySelector('#tc-s-gridcols'), gridrows = s.querySelector('#tc-s-gridrows'); am.checked = SETTINGS.autoMatch !== false; amRec.checked = !!SETTINGS.autoMatchRec; amLbl.checked = SETTINGS.autoMatchLabel !== false; amArt.checked = SETTINGS.autoMatchArtist !== false; alt.checked = !!SETTINGS.altRows; gridcols.checked = !!SETTINGS.gridCols; gridrows.checked = SETTINGS.gridRows !== false; const curLayout = SETTINGS.layout || 'normal'; s.querySelectorAll('input[name="tc-s-layout"]').forEach(rb => { rb.checked = rb.value === curLayout; rb.onchange = () => { if (rb.checked) { SETTINGS.layout = rb.value; saveSettings(); applyViewClasses(); } }; }); am.onchange = () => { SETTINGS.autoMatch = am.checked; saveSettings(); }; amRec.onchange = () => { SETTINGS.autoMatchRec = amRec.checked; saveSettings(); }; amLbl.onchange = () => { SETTINGS.autoMatchLabel = amLbl.checked; saveSettings(); }; amArt.onchange = () => { SETTINGS.autoMatchArtist = amArt.checked; saveSettings(); }; const dmatch = s.querySelector('#tc-s-discogsmatch'); if (dmatch) { dmatch.checked = SETTINGS.discogsUrlMatch !== false; dmatch.onchange = () => { SETTINGS.discogsUrlMatch = dmatch.checked; saveSettings(); }; } const lentol = s.querySelector('#tc-s-lentol'), titletol = s.querySelector('#tc-s-titletol'), igc = s.querySelector('#tc-s-ignorecase'), igp = s.querySelector('#tc-s-ignorepunct'); lentol.value = SETTINGS.recLenTol != null ? SETTINGS.recLenTol : 5; titletol.value = SETTINGS.recTitleTol || 0; igc.checked = SETTINGS.recIgnoreCase !== false; igp.checked = !!SETTINGS.recIgnorePunct; const refreshRec = () => { try { if (document.getElementById('tc-recwrap')) rerenderRec(); } catch (e) {} }; // live-update the table lentol.onchange = () => { const v = Math.max(0, Math.min(60, parseInt(lentol.value, 10) || 0)); SETTINGS.recLenTol = v; lentol.value = v; saveSettings(); refreshRec(); }; titletol.onchange = () => { const v = Math.max(0, Math.min(20, parseInt(titletol.value, 10) || 0)); SETTINGS.recTitleTol = v; titletol.value = v; saveSettings(); refreshRec(); }; igc.onchange = () => { SETTINGS.recIgnoreCase = igc.checked; saveSettings(); refreshRec(); }; igp.onchange = () => { SETTINGS.recIgnorePunct = igp.checked; saveSettings(); refreshRec(); }; const detailhl = s.querySelector('#tc-s-detailhl'); if (detailhl) { detailhl.checked = !!SETTINGS.recDetailedHl; detailhl.onchange = () => { SETTINGS.recDetailedHl = detailhl.checked; saveSettings(); refreshRec(); }; } const hlcol = s.querySelector('#tc-s-hlcolor'); if (hlcol) { hlcol.value = SETTINGS.recHlColor || '#e53935'; hlcol.oninput = () => { SETTINGS.recHlColor = hlcol.value; applyHlColor(); }; hlcol.onchange = () => { SETTINGS.recHlColor = hlcol.value; applyHlColor(); saveSettings(); refreshRec(); }; } const punctsz = s.querySelector('#tc-s-punctsize'); if (punctsz) { punctsz.value = SETTINGS.recPunctSize != null ? SETTINGS.recPunctSize : 3; punctsz.onchange = () => { const v = Math.max(0, Math.min(12, parseInt(punctsz.value, 10) || 0)); SETTINGS.recPunctSize = v; punctsz.value = v; saveSettings(); refreshRec(); }; } alt.onchange = () => { SETTINGS.altRows = alt.checked; saveSettings(); applyViewClasses(); }; const hoverhl = s.querySelector('#tc-s-hoverhl'); if (hoverhl) { hoverhl.checked = !!SETTINGS.hoverHighlight; hoverhl.onchange = () => { SETTINGS.hoverHighlight = hoverhl.checked; saveSettings(); if (!hoverhl.checked) { _hlCur = null; document.querySelectorAll('.tc-aslot.tc-arthl').forEach(e => e.classList.remove('tc-arthl')); } }; } gridcols.onchange = () => { SETTINGS.gridCols = gridcols.checked; saveSettings(); applyViewClasses(); }; gridrows.onchange = () => { SETTINGS.gridRows = gridrows.checked; saveSettings(); applyViewClasses(); }; const replri = s.querySelector('#tc-s-replri'), repltl = s.querySelector('#tc-s-repltl'), replrec = s.querySelector('#tc-s-replrec'), modanno = s.querySelector('#tc-s-modanno'), cnav = s.querySelector('#tc-s-compactnav'), zen = s.querySelector('#tc-s-zen'), moddupes = s.querySelector('#tc-s-moddupes'); replri.checked = SETTINGS.replaceReleaseInfo !== false; repltl.checked = SETTINGS.replaceTracklist !== false; replrec.checked = SETTINGS.replaceRecordings !== false; modanno.checked = SETTINGS.modifyAnnotation !== false; cnav.checked = SETTINGS.compactNav !== false; zen.checked = !!SETTINGS.zenMode; if (moddupes) { moddupes.checked = !!SETTINGS.modifyDuplicates; moddupes.onchange = () => { SETTINGS.modifyDuplicates = moddupes.checked; saveSettings(); applyDuplicates(); }; } replri.onchange = () => { SETTINGS.replaceReleaseInfo = replri.checked; saveSettings(); applyView(); }; repltl.onchange = () => { SETTINGS.replaceTracklist = repltl.checked; saveSettings(); applyView(); }; replrec.onchange = () => { SETTINGS.replaceRecordings = replrec.checked; saveSettings(); applyView(); }; modanno.onchange = () => { SETTINGS.modifyAnnotation = modanno.checked; saveSettings(); applyView(); applyAnnotationPage(); }; cnav.onchange = () => { SETTINGS.compactNav = cnav.checked; saveSettings(); applyNav(); applyZen(); }; zen.onchange = () => { SETTINGS.zenMode = zen.checked; saveSettings(); applyZen(); }; const aconf = s.querySelector('#tc-s-autoconfirm'); if (aconf) { aconf.checked = SETTINGS.autoConfirmSeed !== false; aconf.onchange = () => { SETTINGS.autoConfirmSeed = aconf.checked; saveSettings(); }; } const kcaret = s.querySelector('#tc-s-keepcaret'); if (kcaret) { kcaret.checked = SETTINGS.keepCaretColumn !== false; kcaret.onchange = () => { SETTINGS.keepCaretColumn = kcaret.checked; saveSettings(); }; } // #279 const off = e => { if (!s.contains(e.target) && e.target !== anchor) { s.remove(); document.removeEventListener('mousedown', off); } }; const lbtn = s.querySelector('.tc-logbtn'); if (lbtn) lbtn.onclick = () => { s.remove(); document.removeEventListener('mousedown', off); openLog(); }; setTimeout(() => document.addEventListener('mousedown', off), 0); } /* ── the one shared table ── */ let MODEL = null; let ACTIVE = {}; // { mode, tbody, statusEl } // transient message (e.g. "matching d/n") shown in every table's Artist header const updateStatus = t => { document.querySelectorAll('.tc-medsec .tc-hstatus, #tc-panel .tc-hstatus, .tc-globalstat').forEach(e => { e.textContent = t; e.classList.remove('tc-unres'); }); }; // scroll to + focus the first unresolved artist search box (the white, non-matched one) function focusFirstUnresolved() { const box = document.querySelector('.tc-mirror .tc-search:not(.matched)'); if (!box) return; box.scrollIntoView({ block: 'center', behavior: 'smooth' }); (box.querySelector('input.nm') || box).focus(); } // the always-visible total in the toolbar (left of Match) — shows the release-wide unresolved count / progress; // when there are unresolved artists the badge is clickable and jumps to the first one const setGlobalStat = n => { document.querySelectorAll('.tc-globalstat').forEach(e => { e.textContent = n ? statusText(n) : ''; e.classList.toggle('tc-unres', n > 0); e.onclick = n > 0 ? focusFirstUnresolved : null; e.style.cursor = n > 0 ? 'pointer' : ''; e.title = n > 0 ? 'jump to the first unresolved artist' : ''; }); }; // #227: persistent missing-Discogs-links badge. Stays until every link is // added; each click steps to the NEXT track missing one and focuses its credit. let _discNavIdx = -1; // an artist whose Discogs link needs attention: missing (addable) OR mismatched // (links a different Discogs page than the release credits). #227 const discNeedsAttention = s => !!s._discogsAddable; // mismatch is now addable too (offer the release's link, flagged ⚠) // The tooltip text for an addable slot's link/⚠ icon — shared so the log shows // the exact same message (a mismatch/conflict reads differently from a plain add). function discAddTooltip(s) { const mism = s._discogsMismatch, conf = s._discogsConflict; return mism ? `Discogs mismatch: this artist links ${mism}, the release credits ${s._discogsUrl} — click to add the release's link anyway` : conf ? `Discogs links a different MB artist: ${conf.name} — click to add it to ${s.name || 'this artist'} anyway` : (s.gid ? 'Add the Discogs link to this artist' : 'Create this artist with its Discogs link'); } function focusNextMissingDiscogs() { const list = []; MODEL.tracks.forEach(t => t.slots.forEach((s, i) => { if (discNeedsAttention(s)) list.push([t, i]); })); if (!list.length) { _discNavIdx = -1; return; } _discNavIdx = (_discNavIdx + 1) % list.length; const [t, i] = list[_discNavIdx]; const row = rowEl(t.mi, t.ti); if (!row) return; row.scrollIntoView({ block: 'center', behavior: 'smooth' }); const creds = row.querySelectorAll('.tc-cred'); (creds[i] || row).focus(); } const setDiscStat = () => { // #281: Discogs API unreachable → an amber, clickable "retry" badge instead of a // blank that reads as "nothing to do". (It also auto-retries once on its own.) if (_discMapFailed) { document.querySelectorAll('.tc-discstat').forEach(e => { e.classList.remove('tc-disc-badge', 'tc-disc-ok'); e.classList.add('tc-disc-pend'); e.textContent = '🔗 Discogs ⟳'; e.style.cursor = 'pointer'; e.title = 'Couldn’t reach Discogs (rate-limited) — click to retry'; e.onclick = () => tagDiscogsForAll(); }); return; } let miss = 0, mism = 0, pend = 0, checked = false; if (MODEL) MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s._discogsChecked) checked = true; // #281: a RESOLVED slot was actually checked (unresolved slots don't count) if (s._discogsMismatch) mism++; else if (s._discogsAddable) miss++; else if (s._discogsPending) pend++; })); const n = miss + mism; document.querySelectorAll('.tc-discstat').forEach(e => { e.classList.remove('tc-disc-badge', 'tc-disc-ok', 'tc-disc-pend'); e.onclick = null; e.style.cursor = ''; e.title = ''; if (n) { // links to add / mismatches → teal action badge, click steps to the next e.textContent = `🔗 ${n} link${n === 1 ? '' : 's'}` + (pend ? ` · ${pend}?` : ''); e.classList.add('tc-disc-badge'); e.onclick = focusNextMissingDiscogs; e.style.cursor = 'pointer'; e.title = `${miss} missing, ${mism} mismatched Discogs link${n === 1 ? '' : 's'}` + (pend ? `, ${pend} unchecked` : '') + ' — click to step to the next'; } else if (pend) { // every job ran but some lookups couldn't complete (rate-limited / cold) → // say so honestly and let a click retry, rather than reading as "all OK" e.textContent = `🔗 ${pend} unchecked`; e.classList.add('tc-disc-pend'); e.onclick = () => tagDiscogsForAll(); e.style.cursor = 'pointer'; e.title = `${pend} Discogs link${pend === 1 ? '' : 's'} couldn't be checked (rate-limited) — click to retry`; } else if (checked) { // the check ran and every Discogs artist credit is already linked in MB — // a positive confirmation so a blank badge no longer means "did it run?" e.textContent = '✓ Discogs links OK'; e.classList.add('tc-disc-ok'); e.title = 'every Discogs artist credit on this release is already linked in MusicBrainz'; } else { e.textContent = ''; // no Discogs link on the release / not checked yet } }); }; // transient progress text in the same slot, while the check runs (not a pill) const setDiscProgress = (t) => document.querySelectorAll('.tc-discstat').forEach(e => { e.textContent = t || ''; e.classList.remove('tc-disc-badge', 'tc-disc-ok', 'tc-disc-pend'); e.onclick = null; e.style.cursor = ''; e.title = ''; }); // transient action feedback (a pick propagated, S&R count, …) — lives in the toolbar so it never // overwrites a medium's unresolved badge; auto-clears // majkinetor: "it doesn't seem like a good idea to show messages in the toolbar as it moves // buttons around" — the old .tc-toast span sat in the toolbar's flex row and took width from // the tool buttons (they wrapped). One-off results now go to the shared floating toast // (mbuToast, #563: bottom centre, fades, same in every script); the toolbar keeps only // fixed status (links badge, match state, unresolved count). Colour follows the text: // failures red, refusals/“try again” amber, ✓ green (mbuToast's own inference), else neutral. const TOAST_ERROR = /\bfailed\b|invalid json/i; const TOAST_WARN = /^(merge|split):|couldn[’']t|unavailable|did not load|disc id|no durations|already used|is empty|needs a |type a search|empty chain/i; // #281's "added Discogs link to X" note used its own toolbar span next to the links badge — the // same layout push as the old toast, so it goes to the floating toast too (green). const discMsg = msg => toast(msg ? '✓ ' + msg : '', 'ok'); const toast = (msg, kind) => { if (!msg) return; const k = kind || (TOAST_ERROR.test(msg) ? 'error' : TOAST_WARN.test(msg) ? 'warn' : null); mbuToast(msg, k ? { kind: k, ms: 4000 } : { ms: 4000 }); }; // #281: "added Discogs link to X" feedback sits right next to the "N links" badge // (not in the centre toast), so it reads together with the count it just changed. const unresolvedIn = mi => { let n = 0; MODEL.tracks.forEach(t => { if (mi != null && t.mi !== mi) return; t.slots.forEach(s => { if (!(s.status === 'set' || s.committed)) n++; }); }); return n; }; const statusText = n => (n ? `⚠ ${n} unresolved!` : 'all matched'); const setStatusSpan = (span, n) => { if (!span) return; span.textContent = statusText(n); span.classList.toggle('tc-unres', n > 0); }; // disable the Match button while a match pass is running // #577 (majkinetor): "Tracklist and recording matching can't be stopped, // unlike all others (GT work matching etc.)". One flag shared by both loops: // whichever is running checks it between items and returns early. // // Cooperative rather than an AbortController, because Apollo's requests are // paced and coalesced in one place (wsGet) and several callers can be sharing // a single flight — aborting it would cancel someone else's request too. The // cost is that a stop lands after the request in flight comes back rather than // instantly; the win is that it cannot cancel work that another caller still // wants. The UI flips the moment it is clicked, so the wait isn't felt as // "nothing happened". // // Stopping is not undoing: every artist and recording matched before the stop // stays matched, and the leftovers stay unmatched for a later pass — the same // bargain Group Therapy's work matcher makes. let _matchStop = false; /* #575 round 4 (majkinetor): "both Matches can be 100% unresponsive to clicks, especially Recordings one". The main thread turned out to be fine — a rAF round trip stayed at 0ms through a whole 81s pass on his own release — so nothing was frozen. What was missing was any ANSWER to the press: the loops only notice the flag between items, and one item can be a multi-second search or a shared throttle hold, so the button sat there still reading "Stop" and the click read as ignored. Both buttons now say so immediately. The pass still finishes the request in flight before it exits — cancelling it would cancel a flight other callers may be sharing — but that wait is now visible rather than silent. */ const stopMatching = () => { if (_matchStop) return; _matchStop = true; // Driven off each pass's own running flag, not off what its button happens // to say. The label is not reliable — a Recordings shell rebuilt mid-pass // reads "Match" — and the flag is shared, so stopping the tracklist must not // relabel an idle Recordings button either. // Deliberately not disabled: setMatching / setBusy own that, and a button // disabled here would stay dead if its pass had already ended. const ack = (b, live) => { if (!b || !live) return; (b.querySelector('.tc-rec-am-lbl') || b).textContent = '⏳ Stopping…'; b.title = 'Stopping once the request in flight comes back — everything matched so far is kept'; }; ack(document.querySelector('#tc-bar [data-act="match"], #tc-hdr [data-act="match"]'), _matching); ack(document.querySelector('#tc-recwrap .tc-rec-am'), _autoMatching); }; let _matching = false; // The Match button BECOMES the Stop button for the duration. It used to just // go disabled, which is what made an eight-minute pass feel unstoppable, and // reusing it costs no room in a toolbar that already collapses to icons. function setMatching(on) { _matching = on; // #575: take the status line the moment the pass starts. The first progress // write only lands when track one finishes, which on a slow day is several // seconds — until then the idle "auto-match off — click Match" was still // sitting there, reading as if the press had done nothing. if (on) updateStatus('matching…'); const b = document.querySelector('#tc-bar [data-act="match"], #tc-hdr [data-act="match"]'); if (!b) return; b.disabled = false; b.classList.toggle('tc-stopping', on); b.textContent = on ? '■ Stop' : '⚡ Match'; b.title = on ? 'Stop matching — what has already been matched is kept' : 'search MusicBrainz for the unmatched artists'; } // re-fill every active tbody (per-medium sections in mirror mode, or the single panel table) const rerender = () => { _hlCur = null; renderRiArtist(true); if (ACTIVE.sections) ACTIVE.sections.forEach(s => fillRows(s.tbody, s.mi)); else if (ACTIVE.tbody) fillRows(ACTIVE.tbody); refreshStatus(); }; // our rendered row for a track, wherever it lives (a per-medium section or the floating panel) const rowEl = (mi, ti) => document.querySelector(`.tc-medsec tr[data-tk="${mi}:${ti}"], #tc-panel tr[data-tk="${mi}:${ti}"]`); // ↑/↓ : move to the same field in the prev/next ROW — but for the per-artist fields (search box, // credited-as) walk EVERY line in document order, so multi-artist tracks and media boundaries are // all included. Returns true if it moved. function focusSameField(inp, dir) { const sel = inp.classList.contains('t-num') ? '.t-num' : inp.classList.contains('t-title') ? '.t-title' : inp.classList.contains('t-len') ? '.t-len' : inp.classList.contains('tc-cred') ? '.tc-cred' : inp.classList.contains('nm') ? '.tc-search input.nm' : null; if (!sel) return false; const scope = inp.closest('#tc-panel') ? '#tc-panel' : '.tc-medsec'; const all = [...document.querySelectorAll(`${scope} ${sel}`)]; // flat list across all rows/sections/artist lines const cur = all.indexOf(inp); if (cur < 0) return false; const dest = all[cur + dir]; if (!dest) return false; // remember the destination by row + its slot index within that row (survives a commit-rebuild) const destRow = dest.closest('tr[data-tk]'); const destTk = destRow ? destRow.dataset.tk : null; const destIdx = destRow ? [...destRow.querySelectorAll(sel)].indexOf(dest) : 0; const destPos = cur + dir; // #279: carry the caret COLUMN across the move instead of selecting the whole // field (jesus2099's keyboard-select behaviour) — so you keep typing / fix // casing at the same spot rather than overwriting. Clamped to the destination's // length, so a shorter field just drops the caret at its end. const caret = (inp.selectionStart != null) ? inp.selectionStart : (inp.value || '').length; inp.blur(); // committing the current field on blur can rebuild the rows — focus AFTER, from the fresh DOM const go = () => { let t = null; if (destTk) { const d = document.querySelector(`${scope} tr[data-tk="${destTk}"]`); if (d) { const xs = [...d.querySelectorAll(sel)]; t = xs[Math.min(destIdx, xs.length - 1)]; } } if (!t) t = [...document.querySelectorAll(`${scope} ${sel}`)][destPos]; if (t && document.activeElement !== t) { t.focus(); // #279: keep the caret column by default; the toggle restores select-all-on-arrive. if (SETTINGS.keepCaretColumn !== false) { if (typeof t.setSelectionRange === 'function') { const p = Math.min(caret, (t.value || '').length); try { t.setSelectionRange(p, p); } catch (e) {} } } else if (t.select && !t.classList.contains('nm')) { t.select(); } } }; go(); setTimeout(go, 0); return true; } // ↓/Enter → next field, ↑/Shift+Enter → prev. NOT wired on the artist search box (Enter picks there). function wireRowNav(inp) { inp.addEventListener('keydown', e => { if (e.key === 'ArrowDown') { if (focusSameField(inp, 1)) e.preventDefault(); } else if (e.key === 'ArrowUp') { if (focusSameField(inp, -1)) e.preventDefault(); } else if (e.key === 'Enter') { e.preventDefault(); e.stopPropagation(); if (!focusSameField(inp, e.shiftKey ? -1 : 1)) inp.blur(); } // move on; blur (commit) if at the edge }); } // show each medium's OWN unresolved count in its header (or the global count for the floating panel) function refreshStatus() { if (!MODEL || _matching) return; // while a pass runs the headers show "matching d/n" — don't flicker the badge if (ACTIVE.sections) ACTIVE.sections.forEach(s => setStatusSpan(s.sec.querySelector('.tc-hstatus'), unresolvedIn(s.mi))); else document.querySelectorAll('#tc-panel .tc-hstatus').forEach(span => setStatusSpan(span, unresolvedIn(null))); setGlobalStat(unresolvedIn(null)); // release-wide total in the toolbar if (!_tagDiscogsRunning) setDiscStat(); // #227: keep the missing-links badge in sync (the check owns it while running) } function buildTable() { const t = document.createElement('table'); t.className = 'tc-mirror' + (SETTINGS.altRows ? ' alt' : '') + (SETTINGS.gridCols ? ' gridcols' : '') + (SETTINGS.gridRows !== false ? ' gridrows' : '') + ' ' + (SETTINGS.layout || 'normal'); // the Artist column is the flexible filler (no fixed width) — it absorbs the slack so every OTHER // column keeps its EXACT width (table-layout:fixed) and resizes 1:1 with the mouse (no jump) t.innerHTML = `${COLS.map(c => c.k === 'art' ? '' : ``).join('')}` + `${COLS.map(c => `${c.label}${c.k === 'art' || c.k === 'act' || c.k === 'badge' ? '' : ''}`).join('')}`; return t; } // the artist-selection-mode dropdown now lives in the Artist column header (right-aligned) const AM_SELECT = ``; // wire the apply-mode combo (now lives in the toolbar, was in the Artist header) function wireApplyMode(root) { const am = (root || document).querySelector('.tc-applymode'); if (!am) return; am.value = SETTINGS.applyMode || 'all'; am.onchange = () => { SETTINGS.applyMode = am.value; saveSettings(); document.querySelectorAll('.tc-applymode').forEach(s => { s.value = am.value; }); Log.info('applyMode =', am.value); }; } // #330: per-medium "Pregap" / "Data track" checkboxes that drive MB's writable medium // observables hasPregap()/hasDataTracks() (the same thing native MB's buttons do), then // rebuild so the new/removed track shows. Returns a or null (locked / no medium). // Deep data-section editing (its own Add-track, bounded reorder) intentionally stays native. function mediumOpts(target) { if (target == null) return null; const med = mediums()[target]; if (!med) return null; // #330: show the toggles on EVERY medium and keep them ENABLED even on a disc-ID medium. // Pregap/data tracks live outside the audio TOC (the same reason their lengths stay editable, // #329), and native MB leaves these controls enabled there too. const opts = document.createElement('span'); opts.className = 'tc-medopts'; const mkOpt = (label, prop, help) => { const lbl = document.createElement('label'); lbl.className = 'tc-medopt'; lbl.title = help; const cb = document.createElement('input'); cb.type = 'checkbox'; try { cb.checked = typeof med[prop] === 'function' && !!med[prop](); } catch (e) {} cb.onchange = () => { _selfEdit = true; try { med[prop](cb.checked); } catch (e) { Log.warn(`${label} toggle failed`, e.message); } finally { _selfEdit = false; } Log.info(`medium ${target + 1}: ${label} ${cb.checked ? 'added' : 'removed'}`); MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); }; lbl.appendChild(cb); lbl.appendChild(document.createTextNode(' ' + label)); return lbl; }; opts.appendChild(mkOpt('Pregap', 'hasPregap', 'Add / remove a hidden pregap track (position 0)')); opts.appendChild(mkOpt('Data track', 'hasDataTracks', 'Add / remove a trailing data track (for several, use the native editor)')); return opts; } /* ── #586 — move tracks across the data-track boundary ──────────────────── majkinetor: "The original tracklist editor allows moving tracks between normal and data track section. In Apollo there are no move buttons but drag-n-drop which doesn't allow the same." (His example: the last three video tracks of 55530bc0 should be data tracks.) How native does it, read off MB's own handlers: moveTrackDown(t): if the track BELOW is already data and this one isn't, set isDataTrack(true); otherwise swap the two. moveTrackUp(t): if this is the FIRST data track, set isDataTrack(false); otherwise swap. So native can only ever move the boundary one track at a time, and only from the inside out — three video tracks means opening the section and then clicking down repeatedly. A data section is by definition a TRAILING block, which means the whole state is just a boundary index, so Apollo moves the boundary in one go instead: ⤓ on an audio track → it and everything below it become data tracks ⤒ on a data track → it and every data track above it become audio Both keep the block trailing and contiguous by construction. isDataTrack is a plain writable observable and medium.audioTracks / dataTracks / hasDataTracks all recompute off it (verified live in test/probe-586e-datasection.mjs), so setting the flags is enough — no array surgery. medium.toc(null) is cleared exactly as native does, since the audio track count just changed. Disc-ID media are excluded, matching native's disableBecauseDiscIDs on both buttons: crossing the boundary either way would contradict the TOC. */ function setDataBoundary(mi, ti, toData) { const med = mediums()[mi]; if (!med) return; if (mediumLocked(mi)) { Log.warn('medium ' + (mi + 1) + ': the data-track boundary is fixed by the Disc ID'); return; } const tracks = u(med.tracks) || []; let n = 0; _selfEdit = true; try { tracks.forEach((t, i) => { if (typeof t.isDataTrack !== 'function') return; const is = !!t.isDataTrack(); // down: this track and everything after it · up: this track and every data track before it const want = toData ? (i >= ti ? true : is) : (i <= ti ? false : is); if (want !== is) { t.isDataTrack(want); n++; } }); if (typeof med.toc === 'function') med.toc(null); // native clears the TOC on every boundary move } catch (e) { Log.warn('data-track move failed', e.message); } finally { _selfEdit = false; } Log.info('medium ' + (mi + 1) + ': moved ' + n + ' track' + (n === 1 ? '' : 's') + (toData ? ' into the data-track section (from #' + (ti + 1) + ' down)' : ' back into the audio section (through #' + (ti + 1) + ')')); MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); } // one Apollo table for a single medium (its own header row + Add footer); returns the tbody. // mi == null renders the whole release into one table (the floating panel). function mountTable(container, mi) { container.innerHTML = ''; const wrap = document.createElement('div'); wrap.className = 'tc-tablewrap'; container.appendChild(wrap); const table = buildTable(); wrap.appendChild(table); wireResizers(table); const am = table.querySelector('.tc-applymode'); if (am) { am.value = SETTINGS.applyMode || 'all'; am.onchange = () => { SETTINGS.applyMode = am.value; saveSettings(); document.querySelectorAll('.tc-applymode').forEach(s => { s.value = am.value; }); Log.info('applyMode =', am.value); }; ['mousedown', 'mousemove', 'click'].forEach(ev => am.addEventListener(ev, e => e.stopPropagation())); // don't let the column resizer hijack it } // Footer per medium: "Add N track(s)" (only when the medium can take new tracks — // a disc-ID medium has a fixed track count, #125) PLUS the pregap/data toggles. The // toggles render even on a locked medium (disabled, #330), so every medium shows them. // In the combined multi-medium view the toggles live on each medium header instead // (see fillRows), so only attach them to this footer for a per-medium section or a // single-medium release. const target = (mi == null) ? Math.max(0, mediums().length - 1) : mi; const canAdd = !mediumLocked(target); const opts = (mi != null || mediums().length <= 1) ? mediumOpts(target) : null; if (canAdd || opts) { const addrow = document.createElement('div'); addrow.className = 'tc-addrow'; if (canAdd) { addrow.innerHTML = `Add track(s) `; const addn = addrow.querySelector('.tc-addn'), addbtn = addrow.querySelector('.tc-addbtn'); addbtn.onclick = () => addTracks(target, Math.max(1, parseInt(addn.value, 10) || 1)); } if (opts) addrow.appendChild(opts); container.appendChild(addrow); } return table.querySelector('tbody'); } // resize a column by dragging near its right border from ANY row (or the header) function wireResizers(table) { const cols = [...table.querySelectorAll('col')]; const TOL = 5; const artIdx = COLS.findIndex(c => c.k === 'art'); // the flexible filler column // detect a column boundary near the cursor (each th's right edge), including Artist's right edge. #119 // #654: the boundary before the fixed-width actions column resizes nothing, so it isn't one // (it set the drag going and returned, and the cursor stayed a resize arrow for good) const actIdx = COLS.findIndex(c => c.k === 'act'); const borderIdx = clientX => { const ths = table.querySelectorAll('thead th'); for (let i = 0; i < ths.length - 1; i++) { if (i + 1 === actIdx) continue; if (Math.abs(ths[i].getBoundingClientRect().right - clientX) <= TOL) return i; } return -1; }; let dragging = false; table.addEventListener('mousemove', e => { if (!dragging) table.style.cursor = borderIdx(e.clientX) >= 0 ? 'col-resize' : ''; }); table.addEventListener('mousedown', e => { const i = borderIdx(e.clientX); if (i < 0) return; e.preventDefault(); dragging = true; // data columns have exact fixed widths (the spacer column absorbs slack), so the style width IS the // rendered width — resize is 1:1, no jump. Columns LEFT of the Artist filler resize from their own // right edge; columns AT/AFTER it (Length, badge) have no room to their right, so each boundary // resizes the column to its RIGHT, inversely (drag right = that column shrinks, Artist absorbs). #119 const ths = [...table.querySelectorAll('thead th')]; const inverse = i >= artIdx; const ci = inverse ? i + 1 : i; const col = cols[ci], startX = e.clientX, startW = parseInt(col.style.width) || (ths[ci] && ths[ci].offsetWidth) || 100; const mm = ev => { col.style.width = Math.max(36, startW + (ev.clientX - startX) * (inverse ? -1 : 1)) + 'px'; }; const mu = () => { document.removeEventListener('mousemove', mm); document.removeEventListener('mouseup', mu); dragging = false; SETTINGS.colWidths = SETTINGS.colWidths || {}; SETTINGS.colWidths[COLS[ci].k] = parseInt(col.style.width); saveSettings(); }; document.addEventListener('mousemove', mm); document.addEventListener('mouseup', mu); }); } // ── column sizing (the "Resize columns" tool) ── Artist stays the flexible filler; the rest get // explicit widths in SETTINGS.colWidths and are pushed to every live table in place (no rebuild). function applyColWidths() { SETTINGS.colWidths = SETTINGS.colWidths || {}; document.querySelectorAll('.tc-mirror').forEach(table => { const cols = [...table.querySelectorAll('colgroup col')]; COLS.forEach((c, i) => { if (!cols[i]) return; cols[i].style.width = c.k === 'art' ? '' : colW(c.k, c.w) + 'px'; }); }); } function colsDefault() { SETTINGS.colWidths = {}; saveSettings(); applyColWidths(); Log.info('columns → default widths'); } // fit each text column (#, Title, Length) to its widest content; Artist absorbs the slack function colsFit() { const tables = [...document.querySelectorAll('.tc-mirror')]; if (!tables.length) return; SETTINGS.colWidths = SETTINGS.colWidths || {}; const probe = tables[0].querySelector('tbody input') || tables[0]; const cx = (colsFit._cv || (colsFit._cv = document.createElement('canvas'))).getContext('2d'); cx.font = getComputedStyle(probe).font || '13px sans-serif'; const PAD = { num: 22, title: 32, len: 22 }, CAP = { num: 90, title: 720, len: 90 }; ['num', 'title', 'len'].forEach(k => { const def = COLS.find(c => c.k === k); let max = cx.measureText(def.label || '').width; tables.forEach(t => t.querySelectorAll(`tbody td.c-${k} input`).forEach(inp => { max = Math.max(max, cx.measureText(inp.value || '').width); })); SETTINGS.colWidths[k] = Math.min(CAP[k], Math.max(36, Math.round(max) + PAD[k])); }); saveSettings(); applyColWidths(); Log.info('columns → fit content', JSON.stringify(SETTINGS.colWidths)); } // "centered" / balanced: give Title and Artist an equal share of the row (Artist flexes to the other half) function colsBalanced() { const table = document.querySelector('.tc-mirror'); if (!table) return; SETTINGS.colWidths = SETTINGS.colWidths || {}; const total = table.clientWidth || table.offsetWidth || 900; const fixed = colW('mv', 32) + colW('num', 38) + colW('len', 52) + colW('badge', 56) + colW('act', 44); SETTINGS.colWidths.title = Math.max(160, Math.round((total - fixed) / 2)); saveSettings(); applyColWidths(); Log.info('columns → balanced (Title = Artist)', SETTINGS.colWidths.title); } // picking an artist writes through immediately; in "all" mode it also copies to every other // track credited to the same text, committing each. function pickArtist(slot, c) { if (!c || !c.gid) return; noteDisamb(c.gid, c.comment); // #450: cache the disambiguation — a created / pasted-MBID pick never went through search (where it's cached), so the table showed no comment if (c.aliases) cacheAliases(c.gid, c.aliases); // keep the chosen artist's aliases for the bar else if (!_gidAliases.has(c.gid)) fetchAliasesByGids([c.gid]).then(() => refreshAdorns()); // alias not loaded yet (fast pick / "Show more" result) — fetch + show it without re-searching #128 if (MODEL) MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); // clear the previous selection's outlines const entry = slot._entry, beforeKey = creditKey(entry); // whole-credit snapshot BEFORE the pick (and credited-as auto-fill) slot.entity = c; slot.gid = c.gid; slot.name = c.name; slot.status = 'user'; slot._at = Date.now(); slot.committed = true; slot.query = null; slot._flash = true; if (!(slot.creditedAs || '').trim()) slot.creditedAs = c.name; // auto-fill the credited-as when the user hasn't set one commitTrack(entry); // whole-credit match, like MB's native "all matching tracks": copy this track's resulting // credit (the picked artist included) to every other track that shared its credit string. const copies = propagateCredit(entry, beforeKey); if (copies) { slot._marked = true; Log.info('propagated', c.name, '→', copies, 'matching track(s)'); } rerender(); if (copies) toast(`linked “${c.name}” — also on ${copies} matching track${copies > 1 ? 's' : ''}`); // #227: the new artist may lack the slot's Discogs link — recompute the add affordance if (slot._discogsUrl) tagDiscogsAddable(slot, slot._discogsUrl).then(() => rerender()); if (slot._platUrl) tagPlatformAddable(slot, slot._platUrl).then(() => rerender()); // #651 } // Ctrl/Cmd-click a search result → set that artist on EVERY still-unresolved slot (bulk-fill, e.g. a // various-artists comp that's actually one artist). Resolved (green) slots are left untouched. function pickArtistAllUnresolved(c) { if (!c || !c.gid || !MODEL) return; if (c.aliases) cacheAliases(c.gid, c.aliases); else if (!_gidAliases.has(c.gid)) fetchAliasesByGids([c.gid]).then(() => refreshAdorns()); MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); const touched = new Set(); let n = 0; MODEL.tracks.forEach(t => t.slots.forEach(s => { if (s.committed) return; // skip already-resolved slots Object.assign(s, { entity: c, gid: c.gid, name: c.name, status: 'user', _at: Date.now(), committed: true, query: null, _flash: true, _marked: true }); if (!(s.creditedAs || '').trim()) s.creditedAs = c.name; touched.add(s._entry); n++; })); touched.forEach(commitTrack); rerender(); toast(n ? `linked “${c.name}” on ${n} unresolved track${n > 1 ? 's' : ''}` : 'no unresolved tracks'); } async function revertSlot(entry, i) { const orig = ORIGINALS.get(origKey(entry.mi, entry.ti)); if (!orig || !orig.names[i]) return; const on = orig.names[i], slot = entry.slots[i]; slot.creditedAs = on.creditedAs; slot.joinPhrase = on.joinPhrase; slot.query = null; const a = u(on.artist) || {}, gid = u(a.gid); if (gid) Object.assign(slot, { status: 'set', gid, name: u(a.name), entity: { gid, name: u(a.name), id: u(a.id) }, candidates: [], committed: true }); else { const sib = (await loadSiblingMap()).get(fold(entry.title)); const durls = (await loadDiscogsMap())?.get(fold(entry.title)); const pUrl = fcPlatformUrl(entry, i, on.creditedAs); const m = await matchSlot(on.creditedAs, sib && pickSibArtist(sib, on.creditedAs, i), durls && durls[i], slotContextGids(slot._entry, i), () => positionArtists(entry, on.creditedAs), pUrl); Object.assign(slot, { status: slotStatusOf(m), entity: m.entity, gid: m.entity ? m.entity.gid : null, name: m.entity ? m.entity.name : '', candidates: m.candidates, _pos: m.pos || null, _why: m.why || null, committed: false }); await tagDiscogsAddable(slot, durls && durls[i]); await tagPlatformAddable(slot, pUrl); } commitTrack(entry); Log.info('reverted slot', i, 'of track', entry.number); rerender(); } const blankSlot = entry => ({ creditedAs: '', joinPhrase: '', status: 'none', entity: null, gid: null, name: '', candidates: [], committed: false, _entry: entry }); function focusSlotInput(entry, idx) { if (entry.release) { const ins = document.querySelectorAll('#information .tc-ri-art .tc-search input.nm'); if (ins[idx]) ins[idx].focus(); return; } // #652 const row = rowEl(entry.mi, entry.ti); if (row) { const ins = row.querySelectorAll('.tc-search input.nm'); if (ins[idx]) ins[idx].focus(); } } /* ── "all matching tracks" propagation (mirrors MB's native changeMatchingTrackArtists) ── The match key is the WHOLE artist-credit string — each slot's as-credited text (its credited-as, or the artist name when there's no override) + its join phrase, in order. Linked-artist identity is ignored, exactly like MB's reduceArtistCredit. An empty credit never propagates. Peers are re-derived per action; since every artist action propagates, a matched group stays in lockstep through a multi-step edit (add slot → type → pick). */ const creditKey = entry => entry.slots.map(s => (s.creditedAs || s.name || '') + (s.joinPhrase || '')).join(''); function cloneSlots(src, destEntry) { return src.slots.map(s => ({ creditedAs: s.creditedAs, joinPhrase: s.joinPhrase, // committed+linked slots become 'user' so commitTrack writes our entity (not the peer's stale live one) status: (s.committed && s.entity && s.status === 'set') ? 'user' : s.status, entity: s.entity, gid: s.gid, name: s.name, candidates: (s.candidates || []).slice(), committed: s.committed, query: s.query || null, _entry: destEntry, _flash: true, _marked: true, })); } // Apply `entry`'s resulting credit to every OTHER track whose credit string still equals // `beforeKey` (its string before this edit). Returns how many peer tracks were changed. function propagateCredit(entry, beforeKey) { if (entry.release || !MODEL) return 0; // #652: the release credit is never copied onto tracks if ((SETTINGS.applyMode || 'all') !== 'all' || !beforeKey || !beforeKey.trim()) return 0; MODEL.tracks.forEach(t => t.slots.forEach(s => { delete s._marked; })); // clear the previous action's outline let n = 0; MODEL.tracks.forEach(t => { if (t === entry || creditKey(t) !== beforeKey) return; t.slots = cloneSlots(entry, t); commitTrack(t); n++; }); if (n) entry.slots.forEach(s => { s._marked = true; }); return n; } // Run a credit mutation on `entry`, commit it, then propagate to matching tracks. `liveRerender` // false (text edits) skips the table rebuild when nothing propagated, so the field keeps focus. function editCredit(entry, mutate, verb, liveRerender = true) { const beforeKey = creditKey(entry); mutate(); commitTrack(entry); const n = propagateCredit(entry, beforeKey); if (liveRerender || n) rerender(); Log.info(verb, 'on track', entry.number, n ? ('· +' + n + ' matching') : ''); if (n) toast(`${verb} — also on ${n} matching track${n > 1 ? 's' : ''}`); return n; } // split a credit: append an artist slot (the + create-row / API uses this) function addSlot(entry) { editCredit(entry, () => { const last = entry.slots[entry.slots.length - 1]; if (last && !(last.joinPhrase || '').trim()) last.joinPhrase = ' & '; entry.slots.push(blankSlot(entry)); }, 'added artist slot'); focusSlotInput(entry, entry.slots.length - 1); } // ↵ : insert an artist slot right after this one function addSlotAfter(entry, idx) { editCredit(entry, () => { if (!(entry.slots[idx].joinPhrase || '').trim()) entry.slots[idx].joinPhrase = ' & '; const s = blankSlot(entry); s.joinPhrase = idx + 1 < entry.slots.length ? ' & ' : ''; entry.slots.splice(idx + 1, 0, s); }, 'inserted artist slot'); focusSlotInput(entry, idx + 1); } // merge: remove an artist slot (clearing the trailing join on the new last slot) function removeSlot(entry, idx) { if (entry.slots.length <= 1) return; editCredit(entry, () => { entry.slots.splice(idx, 1); const last = entry.slots[entry.slots.length - 1]; if (last) last.joinPhrase = ''; }, 'removed artist slot'); } function revertTrack(entry) { resetTrack(entry); rebuild(true); } // parse a combined credit ("A feat. B & C") into [{name, sep}] — sep is the separator AFTER each name const SEP_RE = /\s*(\bfeat\.?|\bft\.?|\bfeaturing|&|\band\b|\bvs\.?|\bwith\b|×|・|,|;)\s*/gi; function splitArtistText(text) { const parts = (text || '').split(SEP_RE); const out = []; for (let i = 0; i < parts.length; i += 2) { const name = (parts[i] || '').trim(); if (name) out.push({ name, sep: parts[i + 1] || '' }); } return out; } function normJoin(sep) { const s = (sep || '').trim().toLowerCase(); if (!s) return ' & '; if (s === '&') return ' & '; if (/^feat|^ft/.test(s)) return ' feat. '; if (s === 'and') return ' and '; if (s === ',') return ', '; if (s === ';') return '; '; if (/^vs/.test(s)) return ' vs. '; if (s === 'with') return ' with '; if (s === '×' || s === 'x') return ' × '; return ' ' + sep.trim() + ' '; } // ⋔ : split this slot's combined credit into one slot per artist, auto-match (if on), drop the credited-as override async function splitSlot(entry, idx) { const slot = entry.slots[idx]; const parts = splitArtistText(slot.creditedAs || slot.name || slot.query || ''); if (parts.length < 2) return; const beforeKey = creditKey(entry); // snapshot before the split, for "all matching tracks" const fresh = parts.map((p, i) => { const s = blankSlot(entry); s.creditedAs = p.name; s.joinPhrase = i < parts.length - 1 ? normJoin(p.sep) : ''; s._pending = true; return s; }); entry.slots.splice(idx, 1, ...fresh); entry.slots.forEach(s => { s._entry = entry; }); commitTrack(entry); rerender(); if (entry.release) { fresh.forEach(s => { delete s._pending; }); Log.info('split the release artist', JSON.stringify(slot.creditedAs || slot.name), '→', parts.map(p => p.name).join(' · ')); await matchReleaseArtist(true); return; } // #652: matched by the release artist's stages Log.info('split', JSON.stringify(slot.creditedAs || slot.name), '→', parts.map(p => p.name).join(' · ')); if (SETTINGS.autoMatch !== false) await matchModel(); else fresh.forEach(s => { delete s._pending; }); // remove the credited-as override on the matched parts (the artist name is the credit) entry.slots.forEach(s => { if (s.committed && s.gid) s.creditedAs = ''; }); commitTrack(entry); const n = propagateCredit(entry, beforeKey); // apply the finished split to every track that shared the old credit rerender(); if (n) { Log.info('split — also on', n, 'matching track(s)'); toast(`split — also on ${n} matching track${n > 1 ? 's' : ''}`); } } // + create-button at the right end of the box (before the join), only when the slot is unmatched; // and the alias on the resolved bar — only while the slot stays committed (gone the moment you edit) function adorn(search, slot, inp) { [...search.querySelectorAll('.mk, .tc-bar-aka, .tc-bar-disamb')].forEach(e => e.remove()); search.classList.toggle('matched', !!slot.committed); const ref = search.querySelector('.tc-joinwrap'); const aks = _gidAliases.get(slot.gid); const aka = slot.committed ? aliasStr({ gid: slot.gid, name: slot.name, aliases: aks || (slot.entity && slot.entity.aliases) || [], primaryAlias: slot.entity && slot.entity.primaryAlias }) : null; // when matched, size the name field to its content so the alias sits right after it on the LEFT // (instead of being pushed to the far right by a full-width field); reset when unmatched. #128 if (slot.committed) { inp.style.flex = '0 1 auto'; inp.size = Math.max(3, String(slot.name || inp.value || '').length + 1); } else { inp.style.flex = ''; inp.removeAttribute('size'); } // content-sizing the matched field leaves a short name with almost no click target, so clicking // anywhere in the rest of the bar (the empty space / alias) focuses it to open the search. #128 search.onmousedown = e => { if (e.target === inp || e.target.closest('.tc-joinwrap, .mk')) return; e.preventDefault(); inp.focus(); }; if (aka) { const al = document.createElement('span'); al.className = 'tc-bar-aka'; al.textContent = aka; al.title = aka; search.insertBefore(al, ref); } // artist disambiguation, like native MB's credit editor — grey, after the alias. // Sourced from the cache (filled by the same gid alias fetch); shows even for // special-purpose artists like [unknown], whose alias is suppressed. #195 const dis = slot.committed ? (getDisamb(slot.gid) || (slot.entity && slot.entity.comment) || '') : ''; // #450 entity fallback (created/pasted picks) if (dis) { const ds = document.createElement('span'); ds.className = 'tc-bar-disamb'; ds.textContent = '(' + dis + ')'; ds.title = dis; search.insertBefore(ds, ref); } // #580: always built. On an unresolved slot it shows outright; on a resolved // one the .tc-search.matched rules keep it hidden until the slot has focus. const mk = document.createElement('button'); mk.className = 'mk'; mk.textContent = '+'; mk.title = 'create this artist on MusicBrainz · right-click: create silently in a background tab'; mk.onmousedown = e => { if (e.button === 2) return; e.preventDefault(); createArtist(inp.value.trim() || slot.creditedAs, slot, slot._discogsUrl || null); }; mk.oncontextmenu = e => { e.preventDefault(); createArtist(inp.value.trim() || slot.creditedAs, slot, slot._discogsUrl || null, true); }; search.insertBefore(mk, ref); } // #626: what a `pos` match rests on, on hover /* ── #654: the match card — a badge's rich tooltip ───────────────────────────── majkinetor: "maybe we should have richer tooltip (visually as HTML and more relevant info) that contains not only description but also more match details visible in the log" — and, on the mock, "go with the mock". Hover a badge for half a second and a card opens under it: the stage and what it means, the artist it linked, the evidence the stage had (the slot's _why, recorded while matching: no request is made for the card), what else was considered, and when. The card stays while the pointer is on it, so its links can be followed; Esc or moving away closes it. */ const MATCH_STAGES = { disc: { name: 'Discogs link', n: 1, means: 'The Discogs artist credited on the release is linked from this MusicBrainz artist.' }, plat: { name: 'Platform link', n: 1, means: 'The artist\'s page on the platform the release was imported from is linked from this MusicBrainz artist.' }, rg: { name: 'Release group', n: 2, means: 'Another release in this release group credits this artist on the same track.' }, pos: { name: 'Same position on other editions', n: 3, means: 'Other editions credit this artist on the track at this position.' }, high: { name: 'Exact name', n: 4, means: 'The only MusicBrainz artist with this name; aliases were checked too.' }, alias: { name: 'Alias', n: 4, means: 'The only MusicBrainz artist with this credit as a name or alias.' }, cred: { name: 'Co-credit', n: 5, means: 'Credited alongside an artist already on this release, more often than any other artist of this name.' }, user: { name: 'Picked by you', n: null, means: 'You chose this artist.' }, set: { name: 'Already linked', n: null, means: 'Linked before Apollo matched: by the release, the seed or the page.' }, low: { name: 'Uncertain', n: null, means: 'No stage found one certain artist. This is the best candidate.' }, }; const mtClock = t => { const d = new Date(t); const p = n => String(n).padStart(2, '0'); return p(d.getHours()) + ':' + p(d.getMinutes()) + ':' + p(d.getSeconds()); }; const mtArtistLink = (gid, name) => `${esc(name || gid)}`; const mtRelLink = (gid, title) => `${esc(title || gid)}`; function mtEntityHtml(e, gid, name) { e = e || {}; const g = e.gid || gid; if (!g) return ''; const type = e.typeName || (e.type && (e.type.name || e.type)) || ''; const area = (e.area && e.area.name) || ''; const b = (e.begin_date && (e.begin_date.year || e.begin_date)) || (e['life-span'] && e['life-span'].begin) || ''; const en = (e.end_date && (e.end_date.year || e.end_date)) || (e['life-span'] && e['life-span'].end) || ''; const life = b || en ? String(b || '?').slice(0, 4) + '–' + String(en || '').slice(0, 4) : ''; const meta = [typeof type === 'string' ? type : '', area, life].filter(Boolean).join(' · '); return `
${mtArtistLink(g, e.name || name)}${e.comment ? ` (${esc(e.comment)})` : ''}${meta ? `${esc(meta)}` : ''}
`; } const mtEditionLine = ed => [mtRelLink(ed.gid, ed.title), ed.date ? esc(String(ed.date).slice(0, 4)) : '', ed.format ? esc(ed.format) : ''].filter(Boolean).join(' · '); function mtEvidenceHtml(s) { const w = s._why || {}, st = s.status; if (st === 'plat' && w.plat) return `
First Contact imported this release from ${esc(w.plat.name)}, which credits this artist page, and exactly one MusicBrainz artist links it.
`; if (st === 'disc' && w.discogs) return `
The release credits this Discogs artist, and exactly one MusicBrainz artist links it.
`; if (st === 'rg' && w.rg) { const src = w.rg.src; return '
' + (src ? `Credited${w.rg.creditedAs && w.rg.creditedAs !== s.creditedAs ? ` as ${esc(w.rg.creditedAs)}` : ''} on the same track of another release in the release group:
${mtEditionLine(src)} · track ${esc(src.pos)} “${esc(src.track)}”` : 'Credited on the same track of another release in the release group.') + '
'; } if (st === 'pos') { const p = s._pos || w.pos || {}; const eds = p.editions || []; const a = (p.artists || []).find(x => (x.entity ? x.entity.gid : x.gid) === s.gid); // #654 (majkinetor: "I didn't select any other edition"): an edition is the release group's // or a duplicate — a release elsewhere with this title and artist, found by search, as the // Duplicates tab lists them. Say which. const dups = eds.filter(ed => ed.from === 'dup').length; const where = !eds.length ? 'other edition' : dups === eds.length ? 'release' : 'other edition'; let h = `
Credited ${w.pos && w.pos.ri ? 'as the release artist' : 'on this track'} on ${a ? a.votes : '?'} of ${p.of || '?'} ${where}${p.of === 1 ? '' : 's'}${eds.length ? ':' : '.'}
`; if (dups) h += `
${dups === eds.length ? 'Not in this release group: found' : 'Marked ⧉: found'} ${_artPosDupFromTab ? "by the duplicate search, a release with this title and artist (see the Duplicates tab)" : "by Apollo's own duplicate search (the Duplicates tab had none), a release with this title and artist"}. It may be this release, already in MusicBrainz.
`; if (eds.length) h += '' + eds.slice(0, 8).map(ed => { const ok = (ed.gids || []).includes(s.gid); return ``; }).join('') + (eds.length > 8 ? `` : '') + '
${ok ? '✓' : '✗'}${ed.from === 'dup' && dups !== eds.length ? '⧉ ' : ''}${mtEditionLine(ed)}${ok ? '' : esc((ed.names || []).join(', '))}
and ${eds.length - 8} more
'; return h; } if (st === 'alias' || st === 'high') { const id = w.ident || {}; const named = s.entity && s.entity.name && !sameName(s.entity.name, s.creditedAs); return `
${st === 'alias' || named ? `Credited as ${esc(s.creditedAs)}, ${st === 'alias' ? 'an alias' : 'a name'} of this artist.` : `Credited as ${esc(s.creditedAs)}.`} No other MusicBrainz artist has it as a name or alias${id.count != null ? ` (the search found ${id.count} artist${id.count === 1 ? '' : 's'} like it, ${id.exact} exact)` : ''}.
`; } if (st === 'cred' && w.cred) { const c = w.cred.counts || [], top = c.find(x => x.gid === s.gid) || c[0]; const rest = c.filter(x => x !== top).slice(0, 3); return `
Co-credited with ${top ? top.count : '?'} of the ${w.cred.ctx} artist${w.cred.ctx === 1 ? '' : 's'} already on this release${rest.length ? '; next: ' + rest.map(x => `${esc(x.name)} (${x.count})`).join(', ') : ', and no other artist of this name is'}.
`; } if (st === 'low') return mtStagesHtml(w); return ''; } // what each stage found: the whole story of a slot no stage was sure about function mtStagesHtml(w) { const rows = []; if (w.discogs) rows.push(['Discogs link', w.discogs.n == null ? 'lookup unavailable' : w.discogs.n === 0 ? 'no MusicBrainz artist links it' : `${w.discogs.n} artists link it`]); if (w.plat) rows.push([w.plat.name + ' link', w.plat.n == null ? 'lookup unavailable' : w.plat.n === 0 ? 'no MusicBrainz artist links it' : `${w.plat.n} artists link it`]); rows.push(['Release group', w.rg ? (w.rg.found ? 'found' : 'credit found, artist not') : 'no other release credits it']); if (w.pos) rows.push(['Other editions', w.pos.of ? `${w.pos.of} agree on the track; ${w.pos.artists.length ? w.pos.artists.map(a => `${a.name} ×${a.votes}`).join(', ') : 'none credit this name'}` : 'none']); if (w.ident) rows.push(['Name / alias', w.ident.status === 'unique' ? 'unique' : w.ident.count != null ? `${w.ident.exact} exact of ${w.ident.count} similar${w.ident.status === 'incomplete' ? ', not all seen' : ''}` : 'search unavailable']); if (w.cred) rows.push(['Co-credit', w.cred.counts.length ? (w.cred.counts.length > 1 && w.cred.counts[0].count === w.cred.counts[1].count ? `tie: ${w.cred.counts.slice(0, 3).map(x => `${x.name} ×${x.count}`).join(', ')}` : w.cred.counts.slice(0, 3).map(x => `${x.name} ×${x.count}`).join(', ')) : 'none shared']); return '
What each stage found:
' + rows.map(r => ``).join('') + '
–${esc(r[0])}${esc(r[1])}
'; } function matchCardHtml(s) { const st = MATCH_STAGES[s.status] || { name: s.status, means: '' }; const auto = s.status === 'user' ? 'your pick' : s.status === 'set' ? 'on the page' : s.status === 'low' ? 'not linked' : 'auto-linked'; const alts = (s.candidates || []).filter(c => (c.gid || c.id) !== s.gid).slice(0, 3); const at = s._at || (s._why && s._why.at); const ev = mtEvidenceHtml(s); // the stage's own evidence says what it means; the generic line only when there is none const body = (s.gid ? mtEntityHtml(s.entity, s.gid, s.name) : '') + (ev || `
${esc(st.means)}
`) + (alts.length ? `
Also considered: ${alts.map(c => `${mtArtistLink(c.gid || c.id, c.name)}${c.comment ? ` (${esc(c.comment)})` : ''}`).join(', ')}
` : ''); return `
${esc(badgeText(s))}${esc(st.name)}${st.n ? 'stage ' + st.n + ' · ' : ''}${auto}
` + `
${body}
` + `
${at ? (s.status === 'user' ? 'picked ' : 'matched ') + mtClock(at) : ''}click the artist to change
`; } let _mtCard = null, _mtShowT = 0, _mtHideT = 0, _mtFor = null; function closeMatchCard() { clearTimeout(_mtShowT); clearTimeout(_mtHideT); if (_mtCard) { _mtCard.remove(); _mtCard = null; _mtFor = null; } } function openMatchCard(badge, slot) { closeMatchCard(); const c = document.createElement('div'); c.id = 'tc-mtip'; c.className = 'mbu-ui'; c.setAttribute('role', 'tooltip'); c.innerHTML = matchCardHtml(slot); document.body.appendChild(c); const b = badge.getBoundingClientRect(), w = c.offsetWidth, h = c.offsetHeight; let left = Math.min(b.right - w, window.innerWidth - w - 8); left = Math.max(8, left); let top = b.bottom + 6; if (top + h > window.innerHeight - 8 && b.top - h - 6 > 8) top = b.top - h - 6; c.style.left = (left + window.scrollX) + 'px'; c.style.top = (top + window.scrollY) + 'px'; c.addEventListener('mouseenter', () => clearTimeout(_mtHideT)); c.addEventListener('mouseleave', () => { _mtHideT = setTimeout(closeMatchCard, 250); }); _mtCard = c; _mtFor = badge; Log.debug('match card: ' + slot.status + ' for "' + slot.creditedAs + '"' + (slot._why ? '' : ' (no match record: linked before this session or picked)')); } // one listener for every badge, now and after any re-render function wireMatchCards() { if (wireMatchCards.done) return; wireMatchCards.done = true; document.addEventListener('mouseover', e => { const badge = e.target.closest && e.target.closest('.tc-mirror .tc-badge[data-slot]:not([data-ri]), .tc-ri-rb .tc-badge[data-ri]'); if (!badge || badge === _mtFor) return; clearTimeout(_mtShowT); clearTimeout(_mtHideT); _mtShowT = setTimeout(() => { if (!badge.isConnected || !badge.matches(':hover')) return; if (badge.dataset.ri != null) { const ps = _riArt[+badge.dataset.ri]; if (ps) openMatchCard(badge, ps); return; } // #652 const row = badge.closest('tr[data-tk]'); if (!row || !MODEL) return; const t = MODEL.tracks.find(x => x.mi + ':' + x.ti === row.dataset.tk); const slot = t && t.slots[+badge.dataset.slot]; if (slot) openMatchCard(badge, slot); }, 500); }); document.addEventListener('mouseout', e => { const badge = e.target.closest && e.target.closest('.tc-mirror .tc-badge[data-slot]:not([data-ri]), .tc-ri-rb .tc-badge[data-ri]'); if (!badge || (e.relatedTarget && badge.contains(e.relatedTarget))) return; clearTimeout(_mtShowT); if (_mtFor === badge) _mtHideT = setTimeout(closeMatchCard, 250); }); document.addEventListener('keydown', e => { if (e.key === 'Escape' && _mtCard) closeMatchCard(); }, true); } function posBadgeTitle(s) { if (s.status !== 'pos' || !s._pos) return ''; const a = s._pos.artists.find(x => x.entity && x.entity.gid === s.gid); if (!a) return ''; return ` aria-label="${esc(`matched by position: ${a.entity.name} is credited on this track on ${a.votes} of ${s._pos.of} other edition${s._pos.of === 1 ? '' : 's'} of the release`)}"`; } /* the badge column: a pill per artist line. #654 (majkinetor): the track ↺/✕ overlaid it and covered the pills; they get their own column after Match, "fit to 2 icon width", empty until the row is hovered. (Not the gutter by the drag handle: the data-track ⤓ lives there, and a ✕ next to it is easy to hit by accident.) */ function renderBadgeCell(cell, track) { const changed = trackChanged(track); // ↺ only makes sense (and only shows) when there's something to revert const locked = mediumLocked(track.mi); // disc-ID medium: no remove button (#125) wireMatchCards(); // #654: hover a badge for its match card cell.innerHTML = track.slots.map((s, i) => `
${s.committed ? `${badgeText(s)}` : ''}
`).join(''); const row = cell.closest('tr'); const actCell = (row && row.querySelector('td.c-act')) || cell; // both slots always rendered, so ✕ never moves when ↺ comes and goes actCell.innerHTML = `
` + `
`; const trev = actCell.querySelector('.trev:not(.void)'); if (trev) trev.onclick = () => revertTrack(track); const rm = actCell.querySelector('.rm:not(.void)'); if (rm) rm.onclick = () => { removeTrack(track); rebuild(); }; if (row) row.classList.toggle('tc-changed', changed); // mark the row (left border) } // join phrase: editable text that grows right-to-left, plus a ▾ that opens the presets list function joinControl(entry, slot, refreshBadges) { const wrap = document.createElement('span'); wrap.className = 'tc-joinwrap'; const inp = noPw(document.createElement('input')); inp.className = 'tc-join'; inp.value = slot.joinPhrase || ''; inp.title = 'join phrase to the next artist (editable; ▾ for presets)'; const fit = () => { inp.size = Math.max(2, inp.value.length || 2); }; fit(); // #208: flag a join phrase missing a space on either side (␣) or missing // entirely (␣?␣). Every rendered join control sits BETWEEN two artists // (the last slot has none), so an empty value here is a genuine gap. // Same px>0 master switch as the #203 marking. const markJoin = () => { wrap.classList.remove('tc-jp-nolead', 'tc-jp-notrail', 'tc-jp-nophrase', 'tc-jp-bad'); if (!dhMark()) return; // #443: gated by detailed highlighting, not the px size const v = inp.value; if (v === '') { wrap.classList.add('tc-jp-nophrase', 'tc-jp-bad'); return; } // CJK scripts (Japanese と / 、, Chinese, Korean) don't space their joins — // skip the space flags when the join or an adjacent name is CJK (#208, chaban) const idx = entry.slots.indexOf(slot); const nxt = entry.slots[idx + 1]; const cjk = isCjk(v) || isCjk(slot.creditedAs || slot.name) || (nxt && isCjk(nxt.creditedAs || nxt.name)); let bad = false; // leading space wanted unless the join is a tight ","/";" separator (#208, chaban) if (!cjk && !/^\s/.test(v) && !/^\s*[,;]/.test(v)) { wrap.classList.add('tc-jp-nolead'); bad = true; } if (!cjk && !/\s$/.test(v)) { wrap.classList.add('tc-jp-notrail'); bad = true; } if (bad) wrap.classList.add('tc-jp-bad'); }; const commit = () => { editCredit(entry, () => { slot.joinPhrase = inp.value; }, 'join phrase', false); markJoin(); if (refreshBadges) refreshBadges(); }; inp.oninput = () => { fit(); markJoin(); if (inp.value.trim()) open(inp.value); else close(); }; // #419: typing filters the presets live inp.onchange = () => { commit(); }; const arrow = document.createElement('button'); arrow.className = 'tc-joinarrow'; arrow.textContent = '▾'; arrow.title = 'common join phrases (type to filter · ↑↓ + Enter)'; // #419: keyboard-first presets — typing filters ("fe" → feat. / featuring), ArrowDown // opens/moves, Enter picks the highlighted row, Esc closes; same keys on the ▾ button. let pop = null, items = [], hi = -1; const close = () => { if (pop) { pop.remove(); pop = null; hi = -1; } }; const pick = v => { inp.value = v; fit(); commit(); close(); }; const paint = () => { pop.innerHTML = items.map((o, i) => `
${esc(o.label)}"${esc(o.value)}"
`).join(''); [...pop.querySelectorAll('[data-i]')].forEach(row => { row.onmousedown = e => { e.preventDefault(); pick(items[+row.dataset.i].value); }; }); }; const open = q => { const needle = String(q || '').trim().toLowerCase(); items = needle ? JOIN_OPTIONS.filter(o => o.label.toLowerCase().includes(needle) || o.value.trim().toLowerCase().includes(needle)) : JOIN_OPTIONS.slice(); if (!items.length) { close(); return; } if (!pop) { pop = document.createElement('div'); pop.className = 'tc-acpop tc-joinpop'; document.body.appendChild(pop); const r = inp.getBoundingClientRect(); pop.style.left = Math.max(4, r.right - 150) + 'px'; pop.style.top = (r.bottom + 4) + 'px'; pop.style.minWidth = '150px'; const off = e => { if (!pop) { document.removeEventListener('mousedown', off); return; } if (!pop.contains(e.target) && e.target !== arrow && e.target !== inp) { close(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } hi = needle ? 0 : -1; // typing pre-highlights the top hit so Enter picks it straight away paint(); }; const keys = e => { if (e.key === 'ArrowDown') { e.preventDefault(); e.stopPropagation(); if (!pop) open(inp.value); else { hi = (hi + 1) % items.length; paint(); } } else if (e.key === 'ArrowUp') { if (!pop) return; e.preventDefault(); e.stopPropagation(); hi = (hi - 1 + items.length) % items.length; paint(); } else if (e.key === 'Enter') { if (!pop && e.currentTarget === arrow) return; // let the button's native click open the menu // Enter must never reach MB's form (it switches tabs) — same contract enterBlurs had e.preventDefault(); e.stopPropagation(); if (pop && hi >= 0 && items[hi]) pick(items[hi].value); else { close(); inp.blur(); } } else if (e.key === 'Escape') { if (pop) { e.preventDefault(); e.stopPropagation(); close(); } } }; inp.addEventListener('keydown', keys); arrow.addEventListener('keydown', keys); inp.addEventListener('blur', () => setTimeout(() => { if (document.activeElement !== arrow) close(); }, 120)); // Tab away closes (row picks keep focus via preventDefault) arrow.onclick = () => { if (pop) close(); else open(''); }; wrap.appendChild(inp); wrap.appendChild(arrow); markJoin(); return wrap; } // attach the type-to-search autocomplete to an existing function wireAutocomplete(inp, slot, refresh) { let pop = null, list = [], hi = -1, seq = 0, onScroll = null; let curQuery = '', curLimit = 8; // "Show more…" pagination: bump the limit and re-search const position = () => { if (!pop) return; const r = inp.getBoundingClientRect(); pop.style.left = r.left + 'px'; pop.style.top = (r.bottom + 2) + 'px'; pop.style.minWidth = Math.max(210, r.width) + 'px'; }; const ensure = () => { if (pop) return; pop = document.createElement('div'); pop.className = 'tc-acpop'; document.body.appendChild(pop); onScroll = () => { if (!pop || !pop.isConnected) { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); pop = null; } else position(); }; window.addEventListener('scroll', onScroll, true); window.addEventListener('resize', onScroll); position(); }; const close = () => { if (pop) pop.remove(); pop = null; hi = -1; if (onScroll) { window.removeEventListener('scroll', onScroll, true); window.removeEventListener('resize', onScroll); onScroll = null; } }; const choose = c => { close(); pickArtist(slot, c); }; const searching = () => { ensure(); list = []; pop.innerHTML = `
Searching…
`; position(); }; const akaHtml = c => { const a = aliasStr(c); return a ? `${esc(a)}` : ''; }; // a full page of results probably means there are more — offer "Show more…" (like MB's native popup) const loadMore = () => { curLimit = curLimit >= 50 ? 100 : curLimit >= 25 ? 50 : 25; const my = ++seq; const more = pop && pop.querySelector('.tc-acmore'); if (more) more.textContent = 'Loading…'; searchArtist(curQuery, curLimit).then(res => { if (my === seq && document.activeElement === inp) showResults(res, curQuery); }); }; // #626: the artists other editions credit at this track's position lead the list, in a // section of their own, with how many editions back each — also once the slot resolved. const posLead = () => ((slot._pos && slot._pos.artists) || []).filter(x => x.entity && x.entity.gid); const draw = res => { ensure(); const q = inp.value.trim() || slot.creditedAs; const lead = posLead(), leadG = new Set(lead.map(x => x.entity.gid)), votes = new Map(lead.map(x => [x.entity.gid, x.votes])); Log.debug(`picker "${slot.creditedAs}": ${lead.length} artist(s) from other editions at this position, ${res.length} from the search (slot ${slot.status}${slot._pos ? ', position data kept' : ', no position data'})`); const arr = lead.length ? [...lead.map(x => x.entity), ...res.filter(c => !leadG.has(c.gid || c.id))] : res; list = arr; const of = lead.length ? slot._pos.of : 0; const sec = (i) => !lead.length ? '' : i === 0 ? '
On other editions at this position
' : i === lead.length ? '
Search
' : ''; const votesHtml = c => votes.has(c.gid) ? `${votes.get(c.gid)} of ${of} edition${of === 1 ? '' : 's'}` : ''; pop.innerHTML = arr.length ? arr.map((c, i) => sec(i) + `
${typeSvg(c)}${esc(c.name)}${akaHtml(c)}${c.comment ? `${esc(c.comment)}` : ''}${votesHtml(c)}
`).join('') : `
no matches — use + to create
`; [...pop.querySelectorAll('.tc-acrow[data-i]')].forEach(row => { row.title = 'click to set · Ctrl-click to set on all unresolved tracks'; row.onmousedown = e => { e.preventDefault(); const c = arr[+row.dataset.i]; if (e.ctrlKey || e.metaKey) { close(); pickArtistAllUnresolved(c); } else choose(c); }; }); if (curQuery && res.length >= curLimit && curLimit < 100) { // likely more available → a clickable "Show more…" footer const more = document.createElement('div'); more.className = 'tc-acrow tc-acmore'; more.textContent = 'Show more…'; more.onmousedown = e => { e.preventDefault(); loadMore(); }; pop.appendChild(more); } position(); return arr; }; // patch in the full aliases (one WS2 search) without a full redraw, so it doesn't reset the keyboard highlight const patchAliases = arr => { if (!pop) return; arr.forEach((c, i) => { const a = aliasStr(c); if (!a) return; const row = pop.querySelector(`.tc-acrow[data-i="${i}"]`); if (!row) return; let sp = row.querySelector('.tc-aka'); if (!sp) { sp = document.createElement('span'); sp.className = 'tc-aka'; const nm = row.querySelector('.nm'); nm.parentNode.insertBefore(sp, nm.nextSibling); } sp.textContent = a; }); }; const showResults = (res, q) => { const arr = draw(res); // #626: the rows actually drawn (position artists first) // Fetch aliases for the EXACT result gids (batched, limit 100) — not the old by-name query // capped at 12, which left every "Show more" result past the 12th with no alias. Caches into // _gidAliases so a pick (and the resolved bar) get the alias too. #128 fetchAliasesByGids(arr.map(c => c.gid)).then(() => { if (document.activeElement !== inp || !pop) return; arr.forEach(c => { const a = _gidAliases.get(c.gid); if (a && a.length) c.aliases = a; }); patchAliases(arr); }); }; const runSearch = q => { curQuery = q; curLimit = 8; const my = ++seq; searching(); searchArtist(q).then(res => { if (my === seq && document.activeElement === inp) showResults(res, q); }); }; // paste an MBID or a MusicBrainz /artist/ URL → resolve it straight to that artist. Gate on the // field value (not focus): a commit-rerender can steal focus before the fetch returns. const resolveByGid = async gid => { ensure(); list = []; pop.innerHTML = `
Resolving…
`; position(); const ent = await fetchEntity(gid); if (mbidFrom(inp.value) !== gid) return; if (ent && ent.id) { const entry = slot._entry, i = entry.slots.indexOf(slot); close(); pickArtist(slot, ent); focusSlotInput(entry, i); } else { pop.innerHTML = `
MBID not found
`; } }; // refocus the (re-rendered) field so keyboard nav keeps working after a paste inp.onfocus = () => { slot._editing = true; // #580: this slot is the user's now — auto-match leaves it alone until they leave it if (slot.committed && slot.candidates && slot.candidates.length) { curQuery = inp.value.trim() || slot.creditedAs || slot.name; curLimit = 8; showResults(slot.candidates, curQuery); return; } const q = inp.value.trim() || (slot.creditedAs || '').trim(); if (q) runSearch(q); else close(); // empty → no dropdown }; let tmr; inp.oninput = () => { slot.query = inp.value; clearTimeout(tmr); const gid = mbidFrom(inp.value); if (gid) { resolveByGid(gid); return; } // pasted an MBID / artist URL → resolve directly (pickArtist replaces whatever was there; no un-link needed) // editing away from the matched artist un-links it: bar goes white, + creates the typed name if (slot.committed && !sameName(inp.value, slot.name)) { slot.committed = false; slot.status = 'none'; slot.entity = null; slot.gid = null; commitTrack(slot._entry); if (refresh) refresh(); } if (!inp.value.trim()) { close(); return; } // nothing typed → don't search searching(); const my = ++seq; tmr = setTimeout(async () => { curQuery = inp.value; curLimit = 8; const res = await searchArtist(inp.value); if (my === seq && document.activeElement === inp) showResults(res, inp.value); }, 250); }; // arrows browse the results popup WHILE searching; once the slot is resolved they move row-to-row instead const browsing = () => pop && !slot.committed && list.length; // highlight the row at index `hi` and keep it on screen (the popup scrolls, so a selection past the // last visible row was going off-screen) — #128-adjacent nav fix const hiliteRow = () => { const rows = [...pop.querySelectorAll('[data-i]')]; rows.forEach((r, i) => r.classList.toggle('hi', i === hi)); const cur = rows[hi]; if (cur) cur.scrollIntoView({ block: 'nearest' }); }; inp.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); inp.focus(); } // close the popup but keep the field focused, so the next ↓ navigates rows else if (e.key === 'ArrowDown') { if (browsing()) { hi = Math.min(list.length - 1, hi + 1); hiliteRow(); e.preventDefault(); } else { close(); if (focusSameField(inp, 1)) e.preventDefault(); } } else if (e.key === 'ArrowUp') { if (browsing()) { hi = Math.max(0, hi - 1); hiliteRow(); e.preventDefault(); } else { close(); if (focusSameField(inp, -1)) e.preventDefault(); } } else if (e.key === 'Enter') { e.preventDefault(); const c = list[hi >= 0 ? hi : 0]; if (c) { const entry = slot._entry, i = entry.slots.indexOf(slot); choose(c); focusSlotInput(entry, i); } } // keep focus on the field after picking (so ↓ moves on) }; inp.onblur = () => { slot._editing = false; setTimeout(close, 160); }; // keep whatever the user typed (no reset); #580 hands the slot back to auto-match } // #284: hovering an artist highlights every OTHER instance of that same artist in // the tracklist (matched by gid when resolved, else by the typed/credited name) — // the same idea as the green "matched" bars, but live on hover. STICKY: the last // highlighted artist stays lit even when the mouse isn't over it; moving onto a // DIFFERENT real artist clears the previous highlight (and lights the new one up if // it appears more than once). Only empty slots / gaps keep the previous highlight — // so moving the mouse across rows/gaps never flickers, but landing on another artist // (even a one-off) drops the stale highlight. function hlArtist(id) { if (!SETTINGS.hoverHighlight) return; // #284: opt-in (Appearance setting, off by default) if (!id || id === 'n:' || id === 'g:' || id === _hlCur) return; // empty slot or already current → keep current _hlCur = id; document.querySelectorAll('.tc-aslot.tc-arthl').forEach(e => e.classList.remove('tc-arthl')); const matches = [...document.querySelectorAll('.tc-aslot')].filter(e => e.dataset.art === id); if (matches.length > 1) matches.forEach(e => e.classList.add('tc-arthl')); // single instance → nothing to show } let _hlCur = null; const slotArtId = s => (s.committed && s.gid) ? 'g:' + s.gid : 'n:' + fold(s.creditedAs || s.name || s.query || ''); // one artist = one aligned line: [credited-as][icon][green/white search bar][join][↵ hover][✕ hover] function slotEl(entry, s, idx, refreshBadges) { const line = document.createElement('div'); line.className = 'tc-aslot'; line.dataset.art = slotArtId(s); line.addEventListener('mouseenter', () => hlArtist(line.dataset.art)); // "splittable" (several artists) drives both the credited-as highlight and the ⋔ button, via a line // class that updates live as you edit if (splitArtistText(s.creditedAs || s.name || s.query || '').length > 1) line.classList.add('tc-can-split'); // credited-as: shown empty when it's exactly the artist name (the name is the placeholder); only a real override shows const same = s.name && s.creditedAs === s.name; const cred = noPw(document.createElement('input')); cred.className = 'tc-cred'; cred.value = (s.creditedAs && !same) ? s.creditedAs : ''; cred.placeholder = s.name || 'credit…'; cred.title = 'credited-as override (blank = same as the artist name)'; // #228: a hover × on the left of the field clears the credited-as override const credClr = document.createElement('button'); credClr.type = 'button'; credClr.className = 'tc-cred-clr'; credClr.textContent = '×'; credClr.title = 'clear the credited-as override'; const updCredClr = () => line.classList.toggle('tc-has-cred', !!cred.value.trim()); credClr.onmousedown = e => e.preventDefault(); credClr.onclick = () => { if (!cred.value) return; cred.value = ''; cred.onchange(); updCredClr(); }; cred.oninput = () => { line.classList.toggle('tc-can-split', splitArtistText(cred.value || s.name || '').length > 1); updCredClr(); }; // re-evaluate the highlight / ⋔ + clear button as you type cred.onchange = () => { const v = cred.value.trim(); const newCred = v || (s.name || ''); // whole-credit "all matching tracks" propagation (liveRerender=false → keep focus when nothing propagates) editCredit(entry, () => { s.creditedAs = newCred; if (s.creditedAs === s.name) cred.value = ''; }, 'credited-as', false); refreshBadges(); // a credited-as edit changes the track → update the ↺ button + changed-row border now }; wireRowNav(cred); // wrap so the × can be absolutely positioned over the field's (empty, right- // aligned) left edge — it must not take flow space or it shifts the row (#228) const credWrap = document.createElement('span'); credWrap.className = 'tc-credwrap'; credWrap.appendChild(cred); credWrap.appendChild(credClr); line.appendChild(credWrap); updCredClr(); let ic; if (s._discogsAddable) { // #227: a Discogs link can be added — swap the artist-type icon for a link icon. // Amber warning glyph (still clickable to add) when the URL belongs to a // different MB artist (conflict) or the artist already links a different // Discogs page (mismatch); plain teal link icon otherwise. const conf = s._discogsConflict, mism = s._discogsMismatch; // conflict (URL belongs to a different MB artist) → red triangle; mismatch (artist links a // different Discogs page) → amber circle-! ; conflict wins the icon when both are flagged. const warnCls = conf ? 'discogs-conflict' : 'discogs-mismatch', warnSvg = conf ? DISCOGS_WARN_SVG : DISCOGS_MISMATCH_SVG; ic = document.createElement('a'); ic.className = 'tc-tic ' + ((conf || mism) ? warnCls : 'discogs-add'); ic.href = '#'; ic.innerHTML = (conf || mism) ? warnSvg : DISCOGS_LINK_SVG; ic.title = discAddTooltip(s); ic.title += ' · right-click: do it silently in a background tab'; ic.onmousedown = e => e.preventDefault(); ic.onclick = e => { e.preventDefault(); addOrCreateDiscogsLink(s); }; ic.oncontextmenu = e => { e.preventDefault(); addOrCreateDiscogsLink(s, true); }; // #273 background } else if (s._platAddable) { // #651: the same offer for First Contact's platform link const conf = s._platConflict; ic = document.createElement('a'); ic.className = 'tc-tic ' + (conf ? 'discogs-conflict' : 'discogs-add') + ' plat-offer'; ic.href = '#'; ic.innerHTML = conf ? DISCOGS_WARN_SVG : DISCOGS_LINK_SVG; ic.title = platAddTooltip(s) + ' · right-click: do it silently in a background tab'; ic.onmousedown = e => e.preventDefault(); ic.onclick = e => { e.preventDefault(); addOrCreatePlatformLink(s); }; ic.oncontextmenu = e => { e.preventDefault(); addOrCreatePlatformLink(s, true); }; } else if (s._discogsConflict) { // unresolved slot whose Discogs URL already belongs to an MB artist — info only (pick that artist) ic = document.createElement('a'); ic.className = 'tc-tic discogs-conflict'; ic.innerHTML = DISCOGS_WARN_SVG; ic.href = `${ORIGIN}/artist/${s._discogsConflict.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = `Discogs links this URL to ${s._discogsConflict.name} — pick that artist`; } else if (s._platConflict && !(s.committed && s.gid)) { // #651: an unmatched slot whose platform link belongs to an MB artist — info only (pick that artist) const p = platformOf(s._platUrl) || { name: 'platform' }; ic = document.createElement('a'); ic.className = 'tc-tic discogs-conflict plat-offer'; ic.innerHTML = DISCOGS_WARN_SVG; ic.href = `${ORIGIN}/artist/${s._platConflict.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = `${p.name}: ${s._platUrl} is linked to ${s._platConflict.name} — pick that artist`; } else { ic = document.createElement(s.gid ? 'a' : 'span'); ic.className = 'tc-tic ' + (s.gid ? 'link' : 'dim'); ic.innerHTML = typeSvg(s.entity); if (s.gid) { ic.href = `${ORIGIN}/artist/${s.gid}`; ic.target = '_blank'; ic.rel = 'noopener'; ic.title = 'open artist page'; } else ic.title = 'no artist linked yet'; } line.appendChild(ic); const search = document.createElement('span'); search.className = 'tc-search'; const inp = noPw(document.createElement('input')); inp.className = 'nm'; inp.value = s.committed ? (s.name || s.creditedAs) : (s.query || s.creditedAs || ''); inp.placeholder = 'search artist…'; inp.title = inp.value; search.appendChild(inp); // #228: hover × right after the name clears the field (and unsets a matched // artist — the input handler un-links on an empty value) const nmClr = document.createElement('button'); nmClr.type = 'button'; nmClr.className = 'tc-nm-clr'; nmClr.textContent = '×'; nmClr.title = 'clear / unset the artist'; // size the input to its text (so the clear × hugs the name when not editing — #284 follow-up); // `size` is in characters, close enough. Min 2 keeps a clicked-but-empty field usable. const updNmClr = () => { const v = inp.value.trim(); search.classList.toggle('tc-has-nm', !!v); fitNmWidth(inp); }; nmClr.onmousedown = e => e.preventDefault(); nmClr.onclick = () => { inp.value = ''; inp.dispatchEvent(new Event('input', { bubbles: true })); inp.focus(); updNmClr(); }; inp.addEventListener('input', updNmClr); search.appendChild(nmClr); updNmClr(); if (idx < entry.slots.length - 1) search.appendChild(joinControl(entry, s, refreshBadges)); // join lives inside the box, right side // #376 keep the pending gold in sync with the slot's selected/committed state on every re-adorn — // editing the field to free text (unselecting the mbid) must drop the gold, not just the green. const applyPend = () => { const ps = recPendingState(entry.mi, entry.ti); search.classList.toggle('tc-slot-pending', slotIsPending(ps && ps.artGids, s)); }; adorn(search, s, inp); applyPend(); if (s._marked) search.classList.add('tc-marked'); if (s._flash) { search.classList.add('tc-flash'); delete s._flash; } line.appendChild(search); wireAutocomplete(inp, s, () => { adorn(search, s, inp); applyPend(); refreshBadges(); refreshStatus(); }); // fixed-width actions area (keeps all search boxes the same width); both reveal on row hover const acts = document.createElement('span'); acts.className = 'tc-acts'; const add = document.createElement('button'); add.className = 'tc-enter'; add.textContent = '↵'; add.title = 'add another artist to this credit'; add.onclick = () => addSlotAfter(entry, idx); acts.appendChild(add); // ⋔ split: only when this credit looks like several artists (& / feat. / , …) { const sp = document.createElement('button'); sp.className = 'tc-splitb'; sp.textContent = '⋔'; sp.title = 'split into separate artists (& / feat. …) and match'; sp.onclick = () => splitSlot(entry, idx); acts.appendChild(sp); } if (entry.slots.length > 1) { const g = document.createElement('span'); g.className = 'tc-slotgrab'; g.textContent = '⠿'; g.draggable = true; g.title = 'drag to reorder this artist within the credit'; acts.appendChild(g); // #150 const x = document.createElement('button'); x.className = 'tc-slotx'; x.textContent = '✕'; x.title = 'remove this artist'; x.onclick = () => removeSlot(entry, idx); acts.appendChild(x); } line.appendChild(acts); if (entry.slots.length > 1) wireSlotDrag(line, entry, idx); // #150 return line; } // #150: reorder an artist within a track's credit by dragging its ⠿ handle onto another slot. // Join phrases ("feat." / "&" / …) are positional separators that stay put as artists move through // them — so "A feat. B" reordered reads "B feat. A", and the final position is always join-less. function moveSlot(entry, from, to) { const n0 = entry.slots.length; if (from === to || from < 0 || to < 0 || from >= n0 || to >= n0) return; editCredit(entry, () => { const joins = entry.slots.map(s => s.joinPhrase); const [s] = entry.slots.splice(from, 1); entry.slots.splice(to, 0, s); entry.slots.forEach((x, i) => { x.joinPhrase = i < n0 - 1 ? (joins[i] || ' & ') : ''; x._entry = entry; }); }, 'reordered artist'); } let _slotDrag = null; // { entry, from } of the artist slot being dragged function wireSlotDrag(line, entry, idx) { const handle = line.querySelector('.tc-slotgrab'); if (handle) { handle.addEventListener('dragstart', e => { _slotDrag = { entry, from: idx }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'slot'); } catch (x) {} try { e.dataTransfer.setDragImage(line, 18, 12); } catch (x) {} line.classList.add('tc-slotdragging'); }); handle.addEventListener('dragend', () => { line.classList.remove('tc-slotdragging'); clearSlotDropMarks(line.parentElement); _slotDrag = null; }); } const after = e => (e.clientY - line.getBoundingClientRect().top) > line.getBoundingClientRect().height / 2; line.addEventListener('dragover', e => { if (!_slotDrag || _slotDrag.entry !== entry) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearSlotDropMarks(line.parentElement); line.classList.add(after(e) ? 'tc-slotdrop-after' : 'tc-slotdrop-before'); }); line.addEventListener('dragleave', () => line.classList.remove('tc-slotdrop-before', 'tc-slotdrop-after')); line.addEventListener('drop', e => { if (!_slotDrag || _slotDrag.entry !== entry) return; e.preventDefault(); const from = _slotDrag.from, gap = idx + (after(e) ? 1 : 0), dest = gap > from ? gap - 1 : gap; clearSlotDropMarks(line.parentElement); _slotDrag = null; moveSlot(entry, from, dest); }); } const clearSlotDropMarks = host => host && host.querySelectorAll('.tc-slotdrop-before,.tc-slotdrop-after').forEach(l => l.classList.remove('tc-slotdrop-before', 'tc-slotdrop-after')); // drag-to-reorder WITHIN a medium: grab the ⠿ handle and drop a track anywhere in its medium. The actual // move rides on MB's own up/down ops (moveTrackToIndex), so the editor never diverges. Cross-medium drops // are ignored (same-medium only). Replaces the old ▲▼ buttons. let _drag = null; // { mi, ti } of the row being dragged const clearDropMarks = tb => tb && tb.querySelectorAll('.tc-drop-before,.tc-drop-after').forEach(r => r.classList.remove('tc-drop-before', 'tc-drop-after')); const dropAfter = (tr, clientY) => { const r = tr.getBoundingClientRect(); return (clientY - r.top) > r.height / 2; }; function wireDragReorder(tr, t) { const handle = tr.querySelector('.tc-drag'); if (handle) { handle.addEventListener('dragstart', e => { _drag = { mi: t.mi, ti: t.ti, kind: trackKind(t) }; e.dataTransfer.effectAllowed = 'move'; // #586: kind, so a drag can't cross the data boundary try { e.dataTransfer.setData('text/plain', t.mi + ':' + t.ti); } catch (x) {} try { e.dataTransfer.setDragImage(tr, 18, 12); } catch (x) {} tr.classList.add('tc-dragging'); }); handle.addEventListener('dragend', () => { tr.classList.remove('tc-dragging'); clearDropMarks(tr.parentElement); _drag = null; }); } tr.addEventListener('dragover', e => { /* #586 — same medium AND same side of the data-track boundary. Crossing it is what ⤓/⤒ are for: MB's moveTrackUp DEMOTES the first data track out of the section instead of swapping, so a drag across the line would strand a track mid-list, which is a state MB won't accept. */ if (!_drag || _drag.mi !== t.mi || _drag.kind !== trackKind(t)) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearDropMarks(tr.parentElement); tr.classList.add(dropAfter(tr, e.clientY) ? 'tc-drop-after' : 'tc-drop-before'); }); tr.addEventListener('dragleave', () => tr.classList.remove('tc-drop-before', 'tc-drop-after')); tr.addEventListener('drop', e => { if (!_drag || _drag.mi !== t.mi || _drag.kind !== trackKind(t)) return; // #586 e.preventDefault(); const fromTi = _drag.ti, gap = t.ti + (dropAfter(tr, e.clientY) ? 1 : 0), dest = gap > fromTi ? gap - 1 : gap; clearDropMarks(tr.parentElement); _drag = null; if (moveTrackToIndex({ mi: t.mi, ti: fromTi, number: fromTi + 1 }, dest)) rebuild(); }); } function fillRows(tbody, mi) { document.querySelectorAll('.tc-acpop').forEach(p => p.remove()); // rebuilding rows detaches inputs — drop any open search/join popups so they can't orphan tbody.innerHTML = ''; let lastMi = -1, lastDataMi = -1; const multi = mediums().length > 1 && mi == null; const tracks = (mi == null) ? MODEL.tracks : MODEL.tracks.filter(t => t.mi === mi); tracks.forEach(t => { if (multi && t.mi !== lastMi) { const r = document.createElement('tr'); const td = document.createElement('td'); td.className = 'tc-medhdr'; td.colSpan = COLS.length; td.textContent = `Medium ${t.mi + 1}`; const o = mediumOpts(t.mi); if (o) td.appendChild(o); r.appendChild(td); tbody.appendChild(r); lastMi = t.mi; } // #330: per-medium pregap/data toggles in the combined view const kind = trackKind(t); // #330: pregap / data / audio if (kind === 'data' && t.mi !== lastDataMi) { const dr = document.createElement('tr'); dr.className = 'tc-datadiv'; dr.innerHTML = `⤓ Data tracks`; tbody.appendChild(dr); lastDataMi = t.mi; } const tr = document.createElement('tr'); tr.dataset.tk = t.mi + ':' + t.ti; tr.dataset.mi = t.mi; tr.dataset.ti = t.ti; if (kind !== 'audio') tr.classList.add(kind === 'pregap' ? 'tc-row-pregap' : 'tc-row-data'); // #376 mirror MB's native "modification pending" mark (span.mp): flag recordings that have open edits const ps = recPendingState(t.mi, t.ti); if (ps && (ps.rec || ps.art)) { if (ps.rec) tr.classList.add('tc-rec-pending'); if (ps.art) tr.classList.add('tc-art-pending'); tr.title = ps.rec && ps.art ? 'Recording and artist have pending edits' : ps.rec ? 'This recording has pending edits' : 'This recording’s artist has pending edits'; } const locked = mediumLocked(t.mi); // disc-ID medium: no reorder handle (#125) const lenLocked = trackLenLocked(t); // disc-ID medium: audio-track length fixed by the TOC (#329) /* #586 (chaban-mb: "is it intentional within the data section tracks cannot be reordered?") — data tracks now drag among themselves. #330 excluded them along with pregap, but native reorders inside the section freely: moveTrackDown falls through to swapTracks when both tracks are data, and it isn't disc-ID-disabled in that case either. Pregap stays out — it is pinned at position 0 by definition. */ const canDrag = !locked && kind !== 'pregap'; /* #586 — boundary buttons in the move column, where native keeps its own. ⤓ only where it can do something (an audio track that isn't the last on its medium, i.e. there is a section to open below it); ⤒ on every data track. Hidden until the row is hovered so the column doesn't gain a permanent second glyph — visibility, not display, so nothing shifts. */ const lastOfMedium = !MODEL.tracks.some(x => x.mi === t.mi && x.ti > t.ti); /* chaban-mb: "Last track is not preserving space for move track down arrow." ⤓ can't do anything on the last track of a medium (there is no section to open below it), but OMITTING the element left that row's ⠿ handle at a different x than every other row's. The slot is always rendered; on the last track it is inert and permanently invisible, so the column lines up. */ const dtBtn = locked || kind === 'pregap' ? '' : kind === 'data' ? '' : lastOfMedium ? '' // the glyph, hidden: an EMPTY button is ~6px and still misaligns the handle : ''; tr.innerHTML = `${canDrag ? '⠿' : ''}${dtBtn}
${kind === 'pregap' ? 'pregap' : ''}${trackRecIsVideo(t) ? VIDEO_MARK_PRE : ''}
`; const badgeCell = tr.querySelector('.c-badge'); const refreshBadges = () => renderBadgeCell(badgeCell, t); const art = tr.querySelector('.c-art'); const pgids = ps && ps.artGids; // #376 gold ONLY the slot(s) whose SELECTED artist has pending edits — not free-text neighbours, and not a field edited to free text t.slots.forEach((s, si) => { const se = slotEl(t, s, si, refreshBadges); const sr = se.querySelector('.tc-search'); if (sr) sr.classList.toggle('tc-slot-pending', slotIsPending(pgids, s)); art.appendChild(se); }); refreshBadges(); // guess-case: highlight when the title differs from its guessed form; a per-title button applies it const tin = tr.querySelector('.t-title'); const diff = t.guessTitle && t.guessTitle !== t.title; if (t._srFlash) { tin.classList.add('srflash'); delete t._srFlash; } // flash titles changed by search & replace // #203: rich title display — show the title as styled read-only text (confusable / // invisible chars enlarged + named on hover) when not editing, and drop into the // native input on click/tab. Only when enlargement is on (recPunctSize > 0). let disp = null, paintDisp = null; if ((SETTINGS.recPunctSize | 0) > 0) { disp = document.createElement('span'); disp.className = 't-title-disp'; paintDisp = (val) => { const v = val != null ? val : tin.value; // empty title → a faint "title…" placeholder so there's a visible, full-height // click target (an empty disp collapses to a hard-to-hit thin line). #357 disp.innerHTML = v ? dhRun(v) : 'title…'; ['diff', 'gcpreview', 'hasfeat', 'srflash'].forEach(c => disp.classList.toggle(c, tin.classList.contains(c))); }; tin.classList.add('tc-eml'); tin.parentElement.insertBefore(disp, tin); // sits in the input's flex slot while resting // #601: work out WHICH character was clicked before anything moves — // focus() hides the span (display:none), after which there is nothing // left to hit-test against. disp.addEventListener('mousedown', e => { if (e.button !== 0) return; const at = dispCaretIndex(disp, tin, e.clientX, e.clientY); e.preventDefault(); tin.focus(); // after focus(), so it wins over the focus listeners — `restore()` // assigns tin.value, and assigning value resets the selection. try { tin.setSelectionRange(at, at); } catch (err) {} }); tin.addEventListener('focus', () => { tin.classList.add('tc-editing'); disp.classList.add('tc-hidden'); }); tin.addEventListener('blur', () => { tin.classList.remove('tc-editing'); paintDisp(tin.value); disp.classList.remove('tc-hidden'); }); paintDisp(t.title); } if (diff) { tin.classList.add('diff'); tin.title = 'Guess case → ' + t.guessTitle; const gb = document.createElement('button'); gb.className = 't-gc'; gb.textContent = 'Aa'; gb.title = 'Guess case → ' + t.guessTitle + '\n(right-click: guess case all tracks)'; const wrap = tr.querySelector('.t-wrap'); // like MB's integrated guess case: hovering the title cell previews the guessed name // (highlighted), leaving restores it, clicking Aa applies it. Never preview while editing. const preview = () => { if (document.activeElement !== tin) { tin.value = t.guessTitle; tin.classList.add('gcpreview'); if (paintDisp) paintDisp(t.guessTitle); } }; const restore = () => { tin.value = t.title; tin.classList.remove('gcpreview'); if (paintDisp) paintDisp(t.title); }; wrap.onmouseenter = preview; wrap.onmouseleave = () => { if (document.activeElement !== tin) restore(); }; tin.addEventListener('focus', restore); // clicking in to edit shows the real title, not the preview gb.onclick = () => { restore(); applyGuessTitle(t); t.title = u(koTrack(t.mi, t.ti).name); t.guessTitle = guessTitleStr(t); rerender(); }; // right-click the [Aa] runs guess case on every track (same as the Tools-menu action) — #123 gb.oncontextmenu = e => { e.preventDefault(); restore(); guessCaseAll(); }; tActions(wrap).appendChild(gb); } // featured-artist split: flag titles carrying "feat./ft./featuring" and offer the split inline, // mirroring [Aa] — click ⋔ splits this track, right-click splits all (#124) if (FEAT_RE.test(t.title)) { tin.classList.add('hasfeat'); if (!tin.title) tin.title = 'Title has a featured artist'; const fb = document.createElement('button'); fb.className = 't-feat'; fb.textContent = '⋔'; fb.title = 'Split featured artist out of the title into the artist credit\n(right-click: split all tracks)'; fb.onclick = () => guessFeatTrack(t); fb.oncontextmenu = e => { e.preventDefault(); guessFeatAll(); }; tActions(tr.querySelector('.t-wrap')).appendChild(fb); } tin.onchange = e => { setTitle(t, e.target.value); t.title = e.target.value; t.guessTitle = guessTitleStr(t); rerender(); }; wireRowNav(tin); if (paintDisp) paintDisp(t.title); // re-sync after the diff / feat blocks set their state classes const numIn = tr.querySelector('.t-num'), lenIn = tr.querySelector('.t-len'); numIn.onchange = e => { setNumber(t, e.target.value); refreshBadges(); }; wireRowNav(numIn); if (!lenLocked) lenIn.onchange = e => { let v = e.target.value.trim(); if (v) { const ed = getEditor(); const ms = ed && ed.utils && ed.utils.unformatTrackLength ? ed.utils.unformatTrackLength(v) : NaN; if (ms == null || isNaN(ms)) v = ''; } // invalid (letters/garbage) → delete; valid shorthand like "111" is kept (MB normalizes it to 1:11) setLength(t, v); try { const ko = koTrack(t.mi, t.ti); const norm = typeof ko.formattedLength === 'function' ? (u(ko.formattedLength()) || '') : v; e.target.value = norm; t.length = norm; } catch (err) { e.target.value = v; t.length = v; } // reflect MB's normalized value back into the cell immediately refreshBadges(); }; wireRowNav(lenIn); const dtb = tr.querySelector('.tc-dtmv'); // #586 if (dtb && !dtb.classList.contains('void')) dtb.onclick = () => setDataBoundary(t.mi, t.ti, dtb.classList.contains('down')); if (canDrag) wireDragReorder(tr, t); // #330: don't make pregap/data rows drag sources or drop targets tbody.appendChild(tr); }); } async function loadAndRender(onProgress) { MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } // (re)build per-medium tables + hide/tidy native rerender(); // show the tables instantly /* #575: no idle message at all. It wrote into the same spans that carry each medium's "N unresolved" badge, so every reload wiped the badge to say something the toolbar's own Match button already says — and with reloads landing mid-pass it also took turns with "matching N/M". majkinetor: "lets remove the auto-match off - click Match". refreshStatus then owns the line and the badges survive. */ if (SETTINGS.autoMatch !== false) await matchModel(onProgress); else { tagDiscogsForAll(); tagPlatformForAll(); refreshStatus(); } // #227: tag 'set' artists even when not matching enrichResolvedAliasesSoon(); // batch-fetch aliases for resolved artists (existing releases too) — coalesced, see #575 // #407: resolve an unset release label to its unique exact MB hit — once, independent of the // tracklist auto-match toggle (the label lives in the release-info model, not the tracklist). if (!_labelsAutoMatchedOnce) { _labelsAutoMatchedOnce = true; matchReleaseLabels().catch(e => Log.warn('label auto-match failed', e.message)); } if (!_artistAutoMatchedOnce) { _artistAutoMatchedOnce = true; matchReleaseArtist().catch(e => Log.warn('artist auto-match failed', e.message)); } srApplyDefaultOnStart(); // #410: run the marked default S&R once, now the tracklist is rendered } async function rebuild(noMatch) { MODEL = buildShell(); if (ACTIVE.mode === 'mirror') { mountMediums(); syncNative(); } rerender(); if (!noMatch && SETTINGS.autoMatch !== false) await matchModel(); else if (!noMatch) tagDiscogsForAll(); // #227: tag 'set' artists when auto-match is off — but NOT on a clear/revert (it would check zero matched artists); refreshStatus clears the badge instead enrichResolvedAliasesSoon(); } // revert to the page-load state, but DON'T auto-match (that only runs on startup) — Match is manual here function revertAll() { if (!MODEL) return; if (!W.confirm("Revert every track to what it was when the page loaded?")) return; MODEL.tracks.forEach(t => resetTrack(t, true)); rebuild(true); } // #586: true = also restore the data-track boundary function guessCaseAll() { if (!MODEL) return; MODEL.tracks.forEach(t => { applyGuessTitle(t); t.title = u(koTrack(t.mi, t.ti).name); t.guessTitle = guessTitleStr(t); }); rerender(); Log.info('guess case → all titles'); } // titles carrying a featured-artist credit ("Foo feat. X", "ft.", "featuring") — detect so the // row can flag them and offer the split inline (#124). Needs a space/bracket/start before the // marker and whitespace/bracket/end after, so words like "soft"/"feats"/"drift" don't trip it. const FEAT_RE = /(?:^|[\s([])(?:feat|ft|featuring)\.?(?=[\s)\]]|$)/i; // integrated MB feature: pull "feat. X" out of titles into artist credits, then re-read + re-match async function guessFeatAll() { const ed = getEditor(); mediums().forEach(med => (u(med.tracks) || []).forEach(t => { try { ed.guessTrackFeatArtists(t); } catch (e) { try { ed.guessTrackFeatArtists(t, { type: 'click' }); } catch (e2) { Log.warn('guess feat failed', e2.message); } } })); await loadAndRender(); Log.info('guessed feat artists from titles'); } // single-track variant — fired by the per-track ⋔ split button (#124) async function guessFeatTrack(entry) { const ed = getEditor(), t = koTrack(entry.mi, entry.ti); try { ed.guessTrackFeatArtists(t); } catch (e) { try { ed.guessTrackFeatArtists(t, { type: 'click' }); } catch (e2) { Log.warn('guess feat failed', e2.message); } } await loadAndRender(); Log.info('guessed feat artists for track', entry.number); } // medium-scoped tools — each acts on one medium (chosen via the inline medium combo) async function swapMedium(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; _selfEdit = true; try { ed.swapTitlesWithArtists(m); } catch (e) { Log.warn('swap failed', e.message); } finally { _selfEdit = false; } await loadAndRender(); Log.info('swapped titles ↔ artists on medium', mi + 1); } function resetNumbers(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; _selfEdit = true; try { ed.resetTrackNumbers(m); } catch (e) { Log.warn('reset numbers failed', e.message); } finally { _selfEdit = false; } rebuild(); } /* ── #615 Merge / Split mediums ───────────────────────────────────────────── * majkinetor: "Merge all or subset of mediums (keep only one and move tracks to * it and reset # on it)" and "Split 1 medium on track (add medium, move tracks to * it and reset # on new one)". * * MB's release editor can't move a track between mediums (moveTrackUp/Down and * swapTracks stay inside one medium). What it DOES do, in its own "use the * tracklist of another medium" path (Medium.tracksLoaded), is copy tracks as NEW * tracks — no track id — that keep their recording. That's what happens here, from * the LIVE track values (so unsaved edits come along): title, length, artist * credit, data-track flag and the linked recording. Emptied mediums are removed * with MB's own removeMedium, numbers reset with its resetTrackNumbers. So the * edits are the ones a person doing it by hand produces: Edit medium (tracks * added, existing recordings reused) + Remove medium, or Add medium for a split. * Recordings are untouched; the moved tracks get new track MBIDs, as by hand. * * A medium with a disc ID has its tracklist locked by MB, so it's refused. */ const medHasToc = m => { try { return !!(m && typeof m.hasToc === 'function' && m.hasToc()); } catch (e) { return false; } }; const medLoaded = m => { try { return typeof m.loaded === 'function' ? !!u(m.loaded) : true; } catch (e) { return true; } }; const medLabel = (m, i) => { const n = medLoaded(m) ? u(m.tracks).length : null, nm = (u(m.name) || '').trim(); return 'Medium ' + (i + 1) + (nm ? ' — ' + nm : '') + (n == null ? ' (not loaded)' : ' (' + n + ' track' + (n === 1 ? '' : 's') + ')'); }; // load collapsed mediums' tracks (MB lazy-loads them) and wait until every one reports loaded async function loadMediums(meds) { const pending = meds.filter(m => !medLoaded(m)); if (!pending.length) return true; Log.info('#615 loading', pending.length, 'collapsed medium(s) first'); pending.forEach(m => { try { if (typeof m.loadTracks === 'function') m.loadTracks(); } catch (e) { Log.warn('loadTracks failed', e.message); } }); for (let n = 0; n < 150 && !pending.every(medLoaded); n++) await new Promise(r => setTimeout(r, 200)); const ok = pending.every(medLoaded); if (!ok) Log.warn('#615 medium(s) did not finish loading:', pending.filter(m => !medLoaded(m)).map(m => u(m.position)).join(', ')); return ok; } // a live KO track → constructor data for a NEW track that keeps the same recording (MB's own copy path) function trackCopyData(t) { const d = { name: u(t.name) || '', length: u(t.length), artistCredit: u(t.artistCredit), isDataTrack: !!u(t.isDataTrack) }; const rec = u(t.recording); if (!u(t.hasNewRecording) && rec && rec.gid) d.recording = rec; return d; } async function mergeMediums(indices) { const ed = getEditor(), all = mediums(), F = W.MB && W.MB.releaseEditor && W.MB.releaseEditor.fields; const sel = [...new Set(indices)].filter(i => all[i]).sort((a, b) => a - b); if (sel.length < 2) { toast('Merge: tick at least two mediums'); Log.warn('#615 merge: fewer than two mediums selected', JSON.stringify(indices)); return; } if (!F || !F.Track) { Log.err('#615 merge: MB release-editor fields not available'); toast('Merge failed — see Log'); return; } const meds = sel.map(i => all[i]), target = meds[0], sources = meds.slice(1); const locked = sel.filter(i => medHasToc(all[i])); if (locked.length) { toast('Merge: medium ' + locked.map(i => i + 1).join(', ') + ' has a disc ID — MB locks its tracklist'); Log.warn('#615 merge refused — disc ID on medium(s)', locked.map(i => i + 1).join(', ')); return; } if (!(await loadMediums(meds))) { toast('Merge: a medium did not load — try again'); return; } Log.info('#615 merge: mediums', sel.map(i => i + 1).join(', '), '→ medium', sel[0] + 1, '·', meds.map((m, k) => (sel[k] + 1) + ':' + u(m.tracks).length).join(' ')); _selfEdit = true; let moved = 0; try { const own = u(target.tracks), ownAudio = own.filter(t => !u(t.isDataTrack)), ownData = own.filter(t => u(t.isDataTrack)); const movAudio = [], movData = []; for (const src of sources) { for (const t of u(src.tracks)) { if (u(t.position) === 0) Log.warn('#615 merge: medium', u(src.position), 'pregap track "' + (u(t.name) || '') + '" becomes a regular track'); const nt = new F.Track(trackCopyData(t), target); (u(nt.isDataTrack) ? movData : movAudio).push(nt); Log.debug('#615 ' + u(src.position) + '.' + u(t.number) + ' "' + (u(t.name) || '') + '" → medium ' + (sel[0] + 1) + (nt.recording && u(nt.recording) && u(nt.recording).gid ? ' · recording ' + u(nt.recording).gid : ' · new recording')); moved++; } } // audio stays ahead of data tracks (MB keeps data tracks at the end of a medium) target.tracks([...ownAudio, ...movAudio, ...ownData, ...movData]); for (const src of sources.slice().reverse()) ed.removeMedium(src); // last first, so MB's renumbering of the rest stays simple ed.resetTrackNumbers(target); try { target.toc(null); } catch (e) {} } catch (e) { Log.err('#615 merge failed:', e.message); toast('Merge failed — see Log'); } finally { _selfEdit = false; } Log.ok('#615 merged', sources.length, 'medium(s) into medium', sel[0] + 1, '—', moved, 'track(s) moved, recordings kept, numbers reset'); toast('Merged ' + (sources.length + 1) + ' mediums into medium ' + (sel[0] + 1) + ' (' + moved + ' tracks moved)'); _mergeSel = null; await loadAndRender(); renderToolbar(); } // split medium `mi` so a NEW medium (right after it, same format) starts at track index `at` async function splitMedium(mi, at) { const ed = getEditor(), rel = release(), m = mediums()[mi], F = W.MB && W.MB.releaseEditor && W.MB.releaseEditor.fields; if (!m) return; if (!F || !F.Track || !F.Medium) { Log.err('#615 split: MB release-editor fields not available'); toast('Split failed — see Log'); return; } if (medHasToc(m)) { toast('Split: medium ' + (mi + 1) + ' has a disc ID — MB locks its tracklist'); Log.warn('#615 split refused — disc ID on medium', mi + 1); return; } if (!(await loadMediums([m]))) { toast('Split: the medium did not load — try again'); return; } const tracks = u(m.tracks); if (!(at > 0 && at < tracks.length)) { toast('Split: pick a track after the first one'); Log.warn('#615 split: bad split index', at, 'of', tracks.length); return; } const moving = tracks.slice(at); Log.info('#615 split: medium', mi + 1, 'at track', u(tracks[at].number), '"' + (u(tracks[at].name) || '') + '" —', moving.length, 'of', tracks.length, 'track(s) to a new medium', mi + 2); _selfEdit = true; try { const nm = new F.Medium({ position: u(m.position) + 1, format_id: u(m.formatID), name: '', tracks: [] }, rel); nm.tracks(moving.map((t, i) => new F.Track(Object.assign(trackCopyData(t), { position: i + 1, number: String(i + 1) }), nm))); m.tracks.removeAll(moving); const list = u(rel.mediums), idx = list.indexOf(m); list.slice(idx + 1).forEach(x => x.position(u(x.position) + 1)); // make room right after the split medium rel.mediums.splice(idx + 1, 0, nm); ed.resetTrackNumbers(m); ed.resetTrackNumbers(nm); try { m.toc(null); } catch (e) {} try { if (typeof nm.loaded === 'function') nm.loaded(true); if (typeof nm.collapsed === 'function') nm.collapsed(false); } catch (e) {} } catch (e) { Log.err('#615 split failed:', e.message); toast('Split failed — see Log'); } finally { _selfEdit = false; } Log.ok('#615 split medium', mi + 1, '→ new medium', mi + 2, 'with', moving.length, 'track(s), recordings kept, numbers reset on both'); toast('Split medium ' + (mi + 1) + ': ' + moving.length + ' tracks moved to new medium ' + (mi + 2)); await loadAndRender(); renderToolbar(); } // params state for the two tools (kept while the toolbar re-renders) let _mergeSel = null; // Set of medium indexes ticked for Merge (null → all) let _splitAt = null; // track index the Split tool starts the new medium at (null → middle) let _splitLoading = false; // a collapsed medium's tracks are being loaded for the Split params /* ── #456 pattern Track parser ───────────────────────────────────────────── * MB's native parser is rigid, so we roll our own. A one-line PATTERN describes * a tracklist line; we compile it to a regex and run every pasted line through * it, filling only the fields the pattern declares. Tokens: # T A L M (+ _ skip), * $X explicit, X[slice] positional (1-based; ~ = from end); separators match any * of a list (- – — / :); literals are literal; whitespace is elastic; text fields * are lazy (split-on-first) except the last which is greedy. Engine is unit-tested * in test/pattern-engine.test.mjs — keep the two in sync. */ const TP_FIELDS = { '#': 'pos', 'T': 'title', 'A': 'artist', 'L': 'length', 'M': 'medium' }; const TP_DUR = '\\d{1,2}:\\d{2}(?::\\d{2})?'; // #520 (majkinetor, live): "The pattern that worked is `#. A ‐ T - _ (L)`, // however, using `-` as separator doesn't work." His source used U+2010 // HYPHEN (‐) between artist and title — visually a hyphen, but a different // codepoint from plain ASCII `-` (U+002D). Typing the literal ‐ in the // pattern happened to match by coincidence; the natural, easiest-to-type // `-` didn't, because U+2010 wasn't in the separator class it expands to. // Adding it here means any of these separator KINDS in the pattern now // matches any of them in the source, same as en/em dash already do. const TP_SEPS = ['-', '‐', '–', '—', '/', ':']; const TP_PRESETS = ['#. T', '#. T - A (L)', '# T L', '# A - T', '# A - T (L)', 'T L']; // #. T - A (L) = the native parser's format let _tpPattern = '#. T'; // remembered across opens this session const tpEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const tpUsable = out => !!out && Object.values(out).some(v => v && String(v).trim()); function tpTokenize(pattern, seps) { const segs = [], sepSet = new Set(seps); const isFieldLetter = c => c === '#' || c === 'M' || c === 'T' || c === 'A' || c === 'L'; const readSlice = (str, i) => { if (str[i] !== '[') return null; const close = str.indexOf(']', i); if (close < 0) return null; const inner = str.slice(i + 1, close); let m; // colon form [FROM:TO] — FROM is a numeric position (~ = from end) OR a literal char to start // AFTER; TO is a literal char to stop BEFORE (empty = to end). e.g. #[1:.] (pos 1 → first dot), // L[(:)] (first "(" → first ")"), both excluding the delimiters. A leading ~ on a CHAR (not a // position) means the LAST occurrence of that char, not the first — e.g. L[~(:)] for a title // that has its own "(...)" before the real "(length)" (#456). const colon = inner.indexOf(':'); if (colon >= 0) { let left = inner.slice(0, colon); const hadTilde = left[0] === '~'; if (hadTilde) left = left.slice(1); let toDelim = inner.slice(colon + 1); const toLast = toDelim[0] === '~'; if (toLast) toDelim = toDelim.slice(1); const slice = /^\d*$/.test(left) ? { a: left === '' ? null : parseInt(left, 10), fromEnd: hadTilde, toDelim, toLast } // numeric position (~ = from end) : { fromDelim: left, fromLast: hadTilde, toDelim, toLast }; // literal char (~ = last occurrence) return { slice, next: close + 1 }; } // dash form [a-b] / [a] — numeric char range (1-based inclusive; ~ = from the end) if ((m = /^(~?)(\d*)(-?)(\d*)$/.exec(inner))) { const a = m[2] === '' ? null : parseInt(m[2], 10), b = m[4] === '' ? null : parseInt(m[4], 10); return { slice: m[3] === '-' ? { a, b, fromEnd: m[1] === '~' } : { a, b: a, fromEnd: m[1] === '~' }, next: close + 1 }; } return null; }; for (let i = 0; i < pattern.length;) { const c = pattern[i]; if (c === '$') { const letter = pattern[i + 1]; if (isFieldLetter(letter)) { const sl = readSlice(pattern, i + 2); segs.push({ kind: 'field', field: TP_FIELDS[letter], slice: sl ? sl.slice : null }); i = sl ? sl.next : i + 2; continue; } segs.push({ kind: 'lit', text: '$' }); i++; continue; } if (c === '_') { segs.push({ kind: 'skip' }); i++; continue; } if (isFieldLetter(c)) { const letterish = ch => ch && /[A-Za-z]/.test(ch); if (!(letterish(pattern[i - 1]) || (c !== '#' && letterish(pattern[i + 1])))) { const sl = readSlice(pattern, i + 1); segs.push({ kind: 'field', field: TP_FIELDS[c], slice: sl ? sl.slice : null }); i = sl ? sl.next : i + 1; continue; } } if (sepSet.has(c)) { segs.push({ kind: 'sep' }); i++; continue; } if (/\s/.test(c)) { let j = i; while (j < pattern.length && /\s/.test(pattern[j])) j++; segs.push({ kind: 'ws' }); i = j; continue; } segs.push({ kind: 'lit', text: c }); i++; } return segs; } function tpCompile(pattern, opts = {}) { const seps = opts.separators || TP_SEPS; const segs = tpTokenize(pattern, seps); // #522 (majkinetor, live): "In pattern tracker, `#. A ` doesn't work // (space at the end)" — also `#. A - `. A dangling ws/sep token at // either edge, with no field or literal past it, compiles to a HARD // requirement ("\s+" or a real separator char) right at the string // boundary — but exec() trims the input first, so a trimmed line can // never have trailing whitespace to offer, and a real line never ends // on a bare separator either. The regex already anchors both ends with // its own elastic `\s*` (below), so a leading/trailing ws or sep token // adds nothing but an impossible-to-satisfy demand — drop any run of // them from either edge before compiling. while (segs.length && (segs[0].kind === 'ws' || segs[0].kind === 'sep')) segs.shift(); while (segs.length && (segs[segs.length - 1].kind === 'ws' || segs[segs.length - 1].kind === 'sep')) segs.pop(); const fieldSegs = segs.filter(s => s.kind === 'field'); const fields = new Set(fieldSegs.map(s => s.field)); const sliced = fieldSegs.filter(s => s.slice); const sepClass = '(?:' + seps.map(tpEsc).join('|') + ')'; if (fieldSegs.length > 0 && sliced.length === fieldSegs.length) { // slice-mode (all fields positional) return { fields, exec(line) { const s = String(line), out = {}; for (const seg of fieldSegs) { const { a, b, fromEnd, fromDelim, fromLast, toDelim, toLast } = seg.slice; let start, end; if (fromDelim != null) { const fi = fromDelim === '' ? 0 : (fromLast ? s.lastIndexOf(fromDelim) : s.indexOf(fromDelim)); if (fi < 0) { out[seg.field] = ''; continue; } start = fi + fromDelim.length; } else if (fromEnd) start = a == null ? 0 : s.length - a; else start = (a == null ? 1 : a) - 1; start = Math.max(0, start); if (toDelim != null) { let ti; if (toDelim === '') ti = -1; else if (toLast) { ti = s.lastIndexOf(toDelim); if (ti < start) ti = -1; } else ti = s.indexOf(toDelim, start); end = ti < 0 ? s.length : ti; } else if (fromEnd) end = b == null ? s.length : s.length - b + 1; else end = b == null ? s.length : b; out[seg.field] = s.slice(start, Math.max(start, end)).trim(); } return tpUsable(out) ? out : null; } }; } // one text field is greedy so the split lands on the FIRST separator (default) — or the LAST // one when splitLast is on (#456 v2 ‹first|last› toggle): then the FIRST text field is greedy. const textSegs = fieldSegs.filter(s => s.field === 'title' || s.field === 'artist'); const greedyText = opts.splitLast ? textSegs[0] : textSegs[textSegs.length - 1]; let re = '^\\s*'; for (let idx = 0; idx < segs.length; idx++) { const seg = segs[idx]; if (seg.kind === 'ws') re += '\\s+'; else if (seg.kind === 'lit') re += /\s/.test(seg.text) ? '\\s+' : tpEsc(seg.text); else if (seg.kind === 'sep') re += '\\s*' + sepClass + '\\s*'; else if (seg.kind === 'skip') re += '.*?'; else if (seg.kind === 'field') { const f = seg.field; if (seg.slice && (seg.slice.toDelim != null || seg.slice.fromDelim != null)) { const sl = seg.slice; // skip to fromDelim: lazy .*? = first occurrence, greedy .* = last (~) — mirrors slice-mode's indexOf/lastIndexOf re += (sl.fromDelim ? (sl.fromLast ? '.*' : '.*?') + tpEsc(sl.fromDelim) : '') + (sl.toDelim ? (sl.toLast ? '(.+)' : '(.+?)') + tpEsc(sl.toDelim) : '(.+)'); } // X[from:to] in flow: skip-to-fromDelim, capture, toDelim else if (f === 'pos') re += '([A-Za-z]?\\d+(?:[-.]\\d+)?)'; else if (f === 'medium') re += '(\\d+)'; else if (f === 'length') re += '(' + TP_DUR + ')'; else { // #522 follow-up (majkinetor, live): "#. T (_" on // "1. Ndzirombi (Conflict Monger) - Zig Zag Band (5:21)" gave the // WHOLE line minus the length as the title — expected just // "Ndzirombi". Root cause: T was the only/last text field, so it // compiled greedy; greedy backtracks from the END of the string, // so with an unconstrained "(_" skip after it (matches ANY "("), // it finds the LAST "(" in the source first, not the first one. // A literal immediately following a field is the user explicitly // marking where that field should stop — always lazy there // (finds the FIRST occurrence), regardless of the "is it the // last text field" heuristic that governs plain field-to-field // splits (# A - T with nothing after T still takes the rest). let j = idx + 1; while (j < segs.length && segs[j].kind === 'ws') j++; const followedByLiteral = j < segs.length && segs[j].kind === 'lit'; re += (seg === greedyText && !followedByLiteral) ? '(.+)' : '(.+?)'; } } } re += '\\s*$'; const rx = new RegExp(re), order = fieldSegs.map(s => s.field); return { fields, exec(line) { const m = rx.exec(String(line).trim()); if (!m) return null; const out = {}; order.forEach((f, i) => { out[f] = (m[i + 1] || '').trim(); }); return tpUsable(out) ? out : null; } }; } function openParser(mi) { const ed = getEditor(), m = mediums()[mi]; if (!m) return; try { ed.openTrackParser(m); } catch (e) { Log.warn('open parser failed', e.message); } } function openTrackPatternParser(mi) { document.getElementById('tc-tpppop')?.remove(); let curMi = (mi != null ? mi : toolMedium()); _tpPattern = '#. T - A (L)'; // #456: open like the native parser — prefilled current tracklist in this format let rows = []; // [{ raw, override }] let _splitLast = false; // #456 v2 ‹first|last›: which separator instance a text field splits on const tracks = () => u(mediums()[curMi].tracks) || []; // the current medium's tracklist rendered in the #. T - A (L) format, to seed the paste box const acStr = t => (liveNames(t) || []).map(n => (u(n.name) || (u(n.artist) && u(u(n.artist).name)) || '') + (u(n.joinPhrase) || '')).join('').trim(); const currentText = () => tracks().map((t, i) => { const num = u(t.number) || (i + 1); const title = u(t.name) || ''; const artist = acStr(t); const len = u(t.formattedLength) || ''; return `${num}. ${title}` + (artist ? ` - ${artist}` : '') + (len ? ` (${len})` : ''); }).join('\n'); const compiled = new Map(); // pattern+mode → compiled (cached) const compileFor = ov => { const key = (_splitLast ? 'L\x01' : 'F\x01') + (ov || _tpPattern); if (!compiled.has(key)) { try { compiled.set(key, tpCompile(ov || _tpPattern, { separators: TP_SEPS, splitLast: _splitLast })); } catch (e) { compiled.set(key, null); } } return compiled.get(key); }; const parseRow = r => { const c = compileFor(r.override && r.override.trim()); try { return c ? c.exec(r.raw) : null; } catch (e) { return null; } }; const p = document.createElement('div'); p.id = 'tc-tpppop'; p.className = 'tc-tpppop'; const medSel = mediums().length > 1 ? `` : `Medium ${curMi + 1}`; p.innerHTML = `
Pattern parser${medSel}
Pattern ${TP_PRESETS.map(x => ``).join('')}
patternraw#artisttitlelength
`; document.body.appendChild(p); const $ = s => p.querySelector(s); const ta = $('.tc-tpp-ta'), tbody = $('.tc-tpp-tbl tbody'), patIn = $('.tc-tpp-pi'); const src = $('.tc-tpp-src'), srctgl = $('.tc-tpp-srctgl'); // #456: the paste box only seeds the rows, so fold it away once there's content (the raw // column keeps the source visible); the caret toggles it back to re-paste/edit. const setSrc = collapsed => { src.classList.toggle('tc-collapsed', collapsed); const n = rows.length; srctgl.textContent = (collapsed ? '▸ ' : '▾ ') + (collapsed && n ? `Paste tracklist (${n} line${n !== 1 ? 's' : ''})` : 'Paste tracklist'); }; const close = () => { p.remove(); chipBar.remove(); document.removeEventListener('mousedown', outsideHide, true); }; function syncRows() { const lines = ta.value.split('\n'); // keep overrides aligned to line index across edits rows = lines.map((raw, i) => ({ raw, override: (rows[i] && rows[i].override) || '' })) .filter((r, i) => !(i === lines.length - 1 && r.raw.trim() === '' && lines.length > 1)); } const DOT = { exact: '#2e7d32', over: '#b26a00', none: '#c62828' }; function render() { const nT = tracks().length; tbody.innerHTML = ''; rows.forEach((r, i) => { const parsed = parseRow(r); const state = !parsed ? 'none' : (r.override && r.override.trim() ? 'over' : 'exact'); const tr = document.createElement('tr'); tr.className = 'tc-tpp-tr' + (i >= nT ? ' notrk' : ''); const cell = v => `${esc(v || '')}`; tr.innerHTML = `` + `` + `${esc(r.raw) || '(empty)'}` + cell(parsed && parsed.pos) + cell(parsed && parsed.artist) + cell(parsed && parsed.title) + cell(parsed && parsed.length); const ov = tr.querySelector('.tc-tpp-ov'), ovwrap = tr.querySelector('.tc-tpp-ovwrap'); ov.oninput = () => { r.override = ov.value; ovwrap.classList.toggle('has', !!ov.value); const pr = parseRow(r); const st = !pr ? 'none' : (r.override.trim() ? 'over' : 'exact'); tr.querySelector('.tc-tpp-dot span').style.background = DOT[st]; const cells = tr.querySelectorAll('.tc-tpp-c'); [pr && pr.pos, pr && pr.artist, pr && pr.title, pr && pr.length].forEach((v, k) => { cells[k].textContent = v || ''; cells[k].title = v || ''; }); refreshFoot(); }; tr.querySelector('.tc-tpp-ovclr').onclick = () => { r.override = ''; render(); }; tbody.appendChild(tr); }); refreshFoot(); } function stats() { const nT = tracks().length; let matched = 0, unmatched = 0; rows.forEach(r => { if (r.raw.trim() === '') return; parseRow(r) ? matched++ : unmatched++; }); return { nT, matched, unmatched, n: matched + unmatched }; } function refreshFoot() { const s = stats(), cnt = $('.tc-tpp-cnt'), ok = $('.tc-tpp-ok'), menu = $('.tc-tpp-menu'); cnt.className = 'tc-tpp-cnt' + (s.unmatched ? ' warn' : s.matched ? ' ok' : ''); cnt.textContent = !s.n ? `${s.nT} track${s.nT !== 1 ? 's' : ''} in Medium ${curMi + 1} — paste a tracklist` : `${s.matched} matched · ${s.unmatched} unmatched → ${s.nT} track${s.nT !== 1 ? 's' : ''}` + (s.n > s.nT ? ` (+${s.n - s.nT} would need new tracks)` : ''); ok.disabled = menu.disabled = !s.matched; ok.textContent = `Apply ${Math.min(s.matched, s.nT)} → Medium ${curMi + 1}`; } // Apply captured fields (only the ones each row's pattern produced) to tracks in row order. function apply(which, addMissing) { const s = stats(); if (addMissing && s.n > s.nT) addTracks(curMi, s.n - s.nT); const nT = tracks().length; let wrote = 0; _selfEdit = true; // #483: bulk-apply is our own write, and rebuild(true) below already does the one resync it needs try { rows.forEach((r, i) => { if (i >= nT) return; const pr = parseRow(r); if (!pr) return; const entry = { mi: curMi, ti: i }; if ((which === 'all' || which === 'title') && pr.title != null && pr.title !== '') setTitle(entry, pr.title); if ((which === 'all' || which === 'artist') && pr.artist != null && pr.artist !== '') { try { koTrack(curMi, i).artistCredit({ names: [{ artist: null, name: pr.artist, joinPhrase: '' }] }); } catch (e) { Log.warn('set artist failed', e.message); } } if ((which === 'all' || which === 'length') && pr.length && lpValid(pr.length)) setLength(entry, pr.length); if ((which === 'all' || which === 'pos') && pr.pos != null && pr.pos !== '') setNumber(entry, pr.pos); wrote++; }); } finally { _selfEdit = false; } rebuild(true); Log.info('track parser: applied', which, 'from pattern', JSON.stringify(_tpPattern), 'to', wrote, 'row(s) on medium', curMi + 1); toast(`Applied ${wrote} row${wrote !== 1 ? 's' : ''} to Medium ${curMi + 1}`); close(); } function openMenu() { p.querySelector('.tc-tpp-mpop')?.remove(); const s = stats(); const m = document.createElement('div'); m.className = 'tc-tpp-mpop'; const item = (label, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-tpp-mi'; b.textContent = label; b.onclick = () => { m.remove(); fn(); }; return b; }; m.append(item('Only titles', () => apply('title', false)), item('Only artists', () => apply('artist', false)), item('Only lengths', () => apply('length', false)), item('Only track #s', () => apply('pos', false))); if (s.n > s.nT) { const hr = document.createElement('div'); hr.className = 'tc-tpp-mhr'; m.append(hr, item(`Add ${s.n - s.nT} track${s.n - s.nT !== 1 ? 's' : ''}, then apply all`, () => apply('all', true))); } $('.tc-tpp-acts').appendChild(m); const off = e => { if (!m.contains(e.target) && !e.target.closest('.tc-tpp-menu')) { m.remove(); document.removeEventListener('mousedown', off, true); } }; setTimeout(() => document.addEventListener('mousedown', off, true), 0); } ta.oninput = () => { syncRows(); render(); }; ta.onblur = () => { if (ta.value.trim()) setSrc(true); }; // fold away once seeded srctgl.onclick = () => { const collapse = !src.classList.contains('tc-collapsed'); setSrc(collapse); if (!collapse) ta.focus(); }; const piwrap = $('.tc-tpp-piwrap'); const syncClr = () => piwrap.classList.toggle('has', !!patIn.value); // #456 show the clear-✕ only when there's a pattern to clear patIn.oninput = () => { _tpPattern = patIn.value; compiled.clear(); syncClr(); render(); }; $('.tc-tpp-piclr').onclick = () => { patIn.value = ''; _tpPattern = ''; compiled.clear(); syncClr(); render(); patIn.focus(); }; syncClr(); p.querySelectorAll('.tc-tpp-chip').forEach(c => c.onclick = () => { patIn.value = c.dataset.p; _tpPattern = c.dataset.p; compiled.clear(); syncClr(); render(); patIn.focus(); }); $('.tc-tpp-split').onclick = () => { _splitLast = !_splitLast; $('.tc-tpp-split').innerHTML = 'split: ' + (_splitLast ? 'last' : 'first') + ''; render(); }; // #456 round 5 — iterative refinement: lock the CURRENT pattern onto every row that's still on // «default» and already matches, so trying a new pattern afterward only affects what's left. // Repeat with a different pattern until every row is solved. $('.tc-tpp-freeze').onclick = () => { let n = 0; rows.forEach(r => { if (r.override && r.override.trim()) return; if (parseRow(r)) { r.override = _tpPattern; n++; } }); render(); toast(n ? `Froze ${n} matched row${n !== 1 ? 's' : ''} to this pattern` : 'No still-default rows match this pattern'); }; /* #456 v2 — interactive fix: select a span in a raw cell → a chip bar pops above it to bind * that span to a field for THAT row, writing a slice into its pattern cell (T[a-b], …). */ const FIELD_CHIPS = [['#', 'pos'], ['A', 'artist'], ['T', 'title'], ['L', 'length']]; const chipBar = document.createElement('div'); chipBar.className = 'tc-tpp-chipbar'; chipBar.style.display = 'none'; chipBar.innerHTML = FIELD_CHIPS.map(([g, f]) => ``).join('') + ''; document.body.appendChild(chipBar); // on body, not the modal — the modal's transform would otherwise re-base position:fixed (#456) let _selCtx = null; // { rowIdx, a, b } — 1-based inclusive char range in the raw text const hideChips = () => { chipBar.style.display = 'none'; _selCtx = null; }; // char offsets of the current selection within a raw cell (the cell text == the row's raw) function rawSelection() { const sel = window.getSelection(); if (!sel || !sel.rangeCount || sel.isCollapsed) return null; const range = sel.getRangeAt(0); const cell = (range.startContainer.nodeType === 1 ? range.startContainer : range.startContainer.parentElement)?.closest('.tc-tpp-raw'); if (!cell || !cell.contains(range.endContainer)) return null; const tr = cell.closest('tr'); const rowIdx = tr ? [...tbody.children].indexOf(tr) : -1; if (rowIdx < 0) return null; const pre = document.createRange(); pre.selectNodeContents(cell); pre.setEnd(range.startContainer, range.startOffset); const start = pre.toString().length, len = range.toString().length; if (!len) return null; return { rowIdx, a: start + 1, b: start + len, rect: range.getBoundingClientRect() }; // 1-based inclusive } tbody.addEventListener('mouseup', () => setTimeout(() => { const s = rawSelection(); if (!s) { hideChips(); return; } _selCtx = s; chipBar.style.display = 'flex'; const bw = chipBar.offsetWidth || 160; chipBar.style.left = Math.max(6, Math.min(s.rect.left + s.rect.width / 2 - bw / 2, innerWidth - bw - 6)) + 'px'; chipBar.style.top = Math.max(6, s.rect.top - chipBar.offsetHeight - 6) + 'px'; }, 0)); // set/replace a field's slice in a row's override (overrides built this way are pure slice-mode) function bindSlice(row, letter, a, b) { const esc2 = letter === '#' ? '\\#' : letter; let ov = (row.override || '').replace(new RegExp('\\$?' + esc2 + '\\[[^\\]]*\\]\\s*', 'g'), '').replace(/\s+/g, ' ').trim(); row.override = (ov ? ov + ' ' : '') + letter + '[' + a + '-' + b + ']'; } chipBar.addEventListener('mousedown', e => e.preventDefault()); // keep the text selection alive through the click chipBar.querySelectorAll('button').forEach(b => b.onclick = () => { if (!_selCtx) return; const row = rows[_selCtx.rowIdx]; if (!row) return; if (b.dataset.clr) row.override = ''; else bindSlice(row, b.dataset.g, _selCtx.a, _selCtx.b); hideChips(); window.getSelection()?.removeAllRanges(); render(); }); const outsideHide = e => { if (!chipBar.contains(e.target) && !e.target.closest('.tc-tpp-raw')) hideChips(); }; document.addEventListener('mousedown', outsideHide, true); // removed in close() $('.tc-tpp-ok').onclick = () => apply('all', false); $('.tc-tpp-menu').onclick = openMenu; $('.tc-tpp-x').onclick = close; let _maxed = false, _prevBox = null; // maximize toggle: fill the viewport, restore to the prior box $('.tc-tpp-max').onclick = () => { const btn = $('.tc-tpp-max'); if (!_maxed) { _prevBox = { left: p.style.left, top: p.style.top, width: p.style.width, height: p.style.height, transform: p.style.transform }; p.style.transform = 'none'; p.style.left = '2vw'; p.style.top = '2vh'; p.style.width = '96vw'; p.style.height = '94vh'; _maxed = true; btn.textContent = '❐'; btn.title = 'Restore'; } else { Object.assign(p.style, _prevBox); _maxed = false; btn.textContent = '⛶'; btn.title = 'Maximize / restore'; } }; const medEl = $('.tc-tpp-med'); if (medEl) medEl.onchange = () => { curMi = parseInt(medEl.value, 10) || 0; render(); }; p.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); if (!$('.tc-tpp-ok').disabled) apply('all', false); } }; $('.tc-tpp-hd').onmousedown = e => { if (e.target.closest('button, select')) return; e.preventDefault(); const r = p.getBoundingClientRect(); p.style.transform = 'none'; p.style.left = r.left + 'px'; p.style.top = r.top + 'px'; // #456: detach from the centering transform so the drag math is absolute (else it clamps early) const ox = e.clientX - r.left, oy = e.clientY - r.top; const mv = ev => { p.style.left = Math.max(0, Math.min(ev.clientX - ox, innerWidth - p.offsetWidth)) + 'px'; p.style.top = Math.max(0, Math.min(ev.clientY - oy, innerHeight - p.offsetHeight)) + 'px'; }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }; // #456: seed the paste box with the current tracklist (like the native parser), so it opens showing what's there. // Don't auto-focus when prefilled — focusing then clicking elsewhere would blur→auto-collapse the box. const seed = currentText(); if (seed) { ta.value = seed; syncRows(); render(); } else { render(); ta.focus(); } } /* ── Track length parser (#455) ──────────────────────────────────────────── * MB's native track parser wants a specific format; lengths copied off a site * (Bandcamp, foobar, …) rarely fit. This greps every DURATION out of arbitrary * pasted text — jesus2099's battle-tested regex, which only matches things * shaped like a time so track numbers / titles / years are ignored — into a * REVIEWABLE, editable list (add / delete / edit rows, invalid times blocked), * then writes them to the medium's tracks in order. Nothing is written until * you commit. */ const LP_DUR_RE = /(?:(?:(\b\d\b)[°h])?(\b\d{1,3}\b)[′’'m])?(\b\d{2}\b)[″”"s]|(?:(?:(\b\d\b):)?(\b\d{1,3}\b):)(\b\d{2}\b)/g; // Extract durations (in reading order) → [{ value, raw }]. value is m:ss, or // h:mm:ss when hours are present (hours 0 collapse back to m:ss). function lpParse(text) { if (!text) return []; const item = new RegExp(LP_DUR_RE.source); return (String(text).match(LP_DUR_RE) || []).map(raw => { const m = raw.match(item); const h = +(m[1] || m[4] || 0), mn = +(m[2] || m[5] || 0), s = +(m[3] || m[6] || 0); const value = h > 0 ? `${h}:${String(mn).padStart(2, '0')}:${String(s).padStart(2, '0')}` : `${mn}:${String(s).padStart(2, '0')}`; return { value, raw }; }); } // A valid MB length: m:ss (ss<60; minutes unbounded) or h:mm:ss (mm<60, ss<60). const lpValid = v => /^\d+:[0-5]\d$/.test(String(v).trim()) || /^\d+:[0-5]\d:[0-5]\d$/.test(String(v).trim()); // #621 (majkinetor): "I should be able to commit this (3 tracks missing len)". A source that // simply has no length for a track is not an error — the row stays EMPTY and that track keeps // whatever length it already has (nothing is cleared). Only a non-empty value that isn't a // time is invalid. const lpEmpty = v => !String(v == null ? '' : v).trim(); const lpBad = v => !lpEmpty(v) && !lpValid(v); // #455.1 fetch an external page's HTML (any host — @connect *) via GM. function lpFetchHtml(url) { return new Promise((resolve, reject) => { const gmx = (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || (typeof GM !== 'undefined' && GM && GM.xmlHttpRequest); if (!gmx) { reject(new Error('no GM_xmlhttpRequest')); return; } try { gmx({ method: 'GET', url, timeout: 20000, onload: r => (r.status >= 200 && r.status < 400) ? resolve(r.responseText || '') : reject(new Error('HTTP ' + r.status)), onerror: () => reject(new Error('fetch failed')), ontimeout: () => reject(new Error('timeout')) }); } catch (e) { reject(e); } }); } // #455.1 pull the tracklist text out of fetched HTML: the SMALLEST element whose text // still holds at least `want` durations (trims nav/footer noise), else the whole body. function lpExtractFromHtml(html, want) { let doc; try { doc = new DOMParser().parseFromString(html, 'text/html'); } catch (e) { return ''; } if (!doc || !doc.body) return ''; doc.querySelectorAll('script,style,noscript,svg').forEach(e => e.remove()); // textContent glues adjacent cells/rows ("12Song 1213:55") — durations lose their word // boundary and stop matching. Insert separators: table cells → space, block/rows → newline. doc.querySelectorAll('td,th').forEach(e => e.append(document.createTextNode(' '))); doc.querySelectorAll('tr,li,p,div,br,h1,h2,h3,h4,h5,h6,section,article').forEach(e => e.append(document.createTextNode('\n'))); const count = t => (String(t).match(LP_DUR_RE) || []).length; let best = doc.body, bestLen = (doc.body.textContent || '').length + 1; if (want > 0) for (const el of doc.body.querySelectorAll('*')) { const t = el.textContent || ''; if (t.length > bestLen) continue; if (count(t) >= want) { best = el; bestLen = t.length; } } return (best.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{2,}/g, '\n'); } // #455.1 external links to offer: the release's link rows (Release-Info / relationships // tab), else — for a saved release — its url relationships from the web service. async function lpExternalLinks() { const dom = (typeof linkRows === 'function' ? linkRows() : []).map(l => l.url).filter(Boolean); if (dom.length) return [...new Set(dom)]; const gid = (location.pathname.match(/release\/([0-9a-f-]{36})/i) || [])[1]; if (!gid) return []; const res = await wsJson(`${ORIGIN}/ws/2/release/${gid}?inc=url-rels&fmt=json`, { label: 'release url-rels' }); return res.json ? [...new Set((res.json.relations || []).map(r => r.url && r.url.resource).filter(Boolean))] : []; } // #455.2 append the source URL to the release edit note (React-safe native setter). function lpNoteSource(url) { const ta = document.getElementById('edit-note-text'); if (!ta || !url) return; const line = 'Track lengths from ' + url; const cur = ta.value || ''; if (cur.includes(line)) return; const kept = cur.replace(/\s+$/, ''); const val = kept ? kept + '\n' + line : line; try { Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set.call(ta, val); } catch (e) { ta.value = val; } ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } function openLengthParser(mi) { document.getElementById('tc-lppop')?.remove(); let curMi = (mi != null ? mi : toolMedium()); let items = []; // [{ value, raw }] — the editable list (item i → track i) let sourceUrl = null; // set when the lengths came from an external link → stamped in the edit note const p = document.createElement('div'); p.id = 'tc-lppop'; p.className = 'tc-lppop'; const medSel = mediums().length > 1 ? `` : `Medium ${curMi + 1}`; p.innerHTML = `
Length parser${medSel}
Parse from external link — or paste one (Ctrl+V)
…
`; document.body.appendChild(p); const cw = p.offsetWidth || 832, ch = p.offsetHeight || 460; p.style.left = Math.max(8, Math.round((innerWidth - cw) / 2)) + 'px'; p.style.top = Math.max(8, Math.round((innerHeight - ch) / 2)) + 'px'; const $ = s => p.querySelector(s); const ta = $('.tc-lp-ta'), listEl = $('.tc-lp-list'), chooser = $('.tc-lp-choose'), errBadge = $('.tc-lp-err'), backBtn = $('.tc-lp-back'); const tracks = () => u(mediums()[curMi].tracks) || []; const trackTitle = i => { const t = tracks()[i]; return t ? (u(t.name) || '') : ''; }; const close = () => { document.removeEventListener('paste', onPaste, true); p.remove(); }; // onPaste: #622, below // the chooser overlays the empty box; the header "‹ Sources" button appears once a // source is picked so you can go back and try another (#455: some links, e.g. Spotify, // have no parsable text). setChooser(true) forces it back even over fetched content. const setChooser = show => { chooser.style.display = show ? '' : 'none'; backBtn.style.display = show ? 'none' : ''; }; const showChooser = () => setChooser(!ta.value && !items.length); backBtn.onclick = () => { setChooser(true); }; // ── source chooser (overlaid on the empty textarea, beside the list) ── chooser.querySelector('[data-o="text"]').onclick = () => { setChooser(false); ta.focus(); }; chooser.querySelector('[data-o="clip"]').onclick = () => { if (navigator.clipboard && navigator.clipboard.readText) navigator.clipboard.readText().then(t => { ta.value = t; sourceUrl = null; items = lpParse(t); render(); showChooser(); ta.focus(); }).catch(() => { setChooser(false); ta.focus(); toast('Clipboard blocked — paste into the box'); }); else { setChooser(false); ta.focus(); toast('Clipboard unavailable — paste into the box'); } }; // external links → one favicon each; clicking fetches + parses immediately lpExternalLinks().then(urls => { const favs = chooser.querySelector('.tc-lp-favs'); if (!urls.length) { favs.innerHTML = `no external links on this release`; return; } favs.innerHTML = urls.map(x => { let h = ''; try { h = new URL(x).hostname.replace(/^www\./, ''); } catch (e) {} return `${esc(h)}`; }).join(''); favs.querySelectorAll('.tc-lp-fav').forEach(im => { im.onclick = () => fetchLink(im.dataset.u); im.onerror = () => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-lp-favtxt'; b.textContent = im.dataset.h || 'link'; b.title = im.title; b.onclick = () => fetchLink(im.dataset.u); im.replaceWith(b); }; // favicon blocked/offline → clickable hostname chip }); }); async function fetchLink(url) { setChooser(false); ta.disabled = true; ta.value = 'Fetching the page…\n' + url; let html; try { html = await lpFetchHtml(url); } catch (e) { ta.disabled = false; ta.value = ''; items = []; render(); showChooser(); toast(`Couldn't fetch that page (${e.message}) — try another link or paste`); return; } ta.disabled = false; const text = lpExtractFromHtml(html, tracks().length); ta.value = text; sourceUrl = url; items = lpParse(text); render(); showChooser(); if (!items.length) toast('No durations found on that page — try another link or paste the text'); } ta.oninput = () => { sourceUrl = null; items = lpParse(ta.value); render(); showChooser(); }; // typing overrides an external source // #622 (majkinetor): "CTRL+v like in AS so external link is parsed the same way release links // are parsed". A real paste gesture carries its data with no clipboard permission (Art Station // #554), so Ctrl+V of a single URL anywhere in the open parser — the chooser, the box, the list // — goes through fetchLink exactly like clicking one of the release's link icons, edit-note // credit included. Any other text pastes as it always did; on the chooser (where the box is // covered and nothing has focus) it goes into the box, like "Paste from clipboard". A paste // into some other field on the page is none of our business. const LP_URL_RE = /^https?:\/\/\S+$/i; function onPaste(e) { if (!p.isConnected) { document.removeEventListener('paste', onPaste, true); return; } // replaced by a re-open, which skips close() const t = e.target; const inside = !!(t && t.closest && t.closest('#tc-lppop')); const typing = !!(t && (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) || t.isContentEditable)); if (typing && !inside) return; const txt = ((e.clipboardData && e.clipboardData.getData('text')) || '').trim(); if (!txt) return; if (LP_URL_RE.test(txt)) { e.preventDefault(); e.stopPropagation(); Log.info('length parser: pasted a link — reading track lengths from', txt); fetchLink(txt); return; } if (!typing && chooser.style.display !== 'none') { e.preventDefault(); e.stopPropagation(); Log.debug('length parser: pasted', txt.length, 'chars of text onto the chooser'); setChooser(false); ta.value = txt; sourceUrl = null; items = lpParse(txt); render(); showChooser(); ta.focus(); } } document.addEventListener('paste', onPaste, true); function render() { const nT = tracks().length; listEl.innerHTML = ''; items.forEach((it, i) => { const row = document.createElement('div'); row.className = 'tc-lp-row'; row.innerHTML = `${i < nT ? (i + 1) + '. ' + esc(trackTitle(i) || '—') : '— (no track)'}`; const inp = noPw(document.createElement('input')); inp.type = 'text'; inp.className = 'tc-lp-val' + (lpBad(it.value) ? ' bad' : ''); inp.value = it.value; inp.placeholder = '—'; // #621 an empty row is allowed: that track keeps its current length const tip = () => { inp.title = lpEmpty(inp.value) ? 'empty — this track keeps its current length' : (it.raw ? ('detected: ' + it.raw) : ''); }; tip(); inp.oninput = () => { it.value = inp.value; inp.classList.toggle('bad', lpBad(inp.value)); tip(); refreshFoot(); }; // clears the red the moment it's valid (or emptied) const add = document.createElement('button'); add.type = 'button'; add.className = 'tc-lp-add'; add.textContent = '+'; add.title = 'insert a length below (rows shift down)'; add.onclick = () => { items.splice(i + 1, 0, { value: '', raw: '' }); render(); listEl.querySelectorAll('.tc-lp-val')[i + 1]?.focus(); }; const del = document.createElement('button'); del.type = 'button'; del.className = 'tc-lp-del'; del.textContent = '✕'; del.title = 'delete (rows below shift up)'; del.onclick = () => { items.splice(i, 1); render(); }; row.append(inp, add, del); listEl.appendChild(row); }); const end = document.createElement('button'); end.type = 'button'; end.className = 'tc-lp-addend'; end.textContent = items.length ? '+ add length' : '+ add a length (or paste into the box on the left)'; end.onclick = () => { items.push({ value: '', raw: '' }); render(); const all = listEl.querySelectorAll('.tc-lp-val'); all[all.length - 1]?.focus(); }; listEl.appendChild(end); refreshFoot(); } function refreshFoot() { const cntEl = $('.tc-lp-cnt'), okBtn = $('.tc-lp-ok'); const nT = tracks().length, n = items.length, bad = items.filter(it => lpBad(it.value)).length; // #621 empty rows are fine; only the ones that land on a track count ("left as is") const mapped = items.slice(0, nT), empty = mapped.filter(it => lpEmpty(it.value)).length, toApply = mapped.length - empty; cntEl.className = 'tc-lp-cnt' + (bad ? ' bad' : n === nT ? ' ok' : n ? ' warn' : ''); cntEl.textContent = (!n ? `${nT} track${nT !== 1 ? 's' : ''} in Medium ${curMi + 1}` : `${n} length${n !== 1 ? 's' : ''} ↔ ${nT} track${nT !== 1 ? 's' : ''}` + (bad ? ` · ${bad} invalid — fix or delete` : n !== nT ? ' · count mismatch' : '') + (empty ? ` · ${empty} empty — left as is` : '')) + (sourceUrl ? ' · from external link' : ''); okBtn.disabled = !toApply || bad > 0; okBtn.textContent = `Apply ${toApply} to Medium ${curMi + 1}`; if (bad > 0) { errBadge.style.display = ''; errBadge.textContent = `⚠ ${bad} invalid — fix or delete`; } // #455.3 prominent badge in the header else errBadge.style.display = 'none'; } function commit() { const nT = tracks().length, n = Math.min(items.length, nT); if (!items.length || items.some(it => lpBad(it.value))) return; // #621 an empty row leaves its track's length untouched — it is never written as "clear" let applied = 0; const kept = []; for (let i = 0; i < n; i++) { if (lpEmpty(items[i].value)) { kept.push(i + 1); continue; } setLength({ mi: curMi, ti: i }, items[i].value); applied++; } if (!applied) return; if (sourceUrl) lpNoteSource(sourceUrl); // #455.2 credit the source in the edit note rebuild(true); Log.info('length parser: applied', applied, 'track length(s) on medium', curMi + 1, kept.length ? ('· left as is (empty): track ' + kept.join(', ')) : '', sourceUrl ? ('from ' + sourceUrl) : ''); toast(`Applied ${applied} track length${applied !== 1 ? 's' : ''}` + (kept.length ? ` · ${kept.length} left as is` : '')); close(); } // static header/footer wiring $('.tc-lp-ok').onclick = commit; $('.tc-lp-x').onclick = close; const medEl = $('.tc-lp-med'); if (medEl) medEl.onchange = () => { curMi = parseInt(medEl.value, 10) || 0; render(); }; // Esc / Ctrl+Enter only — never dismisses on an outside click (#455.6) p.onkeydown = e => { if (e.key === 'Escape') { e.preventDefault(); close(); } else if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') { e.preventDefault(); commit(); } }; // draggable by the header $('.tc-lp-hd').onmousedown = e => { if (e.target.closest('button, select')) return; e.preventDefault(); const r = p.getBoundingClientRect(), ox = e.clientX - r.left, oy = e.clientY - r.top; const mv = ev => { p.style.left = Math.max(0, Math.min(ev.clientX - ox, innerWidth - p.offsetWidth)) + 'px'; p.style.top = Math.max(0, Math.min(ev.clientY - oy, innerHeight - p.offsetHeight)) + 'px'; }; const up = () => { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }; render(); showChooser(); } function runMediumTool(act, mi) { if (act === 'parser') openParser(mi); else if (act === 'patternparser') openTrackPatternParser(mi); else if (act === 'lengthparser') openLengthParser(mi); else if (act === 'resetnum') resetNumbers(mi); else if (act === 'swap') swapMedium(mi); } function runAction(a) { // #378 a bridged external tool (x:…). The PINNED toolbar button reaches us via bindActions (which // rebinds every [data-act] to runAction, overriding pickTool), so re-sync the bridge map HERE too — // otherwise a stale map (a prior sync while the external button was hidden) means nothing fires, while // the Tools menu (which goes through pickTool/getToolCfg) still works. if (String(a).startsWith('x:')) { syncBridges(); const b = _bridgeMap[a]; if (b) fireBridge(b); else Log.warn('Apollo bridge not present:', a); return; } if (a === 'match') { if (_matching) stopMatching(); else matchAll(); } // #577: the same button stops a running pass else if (a === 'revert') revertAll(); else if (a === 'guesscase') guessCaseAll(); else if (a === 'guessfeat') guessFeatAll(); else if (a === 'cols') colsFit(); // the Columns button's default action is Fit else if (a === 'mergemed') { const all = mediums(); const sel = _mergeSel ? [..._mergeSel].filter(i => all[i]) : all.map((_, i) => i); mergeMediums(sel); } // #615 else if (a === 'splitmed') { const mi = toolMedium(); const m = mediums()[mi]; const n = m && medLoaded(m) ? u(m.tracks).length : 0; Log.debug('#615 split requested: medium', mi + 1, '· chosen index', _splitAt, '· tracks', n); splitMedium(mi, _splitAt != null ? _splitAt : Math.floor(n / 2)); } // #615 else if (MEDIUM_TOOLS.has(a)) runMediumTool(a, 0); else if (_bridgeMap[a]) fireBridge(_bridgeMap[a]); } function bindActions(host) { host.querySelectorAll('[data-act]').forEach(b => { const a = b.dataset.act; b.onclick = () => { if (a === 'menu') openToolsMenu(b); else if (a === 'tool') runActiveTool(); else if (a === 'toolsmenu') openToolsMenu(b); else if (a === 'toolscfg') openToolsConfig(b); else if (a === 'gear') openSettings(b); else if (a === 'revertmenu') openMiniMenu(b, [{ label: '↺ Revert all', title: 'revert every track to its page-load state', onClick: revertAll }, { label: '✕ Clear all', title: 'unselect every track artist, keeping the credited-as text (titles and lengths kept)', onClick: clearAllTracks }]); else if (a === 'close') { host.remove(); ACTIVE = {}; } else runAction(a); }; }); } // a small one-off dropdown (e.g. the ▾ next to "Revert all"); items: {label, title?, onClick} function openMiniMenu(anchor, items) { document.querySelectorAll('.tc-menu.tc-mini').forEach(m => m.remove()); const m = document.createElement('div'); m.className = 'tc-menu tc-mini'; m.innerHTML = items.map((it, i) => `
${esc(it.label)}
`).join(''); document.body.appendChild(m); const r = anchor.getBoundingClientRect(); const w = m.offsetWidth, h = m.offsetHeight; // right-align the menu to the anchor (opens leftward) and clamp on-screen, so it never runs off the edge m.style.left = Math.round(Math.max(8, Math.min(r.right - w, window.innerWidth - w - 8))) + 'px'; m.style.top = Math.round(Math.max(8, Math.min(r.bottom + 4, window.innerHeight - h - 8))) + 'px'; m.querySelectorAll('.tc-mi').forEach(el => el.onclick = () => { const it = items[+el.dataset.i]; m.remove(); it.onClick(); }); const off = e => { if (!m.contains(e.target) && e.target !== anchor) { m.remove(); document.removeEventListener('mousedown', off, true); } }; setTimeout(() => document.addEventListener('mousedown', off, true), 0); } // Tracklist "Clear all": empty the title, artist credit and length of every track (with confirm) function clearAllTracks() { if (!MODEL) return; if (!W.confirm('Unselect the artist of EVERY track? (the credited-as text, titles and lengths are kept; does not submit)')) return; _selfEdit = true; // #483: bulk write across every track — rebuild(true) below is the one resync it needs try { MODEL.tracks.forEach(t => { try { const ko = koTrack(t.mi, t.ti); const names = u(u(ko.artistCredit).names) || []; // keep each credit's display text (the credited-as name, or the artist's name if none) but drop the // selected entity — turning a matched artist back into unmatched text (artist: bare {name}), ready // to re-match. Mirrors how commitTrack writes an unresolved slot. ko.artistCredit({ names: names.map(n => { const text = u(n.name) || (n.artist && u(u(n.artist).name)) || ''; return { artist: { name: text }, name: text, joinPhrase: u(n.joinPhrase) || '' }; }) }); } catch (e) {} }); } finally { _selfEdit = false; } Log.info('cleared all track artist selections (kept credited-as text)'); rebuild(true); // no re-match, or it would instantly re-link the kept text } /* ── Configurable Tools bar (#280) ─────────────────────────────────────────── A row of tool buttons you choose (the rest live behind ⋯). The ACTIVE tool's params render inline, right after the buttons; tools you "pin" keep their params on a 2nd row. Click the "Tools" label to customise (visibility, order, per-tool icon/text, pinning). ── */ const MENU = [ { act: 'parser', label: 'Track parser', icon: '☰' }, // ☰ native MB parser { act: 'patternparser', label: 'Pattern parser', icon: '▦' }, // ▦ #456 our pattern-based parser { act: 'lengthparser', label: 'Length parser', icon: '⏱' }, // ⏱ #455 { act: 'swap', label: 'Swap', icon: '⇅' }, // ⇅ { act: 'resetnum', label: 'Reset #', icon: '#' }, { act: 'mergemed', label: 'Merge mediums', icon: '⊕', params: true }, // #615 { act: 'splitmed', label: 'Split medium', icon: '✂', params: true }, // #615 { act: 'guessfeat', label: 'Guess feat.', icon: 'ft', instant: true }, { act: 'guesscase', label: 'Guess case', icon: 'Aa', params: true }, { act: 'sr', label: 'Search and Replace', icon: 'S&R', params: true }, { act: 'cols', label: 'Resize columns', icon: '↔', params: true }, // ↔ ]; const TOOL = Object.fromEntries(MENU.map(m => [m.act, m])); const MEDIUM_TOOLS = new Set(['parser', 'patternparser', 'lengthparser', 'resetnum', 'swap']); // act on ONE medium (inline medium combo when >1) const PICK_SHOWS_ONLY = new Set(['mergemed', 'splitmed']); // #615: structural — picking from the menu must not run them const OPTLESS = new Set(['guessfeat']); // global, no options — fires on pick (non-sticky) const hasParams = act => !!(TOOL[act] && TOOL[act].params); /* ── Tier-0 tool bridges (#322) ─────────────────────────────────────────── Surface a button that some OTHER (user)script drops on the page as a fire-and-forget Apollo Tool, but ONLY when it's actually present. Two ways: (a) Built-in registry — for known buttons we want to adopt UNMODIFIED (e.g. kellnerd's "Guess punctuation"), matched by visible text. Add another by appending one { label, icon, find } entry; `find()` returns the live element or null. (b) Convention — any element tagged `class="apollo-tool"` (optional `data-apollo-label` / `data-apollo-icon`) is surfaced too, so a cooperating script self-registers with NO Apollo change. Either kind is param-less: activating clicks the element, then Apollo re-reads (the external code writes the native fields / KO model, which Apollo doesn't watch per-value), so its grid reflects the change. ── */ const bridgeBtn = re => [...document.querySelectorAll('button, input[type="button"], input[type="submit"]')] // #378 must NOT match Apollo's OWN surfaced copy of the tool (the pinned bar button / Tools-menu item // carry the same label) — otherwise the pinned button fires itself and nothing happens. Skip our UI. .find(b => b.offsetParent !== null && !b.closest('.tc-tools, .tc-toolbtns, .tc-menu, #tc-panel, .tc-mirror') && re.test((b.textContent || b.value || '').trim())) || null; // built-in registry: known external buttons, each with a STABLE `act` so its // bar/menu placement persists. Add one entry { act, label, icon, find } to bridge another. const BRIDGE = [ { act: 'x:gpunct', label: 'Guess punctuation', icon: 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAAHYgAAB2IBOHqZ2wAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAFpSURBVDiNpZOxjwFBGMV/e5FspZeoFETlL9Bug0RDL5FolVpRUqxCr1iNUelUEhmFZqlEVAolFRuxsswVl9uzWVfceeX73nvzfTPzaUIIxRuIAJTL5X+ZR6MRH++cDrwOOBwOdLtdbrdbqDafzxmPx78H2LZNtVplt9txPp993vM8TNOk1WoFeIQQ6htSSmUYhur3++rxePi853mq0WioUqmkttutzwshVOS57U6nQy6Xo1KpBLoaDAYsl0t6vR6pVOr1HViWheM4IfPlcmE4HJLNZkPmQMBqtSIajbJYLFiv175gs9lwvV653+/MZjOOx2MgwB/BdV1OpxPtdhuAYrFIvV73X0JKiZQSXdcxTZN0Oh3sIBaLBZInkwlKqRDvui7T6TQ8gmEYAWE8HkfTNBKJBMlkMlQLjVAoFHAcB9u20XWdWq3mi5rNJpZlsd/vyWQy5PP5n7Tnf/BXCCHU27sQga+t+i8+AYUS9lO02Bg3AAAAAElFTkSuQmCC', find: () => bridgeBtn(/^guess\s+punctuation$/i) }, ]; let _bridges = [], _bridgeMap = {}, _lastBridgeSig = null; // #441 dedupe the sync-diagnostics log // refresh the tools available right now: built-in matches that resolve + elements // tagged class="apollo-tool" (self-registering). Each carries a stable act so the // normal tool-config (bar / menu / Customize, persisted) treats it like a native tool. function syncBridges() { const out = []; for (const b of BRIDGE) { let el = null; try { el = b.find(); } catch (e) {} if (el) out.push({ act: b.act, label: b.label, icon: b.icon, el }); } document.querySelectorAll('.apollo-tool').forEach(el => { const label = el.dataset.apolloLabel || (el.textContent || '').trim() || 'Tool'; const act = 'x:' + (el.dataset.apolloId || el.id || label.toLowerCase().replace(/[^a-z0-9]+/g, '-')); if (!out.some(o => o.act === act)) out.push({ act, label, icon: el.dataset.apolloIcon || '🔧', el }); }); _bridges = out; _bridgeMap = Object.fromEntries(out.map(b => [b.act, b])); // #441 — only log when the set actually CHANGES; syncBridges runs on every tick and // was spamming the same line ~2×/s. Dedupe against the last signature. const sig = out.length ? out.map(b => b.act + (b.el ? '' : '(no-el)')).join(', ') : '(none)'; if (sig !== _lastBridgeSig) { _lastBridgeSig = sig; Log.debug('Apollo bridges synced:', sig); } // #378 diagnostics return out; } // tool-def accessor: native tools + present bridges, so render/customize treat them alike const TD = act => TOOL[act] || _bridgeMap[act] || { act, icon: '?', label: act }; // a tool icon is normally a short text glyph (Aa, S&R, ↔); a data:/http(s) URL // renders as a small instead, so a bridged tool can ship its own icon. const isImgIcon = v => typeof v === 'string' && /^(?:data:|https?:\/\/)/.test(v); const iconHtml = v => isImgIcon(v) ? `` : esc(v == null ? '' : v); async function fireBridge(it) { if (!it) { Log.warn('Apollo bridge: nothing to fire (no tool)'); return; } // #378 re-resolve the LIVE element at fire time: the cached one can be stale, and a fresh find() also // avoids firing Apollo's own surfaced copy of the tool. Fall back to the cached element. let el = it.el; const reg = BRIDGE.find(b => b.act === it.act); try { if (reg && reg.find) { const fresh = reg.find(); if (fresh) el = fresh; } } catch (e) { Log.warn('Apollo bridge: find() threw', e.message); } Log.info('Apollo bridge:', it.label, '· cached el', it.el ? 'yes' : 'null', '· resolved', !el ? 'null' : (el === it.el ? 'cached' : 'fresh'), '· ourUI', !!(el && el.closest && el.closest('.tc-tools,.tc-toolbtns,.tc-menu,#tc-panel'))); if (!el) { Log.warn('Apollo bridge: element gone —', it.label); return; } Log.info('Apollo bridge: clicking', it.label, '→', el.tagName + '.' + ((el.className || '').toString().split(' ')[0] || '')); el.click(); try { await rebuild(true); } catch (e) { Log.warn('Apollo bridge: re-render failed', e.message); } // re-read (no re-match) } // default Tools bar (in display order) for a fresh install — every other tool starts // in the Tools ▾ menu. Each entry sets the tool's icon/text and whether its params // start collapsed (right-click a name toggles that later). const TOOL_DEFAULTS = [ { act: 'guesscase', icon: true, text: false, hideParams: true }, // Aa, collapsed { act: 'cols', icon: false, text: true, hideParams: true }, // Resize columns, collapsed { act: 'sr', icon: true, text: false, hideParams: false }, // S&R, params shown ]; const TOOL_DEF = Object.fromEntries(TOOL_DEFAULTS.map(d => [d.act, d])); let _toolMedium = 0; // the medium chosen in the inline combo — shared across all medium-scoped tools const toolMedium = () => Math.min(Math.max(0, _toolMedium), mediums().length - 1); // per-tool config: { act, onBar, pinned, icon, text } in display order. Saved // tools merge over defaults; any tool not yet saved (a future one) is appended // and defaults to the ⋯ menu. function getToolCfg() { syncBridges(); // #322: present bridges become first-class tools (cfg/bar/menu/customize) const known = act => !!(TOOL[act] || _bridgeMap[act]); // drop saved entries for tools/bridges no longer present const saved = Array.isArray(SETTINGS.toolCfg) ? SETTINGS.toolCfg.filter(t => t && known(t.act)) : []; const order = saved.map(t => t.act); // fresh install: the default on-bar tools lead (in their order), then the rest TOOL_DEFAULTS.forEach(d => { if (!order.includes(d.act)) order.push(d.act); }); MENU.forEach(m => { if (!order.includes(m.act)) order.push(m.act); }); _bridges.forEach(b => { if (!order.includes(b.act)) order.push(b.act); }); // present bridges default to the Tools menu const byAct = Object.fromEntries(saved.map(t => [t.act, t])); return order.map(act => { const s = byAct[act] || {}, d = TOOL_DEF[act]; let icon = s.icon != null ? !!s.icon : (d ? d.icon : true); let text = s.text != null ? !!s.text : (d ? d.text : true); if (!icon && !text) text = true; // at least one of icon/text const onBar = s.onBar != null ? !!s.onBar : !!d; const hideParams = s.hideParams != null ? !!s.hideParams : (d ? !!d.hideParams : false); return { act, onBar, icon, text, hideParams }; }); } function saveToolCfg(cfg) { SETTINGS.toolCfg = cfg.map(t => ({ act: t.act, onBar: !!t.onBar, icon: t.icon !== false, text: t.text !== false, hideParams: !!t.hideParams })); saveSettings(); } const cfgOf = act => getToolCfg().find(t => t.act === act) || { act, onBar: false, icon: true, text: true }; // tools surfaced on the bar for THIS session only (picked from the Tools menu) — // never persisted; they fall back to the menu next session (#280). const TEMP_BAR = new Set(); // hovering the "Guess case" tool button previews the guessed form on every differing title function previewAllGuess(on) { if (!MODEL) return; MODEL.tracks.forEach(t => { if (!(t.guessTitle && t.guessTitle !== t.title)) return; const row = rowEl(t.mi, t.ti); if (!row) return; const tin = row.querySelector('.t-title'); if (!tin || document.activeElement === tin) return; const val = on ? t.guessTitle : t.title; tin.value = val; tin.classList.toggle('gcpreview', on); // #203: when the rich title display is on, the visible text is a .t-title-disp // span over the (hidden) input — repaint it too, or the preview is invisible const disp = row.querySelector('.t-title-disp'); if (disp) { disp.innerHTML = dhRun(val); disp.classList.toggle('gcpreview', on); } }); } // #280: render every on-bar tool inline at its position — a plain button when it // has no params, or a group (icon/name trigger + its params) when it does. The // toolbar grows and WRAPS (flex) when it runs out of room (CSS), never shoving Match. function renderToolbar() { const host = document.querySelector('.tc-toolbtns'); if (!host) return; host.innerHTML = ''; getToolCfg().filter(t => t.onBar || TEMP_BAR.has(t.act)).forEach(t => host.appendChild(hasInlineParams(t.act) ? makeToolGroup(t) : makeToolButton(t))); } function makeToolButton(t) { const m = TD(t.act); const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-toolbtn' + (m.instant ? ' instant' : ''); b.dataset.act = t.act; b.innerHTML = (t.icon ? `${iconHtml(m.icon)}` : '') + (t.text ? `${esc(m.label)}` : ''); if (!t.text) b.title = m.label; b.onclick = () => pickTool(t.act); return b; } function pickTool(act) { if (String(act).startsWith('x:')) Log.info('Apollo: external tool activated —', act, '· in map:', !!_bridgeMap[act]); // #378 diagnostics (bar button or Tools-menu item) if (_bridgeMap[act]) return runAction(act); // bridged external tool — fire-and-forget if (OPTLESS.has(act)) return runAction(act); // instant (Guess feat.) // #280: a param tool picked from the Tools menu has nowhere to show its controls // unless it's on the bar — so surface it there for THIS session (not persisted; // it returns to the menu next session). Customize is where you make it permanent. if (hasInlineParams(act)) { if (!cfgOf(act).onBar) { TEMP_BAR.add(act); renderToolbar(); } // #615: Merge / Split restructure the release — picking them only SHOWS their params // (which mediums / which track); they run on their icon/name click, never on the pick. if (PICK_SHOWS_ONLY.has(act)) { Log.info('#615', TD(act).label, '— set it up in the toolbar, then click its name to run'); return; } return triggerTool(act); } if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); } function runActiveTool() { const act = SETTINGS.lastTool; if (!act) return; if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); } // #280: a pinned tool's panel icon runs its primary action (what the row-1 button did) function triggerTool(act) { if (act === 'sr') { _srChain = null; const box = document.querySelector('.tc-sro'); if (box) { box.classList.remove('tc-sro-chain'); } const f = document.querySelector('.tc-sr-find'), r = document.querySelector('.tc-sr-rep'); if (f) f.value = ''; if (r) r.value = ''; srActivate(); MODEL && MODEL.tracks.forEach(t => { delete t._srFlash; }); rerender(); if (f) f.focus(); return; } if (MEDIUM_TOOLS.has(act)) return runMediumTool(act, toolMedium()); runAction(act); // guesscase → guess-case all · cols → Fit } // the "Tools" label menu: the tools NOT on the bar, then Customize… function openToolsMenu(anchor) { let m = document.getElementById('tc-menu'); if (m) { m.remove(); return; } const off = getToolCfg().filter(t => !t.onBar && !TEMP_BAR.has(t.act)); // includes off-bar bridges (#322) m = document.createElement('div'); m.id = 'tc-menu'; m.className = 'tc-menu'; m.innerHTML = off.map(t => `
${iconHtml(TD(t.act).icon)}${esc(TD(t.act).label)}
`).join('') + (off.length ? '
' : '') + '
' + MBU_CFG_ICON + 'Customize…
'; document.body.appendChild(m); const r = anchor.getBoundingClientRect(), mw = m.offsetWidth, mh = m.offsetHeight; const below = r.bottom + 4, above = r.top - 4 - mh; let top = (below + mh > window.innerHeight - 8 && above >= 8) ? above : below; top = Math.max(8, Math.min(top, window.innerHeight - mh - 8)); m.style.left = Math.max(8, Math.min(r.left, window.innerWidth - mw - 8)) + 'px'; m.style.top = top + 'px'; m.querySelectorAll('.tc-mi').forEach(el => el.onclick = () => { m.remove(); if (el.dataset.act === '__cfg') openToolsConfig(anchor); else pickTool(el.dataset.act); }); const off2 = e => { if (!m.contains(e.target) && e.target !== anchor) { m.remove(); document.removeEventListener('mousedown', off2); } }; setTimeout(() => document.addEventListener('mousedown', off2), 0); } // build a tool's parameter controls into `host` (used both inline on row 1 and on row 2) function buildToolParams(act, host) { if (act === 'guesscase') { const g = gcNative(); const box = document.createElement('span'); box.className = 'tc-gco'; if (g && g.lang) { const sel = g.lang.cloneNode(true); sel.className = 'tc-gc-lang'; sel.value = g.lang.value; sel.title = 'Guess Case language'; sel.onchange = () => { setNative(g.lang, sel.value); recomputeGuesses(); }; box.appendChild(sel); } const mkChk = (text, el) => { const l = document.createElement('label'); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = el ? el.checked : false; c.disabled = !el; c.onchange = () => { setNative(el, c.checked); recomputeGuesses(); }; l.append(c, document.createTextNode(' ' + text)); return l; }; if (g) { box.appendChild(mkChk('Keep uppercased', g.keepUC)); box.appendChild(mkChk('Keep Roman', g.roman)); } host.appendChild(box); // #280: no Apply button — clicking the "Aa Guess case" icon/name applies it } else if (act === 'sr') { srActivate(); const box = document.createElement('span'); box.className = 'tc-sro'; // #375 open Saved & History via ↓ in either field, or a ★ button after RE (the in-field caret was hard to hit) const find = noPw(document.createElement('input')); find.type = 'text'; find.className = 'tc-sr-find'; find.placeholder = srRegexOn() ? 'search (regex)' : 'search'; const rep = noPw(document.createElement('input')); rep.type = 'text'; rep.className = 'tc-sr-rep'; rep.placeholder = 'replace'; const run = () => srLive(find.value, rep.value, true); find.oninput = rep.oninput = run; const openOnDown = e => { if (e.key === 'ArrowDown' && !_srPop) { e.preventDefault(); openSrTemplates(find, find, rep, re); } }; // popup anchors to the search field (left-aligned) find.addEventListener('keydown', openOnDown); rep.addEventListener('keydown', openOnDown); const re = document.createElement('button'); re.type = 'button'; re.className = 'tc-srbtn tc-sr-re' + (srRegexOn() ? ' on' : ''); re.textContent = 'RE'; re.title = 'Use regular expressions (search is a regex; $1, $ work in replace)'; re.onclick = () => { SETTINGS.srRegex = !srRegexOn(); saveSettings(); re.classList.toggle('on', srRegexOn()); find.placeholder = srRegexOn() ? 'search (regex)' : 'search'; run(); }; const star = document.createElement('button'); star.type = 'button'; star.className = 'tc-srbtn tc-sr-star'; star.textContent = '★'; star.title = 'Saved patterns, chains & recent (or press ↓ in the search field)'; // #409: in chain mode `find` is hidden (display:none → a 0,0 rect that flung the popup to the // corner). Anchor to whatever's actually visible — the chain chip, else the ★ button. star.onclick = () => { const b = star.closest('.tc-sro'); const anchor = (find.offsetParent !== null) ? find : ((b && b.querySelector('.tc-sr-chainchip')) || star); openSrTemplates(anchor, find, rep, re); }; box.append(find, rep, re, star); host.appendChild(box); if (_srChain) srShowChain(_srChain); // #409: restore the chain chip if a chain was active } else if (act === 'cols') { const box = document.createElement('span'); box.className = 'tc-colso'; const mk = (label, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'tc-colbtn'; b.textContent = label; b.title = title; b.onclick = fn; return b; }; box.append( mk('Fit', 'size #, Title and Length to their content (Artist absorbs the slack)', colsFit), mk('Centered', 'balance Title and Artist to equal width', colsBalanced), mk('Default', 'reset every column to its default width', colsDefault), ); host.appendChild(box); } else if (act === 'mergemed') { // #615: tick the mediums to merge — they all go into the FIRST ticked one const box = document.createElement('span'); box.className = 'tc-mmo'; const all = mediums(); if (!_mergeSel) _mergeSel = new Set(all.map((_, i) => i)); const lab = document.createElement('span'); lab.className = 'tc-mmo-lab'; lab.textContent = all.length > 1 ? 'into first ticked:' : 'only one medium'; box.appendChild(lab); all.forEach((m, i) => { const l = document.createElement('label'); l.title = medLabel(m, i); const c = document.createElement('input'); c.type = 'checkbox'; c.checked = _mergeSel.has(i); c.onchange = () => { if (c.checked) _mergeSel.add(i); else _mergeSel.delete(i); }; l.append(c, document.createTextNode(String(i + 1))); box.appendChild(l); }); host.appendChild(box); // no Run button — click the tool's icon/name (same as the other param tools) } else if (act === 'splitmed') { // #615: which medium, and the track the NEW medium starts at const box = document.createElement('span'); box.className = 'tc-medo tc-spo'; const all = mediums(); let mi = toolMedium(); if (!all[mi]) mi = 0; if (all.length > 1) { const sel = document.createElement('select'); sel.className = 'tc-medsel'; sel.title = 'which medium'; all.forEach((m, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = 'Medium ' + (i + 1); o.title = medLabel(m, i); sel.appendChild(o); }); sel.value = String(mi); sel.onchange = () => { _toolMedium = parseInt(sel.value, 10) || 0; _splitAt = null; renderToolbar(); }; box.appendChild(sel); } const m = all[mi], tr = m && medLoaded(m) ? u(m.tracks) : []; // MB lazy-loads a collapsed medium's tracks — load it, then rebuild these params with its tracks if (m && !medLoaded(m) && !_splitLoading) { _splitLoading = true; loadMediums([m]).then(() => { _splitLoading = false; renderToolbar(); }); } const lab = document.createElement('span'); lab.className = 'tc-mmo-lab'; lab.textContent = m && !medLoaded(m) ? 'loading medium…' : 'new medium from #'; const at = document.createElement('select'); at.className = 'tc-medsel tc-spat'; at.title = 'the first track of the new medium'; tr.forEach((t, i) => { if (i === 0) return; const o = document.createElement('option'); o.value = String(i); o.textContent = String(u(t.number) || i + 1); o.title = u(t.name) || ''; at.appendChild(o); }); if (_splitAt == null || _splitAt <= 0 || _splitAt >= tr.length) _splitAt = Math.floor(tr.length / 2) || null; if (_splitAt != null) at.value = String(_splitAt); at.disabled = tr.length < 2; at.onchange = () => { _splitAt = parseInt(at.value, 10) || null; Log.debug('#615 split: new medium will start at track index', _splitAt, '(#' + at.options[at.selectedIndex].textContent + ')'); }; box.append(lab, at); host.appendChild(box); } else if (MEDIUM_TOOLS.has(act) && mediums().length > 1) { const box = document.createElement('span'); box.className = 'tc-medo'; const sel = document.createElement('select'); sel.className = 'tc-medsel'; sel.title = 'which medium'; mediums().forEach((m, i) => { const o = document.createElement('option'); o.value = String(i); o.textContent = 'Medium ' + (i + 1); sel.appendChild(o); }); sel.value = String(toolMedium()); sel.onchange = () => { _toolMedium = parseInt(sel.value, 10) || 0; }; // #280: no Run button — pick the medium here, then click the tool's icon/name to run it // (matches Guess case / S&R / Resize, which all apply on the icon click). box.append(sel); host.appendChild(box); } } const hasInlineParams = act => hasParams(act) || (MEDIUM_TOOLS.has(act) && mediums().length > 1); // an inline param tool: a clickable icon/name trigger (obeys icon/text cfg) + its params function makeToolGroup(t) { const act = t.act; const grp = document.createElement('span'); grp.className = 'tc-opt'; grp.dataset.tool = act; const name = document.createElement('span'); name.className = 'tc-optname tc-opttrig'; name.innerHTML = (t.icon ? `${iconHtml(TD(act).icon)}` : '') + (t.text ? `${esc(TD(act).label)}` : ''); name.onclick = () => triggerTool(act); // collapsed: drop the title so the native tooltip can't pop up over the flyout const setTitle = () => { name.title = grp.classList.contains('tc-collapsed') ? '' : TOOL[act].label + ' — click to run, right-click to collapse parameters'; }; // #280: right-click the name collapses/expands its params (state remembered); // collapsed params fly out on hover/focus. Toggle in place so typed S&R text survives. name.oncontextmenu = (e) => { e.preventDefault(); const now = !grp.classList.contains('tc-collapsed'); grp.classList.toggle('tc-collapsed', now); setTitle(); const cfg = getToolCfg(); const c = cfg.find(x => x.act === act); if (c) { c.hideParams = now; saveToolCfg(cfg); } }; // preview on the whole group (not just the name) so it persists while the cursor // moves onto the params/flyout — mouseenter/leave ignore parent↔descendant moves if (act === 'guesscase') { grp.onmouseenter = () => previewAllGuess(true); grp.onmouseleave = () => previewAllGuess(false); } if (t.hideParams) grp.classList.add('tc-collapsed'); setTitle(); grp.appendChild(name); buildToolParams(act, grp); return grp; } let _bridgeKey = '', _bridgeT = 0, _bridgeObs = null; const bridgeKeyNow = () => _bridges.map(b => b.act).sort().join('|'); function refreshBridgesIfChanged() { if (!document.querySelector('.tc-toolbtns')) return; // toolbar not mounted syncBridges(); const key = bridgeKeyNow(); if (key !== _bridgeKey) { _bridgeKey = key; renderToolbar(); } } // #322: an external tool button (e.g. Guess punctuation) may be injected AFTER // Apollo first builds its toolbar, so a pinned bridge wouldn't show until a // re-render. Watch for the present-bridge set changing and re-render the bar. function watchBridges() { if (_bridgeObs) return; _bridgeObs = new MutationObserver(() => { clearTimeout(_bridgeT); _bridgeT = setTimeout(refreshBridgesIfChanged, 300); }); _bridgeObs.observe(document.body, { childList: true, subtree: true }); } function initTools() { renderToolbar(); _bridgeKey = bridgeKeyNow(); watchBridges(); } // ── "Customize tools" popover (opens from the Tools label) ── function openToolsConfig(anchor) { let p = document.getElementById('tc-toolcfg'); if (p) { p.remove(); return; } p = document.createElement('div'); p.id = 'tc-toolcfg'; p.className = 'tc-toolcfg'; let dragAct = null; const apply = () => { renderToolbar(); }; const update = (act, patch) => { const cfg = getToolCfg(); const t = cfg.find(x => x.act === act); if (!t) return; Object.assign(t, patch); if (!t.icon && !t.text) { if ('icon' in patch) t.text = true; else t.icon = true; } // keep ≥1 saveToolCfg(cfg); render(); apply(); }; const reorder = (fromAct, toAct, after) => { const cfg = getToolCfg(); const fi = cfg.findIndex(t => t.act === fromAct); if (fi < 0) return; const item = cfg.splice(fi, 1)[0]; let ti = cfg.findIndex(t => t.act === toAct); if (ti < 0) ti = cfg.length - 1; if (after) ti += 1; cfg.splice(ti, 0, item); saveToolCfg(cfg); render(); apply(); }; const below = (e, row) => { const rc = row.getBoundingClientRect(); return e.clientY > rc.top + rc.height / 2; }; // drop after/before per cursor (#280) function render() { p.innerHTML = '
Customize tools
' + '
Drag ⠿ to reorder · ☑ shows it on the bar (else under the Tools menu) · tools with settings show them inline
'; const list = p.querySelector('.tc-tc-list'); getToolCfg().forEach(t => { const m = TD(t.act); const row = document.createElement('div'); row.className = 'tc-tc-row' + (t.onBar ? '' : ' off'); row.dataset.act = t.act; row.draggable = true; row.innerHTML = '⠿' + `` + `${iconHtml(m.icon)}${esc(m.label)}` + ``; list.appendChild(row); }); list.querySelectorAll('.tc-tc-row').forEach(row => { const act = row.dataset.act; row.querySelector('.cb-onbar').onchange = e => update(act, { onBar: e.target.checked }); row.querySelector('.cb-icon').onclick = () => update(act, { icon: !cfgOf(act).icon }); row.querySelector('.cb-text').onclick = () => update(act, { text: !cfgOf(act).text }); row.ondragstart = e => { dragAct = act; row.classList.add('drag'); try { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', act); } catch (x) {} }; row.ondragend = () => { dragAct = null; p.querySelectorAll('.tc-tc-row').forEach(r => r.classList.remove('drag', 'over-top', 'over-bottom')); }; row.ondragover = e => { e.preventDefault(); row.classList.toggle('over-bottom', below(e, row)); row.classList.toggle('over-top', !below(e, row)); }; // #280: indicate drop direction row.ondragleave = () => row.classList.remove('over-top', 'over-bottom'); row.ondrop = e => { e.preventDefault(); const dir = below(e, row); row.classList.remove('over-top', 'over-bottom'); const from = dragAct || (e.dataTransfer && e.dataTransfer.getData('text/plain')); if (from && from !== act) reorder(from, act, dir); }; }); } render(); document.body.appendChild(p); const r = anchor.getBoundingClientRect(); // open BELOW the whole toolbar (it may have wrapped to several rows), not just // below the Tools label — so the popover never covers the 2nd row (#280) const bar = document.getElementById('tc-bar'); const barBottom = bar ? bar.getBoundingClientRect().bottom : r.bottom; p.style.left = Math.max(8, Math.min(r.left, window.innerWidth - p.offsetWidth - 8)) + 'px'; p.style.top = Math.max(8, Math.min(barBottom + 6, window.innerHeight - p.offsetHeight - 8)) + 'px'; const off = e => { if (!p.contains(e.target) && e.target !== anchor) { p.remove(); document.removeEventListener('mousedown', off); } }; setTimeout(() => document.addEventListener('mousedown', off), 0); } // proxy MusicBrainz's own (hidden) Guess-case options so they actually affect its guessing function gcNative() { const fs = document.querySelector('fieldset.guesscase, .guesscase'); if (!fs) return null; const lang = fs.querySelector('select'); const checks = [...fs.querySelectorAll('input[type=checkbox]')]; const txt = c => ((c.closest('label') || {}).textContent || '').toLowerCase(); const keepUC = checks.find(c => txt(c).includes('keep') && txt(c).includes('uppercas')) || checks.find(c => txt(c).includes('keep')) || checks[0] || null; const roman = checks.find(c => txt(c).includes('roman')) || checks[1] || null; return { lang, keepUC, roman }; } // MB's guess-case options are React-controlled: a synthetic `change` is ignored, so setting .checked // never writes the option/cookie. A real .click() fires MB's own handler (the mode the caret is in. Typing one character into a matched artist field was enough to trigger it — the edit un-links the artist, that writes the credit back to Knockout, and MB echoes a second notification of its own AFTER our _selfEdit guard has been dropped, so the watcher read it as an external change. The rebuild then dropped the keystroke, took the focus, and re-queued the slot for auto-match, which completed the name straight back to the artist you were editing away from — so + (create) never got a chance to appear. Fixing the echo alone would not be enough: any background resync lands the same blow. So the resync waits for the field to be left, which is also what was asked for — "auto match should run after focus is lost", keeping the split-artist flow intact. */ function scheduleSync() { clearTimeout(_syncTimer); _syncTimer = setTimeout(runSync, 400); } function runSync() { // Logged on the FIRST deferral of an episode, not on every re-check. The // match branch below re-arms twice a second, and logging each tick buried a // whole session's log under itself — "spamming now like crazy" (majkinetor). if (isEditingNow()) { if (!_syncDeferred) Log.debug('tracklist resync deferred — a field is being edited (#580)'); _syncDeferred = true; return; } /* #575 (majkinetor: "'auto-match off - click match' shows while match is running. It switches between 'matched N/M' and it periodically"). Every committed track writes the credit back to Knockout, MusicBrainz echoes a notification of its own after our _selfEdit guard has dropped (the #580 mechanism), and the watcher read each of those as an external change — so a running pass was scheduling a full loadAndRender per matched track. That is what flipped the status line, and it was re-entering the whole load path dozens of times a run for nothing: a match is OUR write, and the model is already up to date when it lands. Wait for the pass. */ // #616: a recording auto-match that is still only WAITING for media to load has // written nothing — holding the resync for it is what kept a just-expanded // medium's tracks out of the table for the rest of that wait. if (_matching || (_autoMatching && !_autoMatchWaiting)) { if (!_syncDeferred) Log.debug('tracklist resync deferred until the match pass finishes (#575)'); _syncDeferred = true; clearTimeout(_syncTimer); _syncTimer = setTimeout(runSync, 500); return; } if (_syncDeferred) Log.debug('match pass finished — running the deferred tracklist resync (#575)'); _syncDeferred = false; if (document.getElementById('tc-mirror-wrap')) loadAndRender(); } // Released when focus actually leaves our fields. Deferred by a tick because // focusout fires while activeElement is still in flight, and tabbing from one // of our fields to the next must not count as leaving. document.addEventListener('focusout', () => { if (!_syncDeferred) return; setTimeout(() => { if (_syncDeferred && !isEditingNow()) { Log.debug('field left — running the deferred tracklist resync (#580)'); runSync(); } }, 0); }, true); // #472: med.tracks.subscribe below only fires on STRUCTURAL changes to that // array (added/removed/reordered) — a track edited IN PLACE (a bookmarklet // calling tr.name(newName) or tr.artistCredit({...}) directly, or driving // MB's own inputs which round-trip to those same setters) never touches the // array itself, so it went unnoticed until a full off/on toggle forced a // reload. Subscribe each track's own name/artistCredit observables too. const subTrack = tr => { if (!tr || tr._tcTrackSub) return; if (tr.name && tr.name.subscribe) tr.name.subscribe(() => { if (!_selfEdit) scheduleSync(); }); if (tr.artistCredit && tr.artistCredit.subscribe) tr.artistCredit.subscribe(() => { if (!_selfEdit) scheduleSync(); }); tr._tcTrackSub = true; }; function subscribeTracks() { const rel = release(); if (!rel) return; const subMed = med => { if (!med || med._tcSub) return; if (med.tracks && med.tracks.subscribe) { med.tracks.subscribe(() => { if (!_selfEdit) { (u(med.tracks) || []).forEach(subTrack); scheduleSync(); } }); med._tcSub = true; } (u(med.tracks) || []).forEach(subTrack); }; try { (u(rel.mediums) || []).forEach(subMed); // watch the mediums list itself so adding/removing a medium re-renders + re-hides the new native bits if (!_subscribed && rel.mediums && rel.mediums.subscribe) { rel.mediums.subscribe(() => { if (!_selfEdit) { (u(rel.mediums) || []).forEach(subMed); scheduleSync(); } }); } _subscribed = true; Log.info('watching tracklist + mediums for external changes'); } catch (e) { Log.warn('subscribe failed', e.message); } } // Keep the global toolbar pinned at the TOP of the tracklist (right before the // first medium). A native knockout re-render — notably clicking "Add medium" — // re-orders the tracklist and pushes our toolbar down to the footer, so re-anchor // it on every render and whenever the mirror is (re)shown. #247 function anchorBar() { const wrap = document.getElementById('tc-mirror-wrap'); if (!wrap) return; const firstFs = document.querySelector('fieldset.advanced-medium'); if (firstFs && firstFs.parentElement && firstFs.previousElementSibling !== wrap) firstFs.parentElement.insertBefore(wrap, firstFs); } async function showMirror() { _apolloUsed = true; document.body.classList.add('tc-tl-on'); // #154: theme the native medium headers to match Apollo while the takeover is on style(); let wrap = document.getElementById('tc-mirror-wrap'); if (wrap) { anchorBar(); syncNative(); return; } // the global toolbar sits once at the very top of the Tracklist panel; per-medium tables mount below wrap = document.createElement('div'); wrap.id = 'tc-mirror-wrap'; const firstFs = document.querySelector('fieldset.advanced-medium'); if (firstFs && firstFs.parentElement) firstFs.parentElement.insertBefore(wrap, firstFs); else (document.querySelector('#tracklist, .tracklist, #content') || document.body).prepend(wrap); wrap.innerHTML = `
${BAR}
`; ACTIVE = { mode: 'mirror', sections: [] }; syncNative(); // hide the native tracklist NOW (before the async match/render) so a fresh mount shows no native flash #145 bindActions(wrap); initTools(); wireApplyMode(wrap); subscribeTracks(); await loadAndRender((d, n) => updateStatus(`matching ${d}/${n}…`)); } function hideMirror() { document.body.classList.remove('tc-tl-on'); untidyMediums(); document.querySelectorAll('.tc-medsec').forEach(s => s.remove()); const w = document.getElementById('tc-mirror-wrap'); if (w) w.remove(); setNativeHidden(false); if (ACTIVE.mode === 'mirror') ACTIVE = {}; } /* ── entry points: ONE Original/Apollo toggle, applied to whichever editor tab you're on (#119) ── */ // each managed tab tracks whether its Apollo mirror is shown. It INITIALISES from the persisted // "Replace … on start" setting; the Original/Apollo launcher then toggles it transiently, per tab — // so the launcher always works even when a "replace on start" option is off. #119 // #135: the Apollo/Original switch is GLOBAL — one persisted flag toggles every feature on all tabs and stays // until switched back. Each replace* setting still chooses which features Apollo takes over when it's enabled. function apolloEnabled() { return SETTINGS.apolloEnabled !== false; } function tlWant() { return apolloEnabled() && SETTINGS.replaceTracklist !== false; } function recWant() { return apolloEnabled() && SETTINGS.replaceRecordings !== false; } function riWant() { return apolloEnabled() && SETTINGS.replaceReleaseInfo !== false; } function releaseInfoVisible() { const p = document.getElementById('information'); return !!(p && p.offsetParent !== null); } function apolloOn() { return apolloEnabled(); } // #569: guarded. Assigning textContent replaces the child text node whether or // not the string changed, so this was dispatching a childList record twice a // second — the last idle mutation left after the class guards. Safe here // because the span holds text and nothing else (see mbuProp's warning). function relabelLauncher() { const lbl = document.querySelector('#tc-launch .tc-launch-lbl'); if (lbl) mbuProp(lbl, 'textContent', apolloEnabled() ? 'Original' : 'Apollo Editor'); } // show/hide each visible managed tab's mirror per its want function applyView() { recStyle(); // make sure the recordings CSS (incl. the native-table hide rule) exists up front if (tracklistVisible()) { if (tlWant()) { if (!document.getElementById('tc-mirror-wrap')) showMirror(); } else hideMirror(); } if (recordingsVisible()) { if (recWant()) showRecMirror(); else hideRecMirror(); } if (releaseInfoVisible()) applyReleaseInfo(); applyDuplicates(); // #187 relabelLauncher(); } function ensureLauncher() { if (document.getElementById('tc-launch')) { relabelLauncher(); return; } style(); const b = document.createElement('div'); b.id = 'tc-launch'; b.dataset.mbCorner = 'br'; b.dataset.mbCornerOrder = '10'; const lbl = document.createElement('span'); lbl.className = 'tc-launch-lbl'; lbl.title = 'Toggle Apollo / the original editor for ALL tabs — stays this way (across pages) until you switch back'; lbl.onclick = () => { // GLOBAL toggle — flips Apollo for every tab/feature and persists across pages SETTINGS.apolloEnabled = !apolloEnabled(); saveSettings(); applyView(); applyNav(); applyAnnotationPage(); }; const gear = document.createElement('span'); gear.className = 'tc-launch-gear'; gear.textContent = MBU_CFG_ICON; gear.title = 'Apollo Editor settings'; gear.onclick = () => openSettings(gear); // the one settings entry point — gear removed from the toolbars b.append(lbl, gear); document.body.appendChild(b); relabelLauncher(); mbRestackCorner('br'); } function tracklistVisible() { const p = document.getElementById('tracklist'); return !!(p && p.offsetParent !== null); } // the Tracklist tab panel is shown let _tlPrev = false, _recPrev = false, _tlRefreshed = false; // #145: the 500ms watcher means switching tab (or toggling Apollo, then visiting a tab) paints the // stale/native table for up to half a second before the takeover is (re)applied. Catch the tab-nav // clicks — the compact nav routes through these same links — and re-apply on the next frame: after // jQuery-UI has shown the target panel but BEFORE the browser paints, so the switch is flash-free. function wireTabFlush() { const ed = editorEl(); if (!ed || ed._tcTabFlush) return; ed._tcTabFlush = true; ed.addEventListener('click', e => { if (e.target.closest('ul.ui-tabs-nav a')) requestAnimationFrame(() => { applyView(); syncNav(); }); }); } // single watcher for both managed tabs; the one launcher persists across them and is removed elsewhere function watchTabs() { const tick = () => { const tl = tracklistVisible(), rec = recordingsVisible(); if (document.getElementById('tc-mirror-wrap')) syncNative(); // keep native tracklist bits in their chosen state if MB re-renders if (tl && !_tlPrev) { _tlPrev = true; Log.info('entered Tracklist tab'); // #638: this tick lands up to half a second after the tab opened, so a Match clicked at // once is already running: rebuilding the table then left the pass filling in the old one, // and every badge it set was gone ("set") in the table shown. Wait for the pass (#575). if (tlWant()) { if (!document.getElementById('tc-mirror-wrap')) showMirror(); else if (!_tlRefreshed) { _tlRefreshed = true; if (_matching || _autoMatching) runSync(); else loadAndRender(); } } else hideMirror(); } else if (!tl && _tlPrev) { _tlPrev = false; } if (tl && document.getElementById('tc-mirror-wrap')) syncTlVideoMarks(); // #655 if (rec && !_recPrev) { _recPrev = true; Log.info('entered Recordings tab'); } else if (!rec && _recPrev) { _recPrev = false; } // mount as soon as the (lazily-built) native table exists — retry each tick so there's no native flash if (rec) { if (recWant()) { if (!document.getElementById('tc-recwrap')) showRecMirror(); else if (recSig() !== _lastRecSig) rerenderRec(); } else hideRecMirror(); } // re-render when MB mutates a recording externally (e.g. cleared on a title edit) if (releaseInfoVisible()) applyReleaseInfo(); applyDuplicates(); // #187: score the Add-release Duplicates tab when "Modify Duplicates" is on if (editorEl()) { ensureLauncher(); wireTabFlush(); } else { const l = document.getElementById('tc-launch'); if (l) { l.remove(); mbRestackCorner('br'); } } // #135: the switch shows on every tab; #145: flush the takeover on tab clicks if (navOn() && editorEl()) { if (!document.getElementById('tc-nav-steps')) applyNav(); else syncNav(); relocateAddMedium(); } // keep compact nav alive + synced applyZen(); // #141: keep zen state applied (and fill the nav title once the release model is ready) }; tick(); setInterval(tick, 500); } /* ═══════════════════════════════════════════════════════════════════════ DUPLICATES (#187) — Add-release "Duplicates" tab. Adds a Similarity column scoring how closely each existing "similar release" matches the one being entered, so the editor can pick the right one to base their release on. We proxy the native table (KO `foreach: similarReleases`): read each rendered row's cells, insert a score cell, and re-apply via a MutationObserver when KO re-renders. Off by default (the "Modify Duplicates" setting). ═══════════════════════════════════════════════════════════════════════ */ function dupWant() { return apolloEnabled() && !!SETTINGS.modifyDuplicates; } // similarity of an existing release (name / artist / track count, all we can read // from the native row) to the release being entered. Multiplicative penalties, // in the spirit of MB Release Seeding Helper: a steep title base, softened by an // artist mismatch and a track-count gap (our proxy for its per-track length check). // #187/#298: similarity = how many tracks line up AT THE SAME POSITION between the // candidate release and the one being entered, over the larger tracklist — the very // pairing the comparison view (buildDupDetail) reds row by row. Greedy any-position // matching read 100% for a release with the SAME songs in a DIFFERENT order, while // the position-by-position comparison showed those rows red (#298). A position // matches when its TITLE matches (fold-equal or fuzzy ≥0.85) AND its ARTIST agrees // (folded; empty on either side — single-artist releases — falls back to title). // Release-level metadata (name/count) is still deliberately ignored. const _simRatio = (a, b) => a === b ? 1 : 1 - recLev(a, b) / Math.max(a.length, b.length, 1); const _titleOk = (a, b) => a === b || _simRatio(a, b) >= 0.85; // #332: artist is a soft FACTOR, not an exact gate. A fuller credit ("Roy Ayers" vs // "Roy Ayers Ubiquity", "Horace Tapscott" vs "Horace Tapscott with …") or a near-variant // ("Phil"/"Philip", "&"/"and") is the SAME track — exact-only matching scored those as misses // and tanked an otherwise-identical release (62% when it should read ~90%). const _artistPrefix = (a, b) => { const s = a.length <= b.length ? a : b, l = a.length <= b.length ? b : a; return s.length >= 3 && l.startsWith(s) && (l.length === s.length || l[s.length] === ' '); }; const _artistFactor = (a, b) => (!a || !b || a === b) ? 1 : (_artistPrefix(a, b) || _simRatio(a, b) >= 0.7) ? 0.85 : 0.5; function dupTrackScore(media, entered) { const cand = []; (media || []).forEach(m => (m.tracks || []).forEach(t => cand.push({ t: fold(t.title || ''), a: fold(t.artist || '') }))); const ent = (entered || []).map(t => ({ t: fold(t.title || ''), a: fold(t.artist || '') })); // keep positions if (!ent.some(x => x.t) || !cand.length) return null; // can't judge overlap → no confident score let matched = 0; const n = Math.min(ent.length, cand.length); for (let i = 0; i < n; i++) { const e = ent[i], c = cand[i]; if (e.t && c.t && _titleOk(e.t, c.t)) matched += _artistFactor(e.a, c.a); } // title gates; artist scales return matched / Math.max(ent.length, cand.length); } // one WS fetch per existing release, shared between the score and the expand view const _dupTlCache = new Map(); // #626: the same responses, raw — the artist position source reads each listed duplicate's // track-by-track credits from here instead of asking MusicBrainz again const _dupRawCache = new Map(); // release gid → ws/2 release JSON async function dupTracklist(gid) { if (_dupTlCache.has(gid)) return _dupTlCache.get(gid); const m = await fetchDupTracklist(gid); _dupTlCache.set(gid, m); return m; } // throttled scorer queue — fetch each candidate's tracklist one at a time (the // Duplicates tab lists only a handful), staying under MB's WS rate limit. const _dupQ = []; let _dupBusy = false; function enqueueDupScore(gid, td, tr) { _dupQ.push({ gid, td, tr }); pumpDupScores(); } async function pumpDupScores() { if (_dupBusy) return; _dupBusy = true; while (_dupQ.length) { const { gid, td, tr } = _dupQ.shift(); if (!td.isConnected) continue; const media = await dupTracklist(gid); if (!td.isConnected) continue; const candTracks = media ? media.reduce((n, m) => n + ((m.tracks || []).length || 0), 0) : NaN; // #187: candidate's actual track count paintDupCell(td, tr, gid, media ? dupTrackScore(media, enteredTracklist()) : null, candTracks); await new Promise(z => setTimeout(z, 1100)); } _dupBusy = false; } function paintDupCell(td, tr, gid, sim, candTracks) { if (sim == null) { td.textContent = '?'; td.style.cssText = 'text-align:center;color:var(--mbu-text-weak)' + (gid ? ';cursor:pointer' : ''); td.title = 'no track overlap could be computed (tracklist unavailable, or no tracks entered yet)'; if (gid) td.onclick = () => toggleDupDetail(tr, gid, td); return; } const pct = Math.round(sim * 100); // #187: flag a track-count mismatch (still scored — the % is useful when a // platform just has a few extra/fewer tracks) so the number isn't read as a // full match. Only when we actually have a tracklist entered to compare. const myTracks = mediums().reduce((n, m) => n + ((u(m.tracks) || []).length || 0), 0); const trackMiss = myTracks > 0 && !isNaN(candTracks) && candTracks !== myTracks; td.innerHTML = pct + '%' + (trackMiss ? ' ⚠ tracks' : ''); td.style.cssText = 'background:' + dupColor(pct) + ';color:#fff;font-weight:700;text-align:center;border-radius:3px;padding:1px 6px' + (gid ? ';cursor:pointer' : ''); td.title = 'track-overlap similarity to the release you are entering' + (gid ? ' — click for a track-by-track comparison' : ''); if (trackMiss) td.title += ` — track count differs: you entered ${myTracks}, this release has ${candTracks}`; if (gid) td.onclick = () => toggleDupDetail(tr, gid, td); } // 0% → Apollo red, 50% → amber, 100% → Apollo's match green (#1f8a4c) — uses the // same palette as the confidence dots so the score reads as "our" green. (#187) function dupColor(pct) { const p = Math.max(0, Math.min(100, pct)) / 100; const stops = [[0xd3, 0x2f, 0x2f], [0xff, 0xb7, 0x4d], [0x1f, 0x8a, 0x4c]]; // d32f2f · ffb74d · 1f8a4c const seg = p < 0.5 ? 0 : 1, t = p < 0.5 ? p / 0.5 : (p - 0.5) / 0.5; const c = stops[seg].map((v, i) => Math.round(v + (stops[seg + 1][i] - v) * t)); return `rgb(${c[0]},${c[1]},${c[2]})`; } function augmentDupRows(tbody) { tbody.querySelectorAll(':scope > tr').forEach(tr => { if (tr.classList.contains('tc-dup-detail')) return; // our own expanded-detail rows if (tr.querySelector('.tc-dup-sim')) return; // already scored this (KO-rendered) row const gid = (tr.querySelector('input[name="base-release"]') || {}).value || null; const td = document.createElement('td'); td.className = 'tc-dup-sim'; td.textContent = '…'; td.style.cssText = 'text-align:center;color:var(--mbu-text-weak)'; td.title = 'computing track-by-track similarity…'; tr.insertBefore(td, tr.children[2] || null); // place right after the Release column if (gid) enqueueDupScore(gid, td, tr); else td.textContent = '—'; // neutral until the tracklist is fetched (never a metadata-only guess) }); } // expand/collapse a per-track comparison of the existing release vs the one being // entered, beneath the clicked row (mirrors MB Release Seeding Helper). #187 async function toggleDupDetail(tr, gid, cell) { const nxt = tr.nextElementSibling; if (nxt && nxt.classList.contains('tc-dup-detail')) { nxt.remove(); cell.classList.remove('tc-dd-open'); return; } cell.classList.add('tc-dd-open'); const dr = document.createElement('tr'); dr.className = 'tc-dup-detail'; const td = document.createElement('td'); td.colSpan = tr.children.length; td.innerHTML = '
loading track comparison…
'; dr.appendChild(td); tr.parentNode.insertBefore(dr, tr.nextSibling); const media = await dupTracklist(gid); if (!dr.isConnected) return; // collapsed again before the fetch returned td.querySelector('.tc-dd-wrap').innerHTML = media ? buildDupDetail(media, enteredTracklist()) : '
could not load the existing release tracklist
'; } function enteredTracklist() { const out = []; mediums().forEach(med => (u(med.tracks) || []).forEach(t => out.push({ title: u(t.name) || '', artist: acText(u(t.artistCredit)), len: u(t.length) || null, // #626: each credited name with its artist, when matched — the comparison links those names: liveNames(t).map(n => { const a = u(n.artist) || null; return { name: u(n.name) || (a && u(a.name)) || '', join: u(n.joinPhrase) || '', gid: (a && u(a.gid)) || null }; }), }))); return out; } function acJoinJson(ac) { return (ac || []).map(n => (n.name || (n.artist && n.artist.name) || '') + (n.joinphrase || '')).join(''); } // #358: render a WS2 artist credit as per-artist links (each credited name → /artist/), // keeping the join phrases as plain text. Used for the existing release's artists in the // Duplicates comparison so you can click through to each artist. function acLinksJson(ac) { return (ac || []).map(n => { const gid = n.artist && n.artist.id, nm = n.name || (n.artist && n.artist.name) || ''; const cell = gid ? `${esc(nm)}` : esc(nm); return cell + esc(n.joinphrase || ''); }).join(''); } async function fetchDupTracklist(gid) { try { const res = await wsJson(`${ORIGIN}/ws/2/release/${gid}?inc=artist-credits+recordings&fmt=json`, { label: 'dup tracklist' }); const j = res.json; if (!j) return null; _dupRawCache.set(gid, j); return (j.media || []).map((m, mi) => ({ position: m.position || mi + 1, format: m.format || '', tracks: (m.tracks || []).map(t => { const ac = t['artist-credit'] || (t.recording && t.recording['artist-credit']) || []; return { pos: t.number || t.position || '', title: t.title || (t.recording && t.recording.title) || '', artist: acJoinJson(ac), artistAc: ac, // #358: raw credit → per-artist links on the existing (left) side recGid: (t.recording && t.recording.id) || '', // existing (left) title → link to the MB recording len: t.length || (t.recording && t.recording.length) || null, }; }), })); } catch (e) { Log.warn('dup tracklist fetch failed', e.message); return null; } } const dupFmtLen = ms => ms ? (Math.floor(ms / 60000) + ':' + String(Math.round(ms / 1000) % 60).padStart(2, '0')) : ''; // char-level LCS diff (same as the recordings detailed highlight) — only the // differing characters of each title light up. #186/#187 function dupCharDiff(a, b) { a = a || ''; b = b || ''; if (a.length > 200 || b.length > 200) return null; const n = a.length, m = b.length; const dp = []; for (let i = 0; i <= n; i++) dp.push(new Uint16Array(m + 1)); for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); const out = []; let i = 0, j = 0; const push = (t, ch) => { const l = out[out.length - 1]; if (l && l.t === t) l.s += ch; else out.push({ t, s: ch }); }; while (i < n && j < m) { if (a[i] === b[j]) { push(0, b[j]); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { push(-1, a[i]); i++; } else { push(1, b[j]); j++; } } while (i < n) push(-1, a[i++]); while (j < m) push(1, b[j++]); return out; } function dupDiffSide(segs, want) { return segs.map(s => s.t === 0 ? esc(s.s) : s.t === want ? '' + dhRun(s.s) + '' : '').join(''); } // Char-diff with a cutoff (#187): when the two strings share too little, a // scattered per-character highlight reads as "almost the same" — misleading // when the titles are nothing alike. Below the cutoff we mark the WHOLE side // instead (like the original MB Release Seeding Helper), so a real mismatch is // obvious and clearly outweighs a small length gap. `want` = -1 left/existing, 1 right/seeded. const DUP_DIFF_CUTOFF = 0.4; function dupDiffOrWhole(a, b, want) { a = a || ''; b = b || ''; const self = want === -1 ? a : b; if (!self) return ''; const segs = dupCharDiff(a, b); if (!segs) return esc(self); const common = segs.reduce((n, s) => n + (s.t === 0 ? s.s.length : 0), 0); if (common / Math.max(a.length, b.length, 1) < DUP_DIFF_CUTOFF) return '' + esc(self) + ''; // too different → whole line return dupDiffSide(segs, want); } // #358: do two strings differ "wholly" (below the char-diff cutoff)? Used to decide // whether the linked left-artist cell gets a whole-cell mismatch highlight. function dupWholeDiff(a, b) { a = a || ''; b = b || ''; if (a === b) return false; const segs = dupCharDiff(a, b); if (!segs) return true; const common = segs.reduce((n, s) => n + (s.t === 0 ? s.s.length : 0), 0); return common / Math.max(a.length, b.length, 1) < DUP_DIFF_CUTOFF; } // #480 (majkinetor): "we don't need subsecond comparison as that is not a // thing. Up to 3s difference should be very mild color and should go darker // from there" — then, on the ramp's span: "change it so it ramps up from 3 // to 30s". Shared alpha curve for both the recordings detailed-highlight // shade (lenShade) and this duplicates-panel one (dupLenShade) — they're // deliberately kept as mirrors of each other (#186). Under 1s: no shade at // all. 1-3s: a flat, mild tint — a gap this small is common and not worth // alarming over. 3-30s: ramps up toward full strength. 30s+: solid/full. function lenShadeAlpha(gapMs) { const g = Math.abs(gapMs || 0); if (g < 1000) return null; if (g >= 30000) return 1; if (g < 3000) return 0.12; return 0.12 + 0.88 * ((g - 3000) / 27000); } // #564: which text colour survives ON that tint. Below 0.55 alpha the shade is // translucent, so what the eye actually sees is the tint composited over the // page's own surface: pale pink on a light page, dark maroon on a dark one. // Both callers below only knew the light case and always went dark-red there, // which on a dark userstyle is dark red on dark maroon — the cell reads as // EMPTY until you drag-select it (majkinetor's screenshot on #564). These are // inline styles, so neither a userstyle nor our own [style*=background] sweep // can reach them; the theme has to be read right here. data-mbu-theme is kept // current by mbuTheme(), so this is an attribute read, not a layout read. function shadeIsDark() { try { return document.documentElement.getAttribute('data-mbu-theme') === 'dark'; } catch (e) { return false; } } // Mirror images of each other: 42% toward black on a light surface, 65% toward // white on a dark one. 65 rather than 42 because the worst case is the last // step before white takes over at 0.55, where the composite is already a // strong red — 65% is what keeps that pair above 4.5:1. function shadeSoftFg(r, g, b) { const f = shadeIsDark() ? (c => Math.round(c + (255 - c) * 0.65)) : (c => Math.round(c * 0.42)); return 'rgb(' + f(r) + ',' + f(g) + ',' + f(b) + ')'; } // graded length-gap shade — same as the recordings detailed highlight (#186). null under 1s. function dupLenShade(gapMs) { const a = lenShadeAlpha(gapMs); if (a === null) return null; if (a >= 1) return { bg: '#d32f2f', fg: '#fff' }; // light keeps its hand-picked #7a0000; only the dark branch is new (#564) return { bg: 'rgba(211,47,47,' + a.toFixed(2) + ')', fg: a >= 0.55 ? '#fff' : shadeIsDark() ? shadeSoftFg(211, 47, 47) : '#7a0000' }; } function seededAcHtml(names, whole) { const h = names.map(n => (n.gid ? `${esc(n.name)}` : `${esc(n.name)}`) + esc(n.join)).join(''); return whole ? `${h}` : h; } function buildDupDetail(media, entered) { let gi = 0, rows = ''; // #626: decided for the whole tracklist, so every unmatched name is marked the same way const anyLinked = (entered || []).some(s => (s.names || []).some(n => n.gid)); media.forEach((med, mi) => { rows += `#${med.position || mi + 1}${med.format ? ' ' + esc(med.format) : ''}`; med.tracks.forEach(t => { const s = entered[gi]; gi++; const has = !!s; // a seeded counterpart at this position? // title + artist: per-character diff with a cutoff (#187 — a wholly // different title is marked whole, not as scattered characters). const titleExInner = has ? dupDiffOrWhole(t.title || '', s.title || '', -1) : esc(t.title || ''); // link the existing (left) title to its MB recording, keeping the char-diff highlight inside const titleEx = t.recGid ? `${titleExInner}` : titleExInner; const titleSe = has ? dupDiffOrWhole(t.title || '', s.title || '', 1) : ''; // #358: existing (left) artists as per-artist links; keep the whole-cell "differs" // cue when they diverge below the cutoff (partial diffs just show the links). const artExLinks = acLinksJson(t.artistAc); const artEx = has && dupWholeDiff(t.artist || '', s.artist || '') ? `${artExLinks}` : artExLinks; // #626: once any seeded artist is matched, the seeded credits are links too — a matched // name links to its artist and an unmatched one stands out. Before that, the char diff. const artSe = !has ? '' : anyLinked && (s.names || []).length ? seededAcHtml(s.names, dupWholeDiff(t.artist || '', s.artist || '')) : dupDiffOrWhole(t.artist || '', s.artist || '', 1); // length: graded shade on both Len cells when the gap is real (#186) const sh = (has && t.len && s.len) ? dupLenShade(t.len - s.len) : null; const lenSt = sh ? ` style="background:${sh.bg};color:${sh.fg}"` : ''; // title-then-artist column order, to match the rest of the UI (#187) rows += `${esc(String(t.pos || gi))}` + `${titleEx}${artEx}${dupFmtLen(t.len)}` + `${titleSe}${artSe}${has ? dupFmtLen(s.len) : ''}`; }); }); return `${rows}
PosRelease titleRelease artistLenSeeded titleSeeded artistLen
`; } function dupStyle() { if (document.getElementById('tc-dup-style')) return; const s = document.createElement('style'); s.id = 'tc-dup-style'; s.textContent = ` #duplicates-tab .tc-dup-sim.tc-dd-open { outline: 2px solid #2a8f2a; outline-offset: -2px; } #duplicates-tab tr.tc-dup-detail > td { padding: 0; background: var(--mbu-bg-raised); border-bottom: 2px solid var(--mbu-border); } .tc-dd-tbl { width: 100%; border-collapse: collapse; font: 12px Arial, sans-serif; } .tc-dd-tbl th { text-align: left; font-weight: 600; color: var(--mbu-ok); border-bottom: 1px solid var(--mbu-border); padding: 3px 10px; } .tc-dd-tbl td { padding: 2px 10px; vertical-align: top; } .tc-dd-unres { color: var(--mbu-warn); text-decoration: underline dotted; text-underline-offset: 3px; } .tc-dd-tbl .tc-dd-medhdr td { font-weight: 700; background: var(--mbu-ok-bg); color: var(--mbu-ok); } .tc-dd-tbl .tc-dd-pos { color: var(--mbu-text-weak); text-align: right; width: 34px; } .tc-dd-tbl .tc-dd-len { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; width: 48px; } .tc-dd-tbl .tc-dd-x { color: var(--mbu-error); } .tc-dd-tbl .tc-dh { background: var(--mbu-error-bg); color: var(--mbu-error); border-radius: 2px; } .tc-dd-tbl a { color: inherit; text-decoration: underline; text-decoration-color: rgba(0,0,0,.28); text-underline-offset: 2px; } /* #358: left-artist links, readable on normal + mismatch cells */ .tc-dd-tbl a:hover { text-decoration-color: currentColor; } .tc-dd-tbl .tc-cf { display: inline-block; padding: 0 .5px; } #duplicates-tab .tc-dup-sim .tc-dup-tm { display: inline-block; margin-left: 4px; font-weight: 600; font-size: 10px; opacity: 0.92; white-space: nowrap; }`; document.head.appendChild(s); } function applyDuplicates() { const panel = document.getElementById('duplicates-tab'); if (!panel) return; const table = panel.querySelector('table.tbl'); if (!table) return; const thead = table.querySelector('thead tr'), tbody = table.querySelector('tbody'); if (!dupWant()) { // teardown panel.querySelectorAll('.tc-dup-sim, .tc-dup-th, .tc-dup-detail').forEach(e => e.remove()); if (tbody && tbody._tcDupObs) { tbody._tcDupObs.disconnect(); tbody._tcDupObs = null; } return; } dupStyle(); if (thead && !thead.querySelector('.tc-dup-th')) { const th = document.createElement('th'); th.className = 'tc-dup-th'; th.textContent = 'Similarity'; th.title = 'How closely each existing release matches the one you are entering — click a score for a track-by-track comparison.'; thead.insertBefore(th, thead.children[2] || null); } if (!tbody) return; augmentDupRows(tbody); if (!tbody._tcDupObs) { const obs = new MutationObserver(() => augmentDupRows(tbody)); obs.observe(tbody, { childList: true }); tbody._tcDupObs = obs; } } /* ═══════════════════════════════════════════════════════════════════════ RECORDING MATCHER (#119) — Recordings tab. WIP — Phase 1: in-page comparison view. Takes over the native recording-assignment table (toggle via the shared Original/Apollo button) and shows track vs recording (title / artist / length) with a confidence colour and per-field diff highlight. Drives MB's recording association (setRecordingValue / suggestedRecordings / updateRecordingTitle|Artist); row actions land in P2. ═══════════════════════════════════════════════════════════════════════ */ // artist-credit display text (the recording AC has no .text() helper, so build it from names) function acText(ac) { if (!ac) return ''; try { if (typeof ac.text === 'function') { const t = ac.text(); if (t) return t; } } catch (e) {} const names = u(ac.names) || []; return names.map(n => (u(n.name) || (n.artist && u(u(n.artist).name)) || '') + (u(n.joinPhrase) || '')).join(''); } // the ordered artist-entity gids behind an artist credit (ignores the credited-as display names) function acArtistGids(ac) { const names = u(ac && ac.names) || []; return names.map(n => n.artist && u(u(n.artist).gid)).filter(Boolean); } // true when a track and recording credit the SAME artist entities (same gids, same order) — i.e. any // text difference between them is only a "credited as" name, not a real different artist. #119 function sameArtistEntities(track, rec) { const a = acArtistGids(u(track.artistCredit)), b = acArtistGids(rec ? u(rec.artistCredit) : null); return a.length > 0 && a.length === b.length && a.every((g, i) => g === b[i]); } // a length gap up to SETTINGS.recLenTol seconds (default 5) is treated as identical (MB lengths jitter). // Measured in WHOLE on-screen seconds so rows that LOOK the same get the same verdict. #119 function recLenTolMs() { return (SETTINGS.recLenTol != null ? SETTINGS.recLenTol : 5) * 1000; } function recLenGap(a, b) { if (!a || !b) return 0; const gap = Math.abs(Math.round(a / 1000) - Math.round(b / 1000)) * 1000; // gap in whole displayed seconds return gap <= recLenTolMs() ? 0 : gap; } // shared text normalisation for matching: case/accents (Ignore casing) + punctuation/symbols (Ignore punctuation) function recFold(s) { let t = SETTINGS.recIgnoreCase !== false ? fold(s) : String(s || ''); if (SETTINGS.recIgnorePunct) t = t.replace(/&/g, ' and ').replace(/[^\p{L}\p{N}]+/gu, ' ').replace(/\s+/g, ' ').trim(); return t; } function recNameEq(a, b) { return recFold(a) === recFold(b); } // exact after normalisation (used for artist) // Levenshtein edit distance — backs the "Title tolerance" (allow up to N differing characters) function recLev(a, b) { if (a === b) return 0; if (!a) return b.length; if (!b) return a.length; const n = b.length; let prev = []; for (let j = 0; j <= n; j++) prev[j] = j; for (let i = 1; i <= a.length; i++) { const cur = [i]; for (let j = 1; j <= n; j++) cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)); prev = cur; } return prev[n]; } // title equality: normalised, then within the "Title tolerance" edit distance (0 = exact) function recTitleEq(a, b) { const x = recFold(a), y = recFold(b); if (x === y) return true; const tol = SETTINGS.recTitleTol || 0; return tol > 0 && recLev(x, y) <= tol; } // an IDEAL match: title/length/artist match WITHOUT any relaxation option (ignore-punctuation, title or // length tolerance) — only case/accent folding. Preferred by auto-match and shown distinctly. #119 // artist-entity equality for the flattened {artistGids} carried on ctx / candidate data: // same gids in order means any text difference is only a "credited as" name, not a real // artist mismatch — so it must NOT drag the match confidence down (a *Credited as* value // doesn't influence matching). This keeps auto-match consistent with the table's exact dot. #190 function sameAcEntities(a, b) { const x = a && a.artistGids, y = b && b.artistGids; return !!(x && y && x.length > 0 && x.length === y.length && x.every((g, i) => g === y[i])); } // EXACT means the displayed strings are identical — only Unicode-normalisation // (NFC) is folded, never case/accents/spacing. A casing-only (or accent-only) // difference is NOT exact: it's a real visible difference that native offers to // copy, so it belongs in the tolerance band, not the blue "exact" one. #197 const literalEq = (a, b) => String(a == null ? '' : a).normalize('NFC') === String(b == null ? '' : b).normalize('NFC'); function recExactMatch(data, ctx) { if (!ctx) return false; const titleEq = !data.name || !ctx.title || literalEq(data.name, ctx.title); // a same-entity credited-as is still exact (#190) — only the artist *text* // is no longer case/accent-folded into exactness (#197) const artistEq = !data.artist || !ctx.artist || sameAcEntities(data, ctx) || literalEq(data.artist, ctx.artist); const lenEq = !data.length || !ctx.length || Math.round(data.length / 1000) === Math.round(ctx.length / 1000); return titleEq && artistEq && lenEq; } // per-field diff between a track and its recording: title/artist via MB's own flags, length in ms function recFieldDiffs(track, rec) { let title = false, artist = false; try { if (typeof track.titleDiffersFromRecording === 'function') title = !!track.titleDiffersFromRecording(); } catch (e) {} try { if (typeof track.artistDiffersFromRecording === 'function') artist = !!track.artistDiffersFromRecording(); } catch (e) {} // a "credited as" (same artist entity, different credit name) is NOT a real artist mismatch — don't // let it drag the match confidence down; it's still the right recording. #119 if (artist && sameArtistEntities(track, rec)) artist = false; // a casing-only (cosmetic) difference is not a real mismatch when "ignore casing" is on if (title && recTitleEq(u(track.name), rec ? u(rec.name) : '')) title = false; if (artist && recNameEq(acText(u(track.artistCredit)), acText(rec ? u(rec.artistCredit) : null))) artist = false; const lenDiff = recLenGap(u(track.length), rec ? u(rec.length) : null); return { title, artist, lenDiff, len: lenDiff > 0 }; } // Native MB's raw per-field "differs from recording" flag, UNfiltered by // Apollo's tolerance / ignore-casing settings. This is what drives the // native update checkboxes, so proxying it (rather than the cosmetically // filtered recFieldDiffs) is the only way to be sure the copy is offered // exactly when native offers it — e.g. casing-only title diffs. #146 function nativeDiffFlag(track, which) { try { const fn = which === 'title' ? track.titleDiffersFromRecording : track.artistDiffersFromRecording; return typeof fn === 'function' ? !!fn.call(track) : false; } catch (e) { return false; } } // Confidence ported from "Quick Recording Match": null = perfect (green), else low / vlow / xlow // (yellow / orange / red), graded by how many fields differ and by how far the length is off. // single source of truth for the confidence colors — used by the row dots AND the Cutoff picker const CONF_COLOR = { exact: '#2f6fd6', tolerance: '#86c686', near: '#fff176', low: '#ffb74d', vlow: '#d32f2f' }; const REC_CONF = { // the colored bands below exact/tolerance (keys = display names) near: { c: CONF_COLOR.near, label: 'near' }, low: { c: CONF_COLOR.low, label: 'low' }, vlow: { c: CONF_COLOR.vlow, label: 'very low' }, }; function recConfidence(track, rec, d) { if (!rec || !u(rec.gid)) return null; d = d || recFieldDiffs(track, rec); const diffs = []; if (d.title) diffs.push('title'); if (d.artist) diffs.push('artist'); if (d.lenDiff > 0) diffs.push('length ' + Math.round(d.lenDiff / 1000) + 's'); let level = null; if (diffs.length >= 3 && d.lenDiff > 10000) level = 'vlow'; else if (d.lenDiff > 15000) level = 'low'; else if (diffs.length >= 2 && d.lenDiff <= 15000) level = 'low'; else if (diffs.length === 1 || d.lenDiff > 3000) level = 'near'; if (!level) return null; return { level, color: REC_CONF[level].c, label: REC_CONF[level].label, diffs }; } const fmtMs = ms => (ms || ms === 0) ? (Math.floor(Math.round(ms / 1000) / 60) + ':' + String(Math.round(ms / 1000) % 60).padStart(2, '0')) : ''; // artist-credit rendered as links to each artist's page (joined by their join // phrases). `withDisamb` appends each artist's disambiguation (when the page // model carries it) — used in the picker header so a wrong artist is obvious. #195 function acLinks(ac, withDisamb) { const names = u(ac && ac.names) || []; if (!names.length) return ''; return names.map((n, i) => { const art = n.artist ? u(n.artist) : null; const nm = u(n.name) || (art && u(art.name)) || ''; const gid = art && u(art.gid); const cmt = (art && u(art.comment)) || getDisamb(gid); // KO model lacks it for fresh picks → cache fallback #195 const dis = withDisamb && cmt ? ' (' + esc(cmt) + ')' : ''; const nx = names[i + 1]; const nxa = nx && (nx.artist ? u(nx.artist) : null); const nxNm = nx ? (u(nx.name) || (nxa && u(nxa.name)) || '') : ''; return (gid ? '' + dhRun(nm) + '' : dhRun(nm)) + dis + joinMark(u(n.joinPhrase) || '', i === names.length - 1, nm, nxNm); }).join(''); } // flat {name, gid, comment, join} per artist of a credit — lets the table diff // artists by ENTITY (not raw text) while keeping links + disambiguations. #186 #195 function acData(ac) { return (u(ac && ac.names) || []).map(n => { const art = n.artist ? u(n.artist) : null; const gid = (art && u(art.gid)) || null; return { name: u(n.name) || (art && u(art.name)) || '', gid, comment: (art && u(art.comment)) || getDisamb(gid), join: u(n.joinPhrase) || '' }; }); } // render one credit as links (+ disambiguations), boxing the artists whose // ENTITY isn't present on the other side — the detailed-highlight equivalent // for artists that keeps links instead of char-diffing plain text (#186: the // original highlighted the differing artist; links used to drop here). A // credited-as (same entity, different text) is NOT boxed — it's the same // artist, just a different credit. function acLinksDiff(self, other, withDisamb) { const key = a => a.gid || ('name:' + a.name.toLowerCase().trim()); const otherArr = other || []; const otherSet = new Set(otherArr.map(key)); // key → the other side's credited-as text, so a SAME-entity credit whose *text* // differs (a credited-as change — same artist, different phrasing) still gets the // per-character highlight (#444). It doesn't affect matching (credited-as never // does), it just makes the difference visible like a title diff does. const otherName = new Map(); otherArr.forEach(a => { const k = key(a); if (!otherName.has(k)) otherName.set(k, a.name || ''); }); return (self || []).map((a, i) => { const sameEntity = otherSet.has(key(a)); let nm; if (sameEntity) { const on = otherName.get(key(a)); const segs = (on != null && (a.name || '') !== on) ? charDiff(a.name || '', on) : null; // #444 credited-as text diff nm = segs ? diffSide(segs, -1) : dhRun(a.name); // -1 = this side's unique chars (self is always "this" column) } else { nm = dhRun(a.name); } const inner = a.gid ? '' + nm + '' : nm; const boxed = sameEntity ? inner : '' + inner + ''; // a DIFFERENT entity is boxed whole; a same-entity credited-as is char-diffed above const dis = withDisamb && a.comment ? ' (' + esc(a.comment) + ')' : ''; return boxed + dis + joinMark(a.join, i === self.length - 1, a.name, self[i + 1] && self[i + 1].name); }).join(''); } // #186 detailed highlighting — char-level LCS diff of two short strings. // Returns segments [{t,s}] with t: 0 common · -1 only-in-a · 1 only-in-b, or null // when either string is too long (caller falls back to the flat highlight). function charDiff(a, b) { a = a || ''; b = b || ''; if (a.length > 200 || b.length > 200) return null; const n = a.length, m = b.length; const dp = []; for (let i = 0; i <= n; i++) dp.push(new Uint16Array(m + 1)); for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); const out = []; let i = 0, j = 0; const push = (t, ch) => { const l = out[out.length - 1]; if (l && l.t === t) l.s += ch; else out.push({ t, s: ch }); }; while (i < n && j < m) { if (a[i] === b[j]) { push(0, b[j]); i++; j++; } else if (dp[i + 1][j] >= dp[i][j + 1]) { push(-1, a[i]); i++; } else { push(1, b[j]); j++; } } while (i < n) push(-1, a[i++]); while (j < m) push(1, b[j++]); return out; } // render one side of a diff: common runs stay plain, this side's unique chars // (want = -1 track/left, 1 recording/right) are wrapped as .tc-dh. // #203: confusable / invisible characters. These are marked with their Unicode // name + codepoint (tooltip) and enlarged, with invisibles drawn as a visible // glyph — so a curly quote, an en-dash, or a no-break space is obvious wherever a // title/artist is shown (not just inside a detailed-highlight diff), not a guess. const CONFUSABLE = {}; // Built from codepoints (NOT literal chars) so invisible keys can't be stripped/ // normalised in source. vis glyphs: ␣ ␣, ∅ ∅, ⇥ ⇥, · ·. [ [0x0027, 'apostrophe (straight)'], [0x2019, 'right single quote / curly apostrophe'], [0x2018, 'left single quote'], [0x0060, 'grave accent / backtick'], [0x00B4, 'acute accent'], [0x02BC, 'modifier letter apostrophe'], [0x0022, 'quotation mark (straight)'], [0x201C, 'left double quote'], [0x201D, 'right double quote'], [0x2032, 'prime'], [0x2033, 'double prime'], // #369 NOT U+002D hyphen-minus — it's the correct, standard hyphen in MB titles ("Cat-O-Nine", // "Afro-Jamaican"), so enlarging+bolding every one of them just mangled normal text. Its LOOK-ALIKES // (real hyphen, non-breaking hyphen, dashes, minus) stay flagged so an odd one still stands out. [0x2010, 'hyphen'], [0x2011, 'non-breaking hyphen'], [0x2012, 'figure dash'], [0x2013, 'en dash'], [0x2014, 'em dash'], [0x2015, 'horizontal bar'], [0x2212, 'minus sign'], [0x00AD, 'soft hyphen', '·'], [0x2026, 'horizontal ellipsis'], [0x00A0, 'no-break space', '␣'], [0x202F, 'narrow no-break space', '␣'], [0x2009, 'thin space', '␣'], [0x2002, 'en space', '␣'], [0x2003, 'em space', '␣'], [0x3000, 'ideographic space', '␣'], [0x200B, 'zero-width space', '∅'], [0x200C, 'zero-width non-joiner', '∅'], [0x200D, 'zero-width joiner', '∅'], [0xFEFF, 'byte-order mark', '∅'], [0x0009, 'tab', '⇥'], ].forEach(([cp, n, vis]) => { CONFUSABLE[String.fromCodePoint(cp)] = { n, c: cp.toString(16).toUpperCase().padStart(4, '0'), vis }; }); // Mark EVERY confusable / invisible character, wherever a title/artist is shown // (not just inside a detailed-highlight diff): each gets a tooltip naming it + its // codepoint, invisibles draw a visible glyph, and all are enlarged by the Appearance // "punctuation size" setting (px). Straight ' " are marked too (MB style prefers curly), but a plain // hyphen-minus is NOT — it's the correct char (#369). px = 0 disables it (master switch). // #443: the confusable / invisible marking (and the missing-space markers) belong to // **detailed highlighting** — they must show whenever it's on, so an invisible char (a // no-break space, a missing space) can never hide. "Enlarge punctuation" (px) is only // the enlargement amount, NOT a master switch: at 0px the markers are still drawn // (invisibles as their glyph, all named on hover), just not enlarged. Before, 0px // silently disabled everything — which surprised (a missing space vanished, #443). const dhMark = () => (SETTINGS.recPunctSize | 0) > 0 || !!SETTINGS.recDetailedHl; function dhRun(str) { if (!dhMark()) return esc(String(str)); const px = SETTINGS.recPunctSize | 0; const st = px > 0 ? ' style="font-size:calc(1em + ' + px + 'px);font-weight:700;line-height:1"' : ''; // px enlarges only let out = ''; for (const ch of String(str)) { const cf = CONFUSABLE[ch]; if (!cf) { out += esc(ch); continue; } // invisibles carry a persistent highlight (like the join ␣) so they're obvious at any size const cls = 'tc-cf' + (cf.vis ? ' tc-cf-inv' : ''); out += '' + esc(cf.vis || ch) + ''; } return out; } /* #601 (majkinetor): "When clicking on a track, cursor should always be on the place that is clicked. Current behavior is that it rarely does that and mostly moves to start/end… requires one to need 2 clicks to position the cursor instead 1." The #203 rich display is a occupying the input's slot; while resting the real input is 1x1 with pointer-events:none, so a click can only ever land on the span. The handler did `e.preventDefault(); tin.focus()` — and preventDefault is exactly what stops the browser placing a caret, so focus() dropped it wherever the input last had it (start, or end). The second click then worked, because by then the input was the thing under the pointer. preventDefault has to stay (without it the span keeps its own text selection), so the caret is placed by hand at the character actually clicked. This is the inverse of dhRun: the span's text is the input's text unit for unit, because dhRun emits each source character either as escaped text (identical characters) or as a one-character stand-in, and every entry in CONFUSABLE is a single UTF-16 unit with a single-char `vis`. So walking text nodes gives the offset with no bookkeeping. */ function dispCaretIndex(disp, input, x, y) { const val = input.value || ''; // empty title: the span shows its own "title…" placeholder, whose text is // not the value — anywhere in it means offset 0. if (!val) return 0; let node = null, offset = 0; if (document.caretPositionFromPoint) { // Firefox const p = document.caretPositionFromPoint(x, y); if (p) { node = p.offsetNode; offset = p.offset; } } else if (document.caretRangeFromPoint) { // Chromium / WebKit const r = document.caretRangeFromPoint(x, y); if (r) { node = r.startContainer; offset = r.startOffset; } } // clicked past the end of the text (the span is flex:1, so there is usually // empty space to its right) — that means the end, which is what a text field // does for a click past its content. if (!node || !disp.contains(node)) return val.length; let idx = 0, done = false; const walk = (n) => { if (done) return; if (n.nodeType === 3) { if (n === node) { idx += Math.min(offset, n.nodeValue.length); done = true; return; } idx += n.nodeValue.length; return; } // a caret resolved to an ELEMENT: its offset counts child nodes, not chars if (n === node) { for (let i = 0; i < offset && i < n.childNodes.length; i++) walk(n.childNodes[i]); done = true; return; } for (const c of n.childNodes) { walk(c); if (done) return; } }; walk(disp); // clamped because a guess-case PREVIEW can be on screen while the input is // about to be restored to the real title (see `restore` on focus), and the // two can differ in length. return Math.max(0, Math.min(idx, val.length)); } // #208 join-phrase spacing visibility (Recording view). A join phrase between // two artists should have a space on BOTH sides (" & ", " feat. ", …). Mark // where one is MISSING with a highlighted ␣ — so `Gandhabba &Render` reads as // `Gandhabba &␣Render`. An empty phrase between two artists (no join at all) // becomes `␣?␣`. Uses the same px>0 master switch as the #203 marking. `isLast` // = the final artist of a credit, whose empty join is correct (not flagged). // A join needs a TRAILING space (the next artist mustn't be glued on) and, // for most phrases, a LEADING one too (" & ", " feat. "). The exception: // a comma/semicolon attaches to the PREVIOUS name, so ", " is correct with // no leading space — don't flag it (chaban, #208). function joinNeed(j) { return { lead: !/^\s/.test(j) && !/^\s*[,;]/.test(j), trail: !/\s$/.test(j) }; } // CJK scripts (Japanese, Chinese, Korean) don't space their joins — e.g. と / // 、 in Japanese — so a no-space join is correct there. Detects Hiragana, // Katakana, Kanji, Hangul, CJK punctuation and full-width forms (chaban, #208). const CJK_RE = /[ -〿぀-ヿ㐀-䶿一-鿿豈-﫿︰-﹏＀-￯가-힯]/; const isCjk = s => CJK_RE.test(String(s == null ? '' : s)); function joinMark(join, isLast, prevName, nextName) { const j = String(join == null ? '' : join); if (!dhMark()) return esc(j); // #443: gated by detailed highlighting, not the px size if (isLast) return dhRun(j); // trailing artist — empty join is fine if (j === '') return ''; const mk = side => ''; const need = joinNeed(j); if (isCjk(j) || isCjk(prevName) || isCjk(nextName)) { need.lead = false; need.trail = false; } // CJK: no space convention return (need.lead ? mk('leading') : '') + dhRun(j) + (need.trail ? mk('trailing') : ''); } function diffSide(segs, want) { return segs.map(s => s.t === 0 ? esc(s.s) : s.t === want ? '' + dhRun(s.s) + '' : '').join(''); } // graded length-gap shade (#186): null under 1s, scaling red 1–5s, solid red ≥5s. // the configurable detailed-highlight colour as {r,g,b} (#186 — Matching → highlight colour) function hlRgb() { const h = String(SETTINGS.recHlColor || '#e53935').replace('#', ''); const n = h.length === 3 ? h.split('').map(c => c + c).join('') : h; const v = parseInt(n, 16); return (n.length === 6 && Number.isFinite(v)) ? { r: (v >> 16) & 255, g: (v >> 8) & 255, b: v & 255 } : { r: 229, g: 57, b: 53 }; } function applyHlColor() { try { document.documentElement.style.setProperty('--tc-hl', SETTINGS.recHlColor || '#e53935'); } catch (e) {} } function lenShade(gapMs) { const a = lenShadeAlpha(gapMs); if (a === null) return null; // #480: shared curve with dupLenShade const { r, g: gg, b } = hlRgb(); if (a >= 1) return { bg: `rgb(${r},${gg},${b})`, fg: '#fff' }; return { bg: `rgba(${r},${gg},${b},${a.toFixed(2)})`, fg: a >= 0.55 ? '#fff' : shadeSoftFg(r, gg, b) }; // #564: theme-aware, was light-only } // read each track's recording association + the data needed to compare them side by side function readRecordings() { const out = []; mediums().forEach((med, mi) => (u(med.tracks) || []).forEach((t, ti) => { const rec = u(t.recording); const sugg = (typeof t.suggestedRecordings === 'function' ? (u(t.suggestedRecordings) || []) : []); const diffs = rec ? recFieldDiffs(t, rec) : null; // ideal/exact match: title + length match with NO relaxation option used (fold only, no punctuation/ // edit-distance/length tolerance). A credited-as artist (same entity) still counts as exact. #119 // EXACT = displayed title/artist identical (literal, not case/accent-folded — // a casing-only diff is tolerance, #197) and length displays the same. A // credited-as artist (same entity) still counts as exact. #119 #190 const exact = !!(rec && literalEq(u(t.name), u(rec.name)) && (!u(t.length) || !u(rec.length) || Math.round(u(t.length) / 1000) === Math.round(u(rec.length) / 1000)) && (sameArtistEntities(t, rec) || literalEq(acText(u(t.artistCredit)), acText(u(rec.artistCredit))))); // which fields a green (within-tolerance) match still differs on — for the dot tooltip const tolDiffs = []; if (rec) { if (fold(u(t.name)) !== fold(u(rec.name))) tolDiffs.push('title'); if (u(t.length) && u(rec.length) && Math.round(u(t.length) / 1000) !== Math.round(u(rec.length) / 1000)) tolDiffs.push('length ' + Math.round(Math.abs(u(t.length) - u(rec.length)) / 1000) + 's'); if (!(sameArtistEntities(t, rec) || fold(acText(u(t.artistCredit))) === fold(acText(u(rec.artistCredit))))) tolDiffs.push('artist'); } const isNew = typeof t.hasNewRecording === 'function' ? !!u(t.hasNewRecording) : false; // raw native diff flags (proxy the native update checkboxes), #146 const rawTitleDiff = !!(rec && !isNew && nativeDiffFlag(t, 'title')); const rawArtistDiff = !!(rec && !isNew && nativeDiffFlag(t, 'artist')); out.push({ exact, tolDiffs, mi, ti, number: u(t.number), title: u(t.name), trackArtist: acText(u(t.artistCredit)), trackArtistHtml: acLinks(u(t.artistCredit), true), trackAc: acData(u(t.artistCredit)), trackLen: u(t.length), isNew, recGid: rec ? u(rec.gid) : null, recName: rec ? u(rec.name) : null, recComment: rec ? (u(rec.comment) || '') : '', recArtist: rec ? acText(u(rec.artistCredit)) : null, recArtistHtml: rec ? acLinks(u(rec.artistCredit), true) : '', recAc: rec ? acData(u(rec.artistCredit)) : [], recLen: rec ? u(rec.length) : null, recVideo: rec && !isNew ? !!u(rec.video) : false, // #655: a track set to a new recording has no video // #303 — native recordings table shows a video marker; mirror it here // submit-flags: when on, the recording's title/artist will be overwritten with the track's on submit copyTitle: typeof t.updateRecordingTitle === 'function' ? !!u(t.updateRecordingTitle) : false, copyArtist: typeof t.updateRecordingArtist === 'function' ? !!u(t.updateRecordingArtist) : false, rawTitleDiff, rawArtistDiff, suggCount: sugg.length, diffs, conf: rec ? recConfidence(t, rec, diffs) : null, }); })); return out; } let _recStyled = false; function recStyle() { if (_recStyled) return; _recStyled = true; const s = document.createElement('style'); s.textContent = [ '#tc-recwrap{margin:4px 0 12px;font:13px/1.4 Arial}', // #369 match the Tracklist font (was system-ui) so curly quotes/apostrophes render the same in both tables, not as system-ui prime-like glyphs '#tc-recwrap .tc-recbar{display:flex;align-items:center;gap:8px;padding:2px 2px 8px;font-weight:600}', '#tc-recwrap .tc-recbar .tc-ico{vertical-align:-5px}', '#tc-recwrap .tc-recwarn{color:var(--mbu-error);font-weight:600}#tc-recwrap .tc-recwarn:hover{text-decoration:underline;cursor:pointer}', // consistent with the Tracklist tab toolbar (#tc-bar / .tc-btn): same bar spacing, button look, inputs. // sticky at the top while the table scrolls (mirrors #tc-mirror-wrap) so it stays reachable on big releases. '#tc-recwrap .tc-rec-tb{display:flex;align-items:center;gap:8px;padding:6px 4px;flex-wrap:wrap;position:sticky;top:0;z-index:50;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-accent);box-shadow:0 3px 8px rgba(40,20,80,.07)}', '#tc-recwrap .tc-rec-tb .sp{flex:1}', // flex spacer: Clear hugs the left, the Match cluster hugs the right (mirrors #tc-bar) // flat buttons that match the Tracklist tab's .tc-btn (transparent until hover); Match keeps the bold-purple primary look '#tc-recwrap .tc-rec-tb button{padding:4px 11px;border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial;color:var(--mbu-text)}#tc-recwrap .tc-rec-tb button:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-border)}', '#tc-recwrap .tc-rec-split{display:inline-flex}#tc-recwrap .tc-rec-revcaret{padding:4px 6px;color:var(--mbu-accent-text)}', '#tc-recwrap .tc-rec-tb button.primary{color:var(--mbu-accent-text);font-weight:bold}#tc-recwrap .tc-rec-tb button.primary:hover{background:linear-gradient(#7a52df,#5f3ec0);color:#fff;border-color:var(--mbu-accent)}', '#tc-recwrap .tc-rec-tbl{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text-dim);font-family:"Bitstream Vera Sans",Verdana,Arial,sans-serif}', '#tc-recwrap .tc-rec-tbl b{color:var(--mbu-accent-deep-text)}', // bold label word in MB purple, matching #tc-bar b on the Tracklist toolbar '#tc-recwrap .tc-cutoff{display:inline-flex;align-items:center;gap:6px;border:1px solid var(--mbu-border);border-radius:14px;padding:2px 9px;cursor:pointer;font:12px Arial;background:var(--mbu-bg);user-select:none}', '#tc-recwrap .tc-cutoff:hover{border-color:var(--mbu-border)}', '.tc-cutoff-dot,.tc-cutoff-menu .dot{width:12px;height:12px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,.18);flex:none}', '#tc-recwrap .tc-cutoff-caret{color:var(--mbu-text-weak);font-size:10px}', '.tc-cutoff-menu{position:fixed;z-index:100002;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:7px;box-shadow:0 8px 24px rgba(40,20,80,.22);padding:4px;font:13px Arial}', '.tc-cutoff-menu .mi{display:flex;align-items:center;gap:9px;padding:5px 11px 5px 8px;border-radius:5px;cursor:pointer;white-space:nowrap;color:var(--mbu-text)}', '.tc-cutoff-menu .mi:hover,.tc-cutoff-menu .mi.sel{background:var(--mbu-bg-hover)}', '#tc-recwrap .tc-rec-amstatus{color:var(--mbu-accent-text);font-size:12px;flex:1 1 0;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:right;padding-right:4px}', '.tc-rectbl .tc-recname{position:relative}', '.tc-rectbl .tc-rec-rev{position:absolute;right:28px;top:50%;transform:translateY(-50%);border:none;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-accent-text);font-size:15px;line-height:1;visibility:hidden;padding:1px 4px;border-radius:3px}', '.tc-rectbl tr.tc-recrow:hover .tc-rec-rev{visibility:visible}.tc-rectbl .tc-rec-rev:hover{color:var(--mbu-accent-text);background:var(--mbu-bg-hover)}', 'table.tc-rectbl{border-collapse:collapse;width:100%;background:var(--mbu-bg);table-layout:fixed}', '.tc-rectbl td{overflow-wrap:anywhere}', '.tc-rectbl th{text-align:left;font-size:11px;color:var(--mbu-text-dim);border-bottom:1px solid var(--mbu-border);padding:4px 7px;white-space:nowrap}', '.tc-rectbl td{padding:4px 7px;vertical-align:top}', '.tc-rectbl td.tc-rdrag{box-shadow:inset 0 0 0 2px var(--mbu-accent)}', // #635 cells a right-drag touched '.tc-rectbl.gridrows td{border-bottom:1px solid var(--mbu-border)}', // density layouts (same names as the Tracklist tab): compact tighter, cozy airier, normal = default '.tc-rectbl.compact th{padding:2px 7px}.tc-rectbl.compact td{padding:1px 7px}', '.tc-rectbl.cozy th{padding:7px 7px}.tc-rectbl.cozy td{padding:8px 7px}', // grid option: column separators on both tables '.tc-rectbl.gridcols td,.tc-rectbl.gridcols th{border-right:1px solid var(--mbu-border)}.tc-rectbl.gridcols td:last-child,.tc-rectbl.gridcols th:last-child{border-right:none}', '.tc-rectbl.alt tbody tr.tc-recrow:nth-of-type(even) td:not(.tc-diff):not(.tc-copy){background:var(--mbu-bg-raised)}', // zebra skips highlighted cells so their colour always shows', // #376 pending-edit recordings — gold tint + gold title (!important to beat the higher-specificity zebra) /* #564 (majkinetor): "Apollo pending changes on Recordings could be more readable (currently blue over redish)". We painted the background and left the text to MusicBrainz — whose artist links are its own bright blue. On a light page a pale cream tint under that blue is fine; on a dark one the tint is a muddy amber and the blue sits on top of it unreadably. Own both halves: the cell takes the theme's text colour and so do the links inside it, keeping their underline so they still read as links. Contrast is then text-on-background by construction, in either theme, whatever colour MusicBrainz would have used. */ '.tc-rectbl tr.tc-recrow.tc-rec-pending td:nth-child(2){background:var(--mbu-warn-bg)!important;border-radius:3px;color:var(--mbu-text)}', // #376 title pending '.tc-rectbl tr.tc-recrow.tc-art-pending td:nth-child(3){background:var(--mbu-warn-bg)!important;border-radius:3px;color:var(--mbu-text)}', // #376 artist pending '.tc-rectbl tr.tc-recrow.tc-rec-pending td:nth-child(2) a,.tc-rectbl tr.tc-recrow.tc-art-pending td:nth-child(3) a{color:var(--mbu-text);text-decoration:underline;text-decoration-color:var(--mbu-warn)}', '.tc-rectbl tr.tc-recmed td{background:var(--mbu-bg-raised);font-weight:600;color:var(--mbu-accent-deep-text)}', // collapsed-medium expand control (#149) '.tc-rectbl tr.tc-recmed-coll td{padding:0}', '.tc-rectbl .tc-recmed-exp{width:100%;text-align:left;border:none;background:var(--mbu-bg-raised);color:var(--mbu-accent-deep-text);font:600 13px Arial;padding:7px 10px;cursor:pointer}', '.tc-rectbl .tc-recmed-exp:hover{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)}', '.tc-rectbl .tc-recmed-exp.loading{cursor:default;opacity:.7}', '.tc-rectbl tr.tc-recchanged td:first-child{box-shadow:inset 4px 0 0 var(--mbu-accent-text)}', // changed-row marker, like the Tracklist tab', '.tc-rectbl .c-n{color:var(--mbu-text-weak);text-align:right;width:34px;min-width:34px;white-space:nowrap}', '.tc-rectbl .c-sep{width:20px;text-align:center}', // group header (Track | Recording) + a vertical divider down the middle so the two halves read clearly '.tc-rectbl .tc-grouphd th{padding:5px 7px 3px;border-bottom:none;font-size:11px;font-weight:700;letter-spacing:.04em}', '.tc-rectbl .tc-grp{text-align:center;border-radius:5px 5px 0 0}', '.tc-rectbl .tc-grp-l{background:var(--mbu-bg-raised);color:var(--mbu-info)}', '.tc-rectbl .tc-grp-r{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)}', '.tc-rectbl th.c-sep,.tc-rectbl td.c-sep{border-left:1px solid var(--mbu-accent);border-right:1px solid var(--mbu-accent)}', '.tc-rectbl .c-len{color:var(--mbu-text-dim);white-space:nowrap;font-variant-numeric:tabular-nums;text-align:right;width:48px}', '.tc-rectbl .c-sugg{color:var(--mbu-accent-text);text-align:center;width:34px}', '.tc-rectbl .tc-tkt{font-weight:600}', '.tc-rectbl .tc-rec-none{color:var(--mbu-error)}.tc-rectbl .tc-rec-new{color:var(--mbu-ok)}', '.tc-rectbl td.tc-diff{background:var(--mbu-bg-raised);color:var(--mbu-error)}', '.tc-rectbl .tc-dh{background:var(--tc-hl,#e53935);color:#fff;border-radius:2px;padding:0 1px}', // #186 a differing character run — colour configurable (Matching → highlight colour) /* #564 (majkinetor): "Apollo pending changes on Recordings could be more readable (currently blue over redish)" — and, decisively, a DevTools capture of `` inside `span.tc-dh` looking right. It was never the pending tint: it is the DIFF highlight. The span sets its own white foreground, but the run being highlighted is usually an artist LINK, and `.tc-rectbl td a` (0,1,2) matches that directly where `.tc-rectbl .tc-dh` only reaches it by inheritance — so the link kept its --mbu-info blue and sat on the red wash. A link inside the highlight takes the highlight's own colour. !important because the pending rule below is deliberately more specific, and a row that is both pending AND diff-highlighted must not end up with body text on a red background. */ '.tc-rectbl .tc-dh a{color:inherit!important;text-decoration:underline;text-underline-offset:2px}', '.tc-cf{display:inline-block;padding:0 .5px}', // #203 confusable/invisible changed char — enlarged inline (Appearance) + hover tooltip names the codepoint '.tc-cf-inv{background:var(--tc-hl,#e53935);color:#fff;border-radius:2px;padding:0 1px}', // #443 invisible/whitespace char — always visible (glyph + wash), even at 0px enlargement '.tc-jp{display:inline-block;background:var(--tc-hl,#e53935);color:#fff;border-radius:2px;padding:0 1px;font-weight:700}', // #208 missing space (␣) / missing join phrase (␣?␣) around a join phrase', '.tc-rectbl td.tc-dh-len{font-weight:600;border-radius:2px}', // #186 graded length-gap shade (inline bg)', '.tc-rectbl td.tc-copy{background:var(--mbu-ok-bg);color:var(--mbu-ok);font-style:italic}', // flagged to copy the track value on submit '.tc-rectbl .tc-rec-orig{text-decoration:line-through;opacity:.55;font-style:normal;font-weight:400}', // recording original kept beside the → preview #146 '.tc-rectbl .tc-rec-disamb{color:var(--mbu-text-weak);font-weight:400}', // recording disambiguation, grey like native #144 '.tc-rec-video{display:inline-flex;vertical-align:-2px;color:var(--mbu-accent-text);margin-left:1px}', // recording video marker (table + picker) #303 '.tc-rec-video.lead{margin-left:0;margin-right:4px}', // #584 — marker leads the title instead of trailing it '.tc-rec-video svg{display:block}', '.tc-rectbl .tc-rpk-adis{color:var(--mbu-text-weak);font-weight:400}', // artist disambiguation in the table — grey like native, not black (tc-rpk-adis base style is picker-scoped) #186 '.tc-rectbl td.tc-clickable{cursor:pointer}', '.tc-rectbl td.tc-clickable:hover{outline:1px solid #9cc6ab;outline-offset:-1px}', '.tc-rectbl td a{color:var(--mbu-info);text-decoration:none}.tc-rectbl td a:hover{text-decoration:underline}', '.tc-rectbl .tc-recname{font-weight:600}', '.tc-recpop .tc-rpk-copy{padding:5px 10px;border-bottom:1px solid var(--mbu-border);display:flex;flex-direction:column;gap:3px;background:var(--mbu-bg-raised)}', '.tc-recpop .tc-rpk-copy label{cursor:pointer;color:var(--mbu-text);font-size:11px;display:flex;align-items:center;gap:5px}', '.tc-recpop .tc-rpk-row{border-left:3px solid transparent}', '.tc-recpop .tc-rpk-isrcm{font:bold 9.5px Arial;border-radius:8px;padding:0 6px;background:#0a7a8c;color:#fff;vertical-align:1px}', // #662 '.tc-recpop .tc-rpk-row.tc-conf-exact{border-left-color:var(--mbu-info)}', '.tc-recpop .tc-rpk-row.tc-conf-tolerance{border-left-color:var(--mbu-ok)}', '.tc-recpop .tc-rpk-row.tc-conf-near{border-left-color:var(--mbu-warn)}', '.tc-recpop .tc-rpk-row.tc-conf-low{border-left-color:var(--mbu-warn)}', '.tc-recpop .tc-rpk-row.tc-conf-vlow{border-left-color:var(--mbu-error)}', '.tc-rectbl .tc-dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid rgba(0,0,0,.15)}', '.tc-rectbl tr.tc-recrow:hover td:not(.tc-diff):not(.tc-copy){background:var(--mbu-bg)}', /* #583 — the + row action sits in the FIRST position on the right and the ↺ revert moves in beside it, so + is at the same x on every row whether or not that row has something to revert. */ '.tc-rectbl .tc-rec-new-btn{position:absolute;right:3px;top:50%;transform:translateY(-50%);border:none;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-accent-text);font-size:15px;line-height:1;visibility:hidden;padding:1px 4px;border-radius:3px}', '.tc-rectbl tr.tc-recrow:hover .tc-rec-new-btn{visibility:visible}.tc-rectbl .tc-rec-new-btn:hover{background:var(--mbu-bg-hover)}', '.tc-rectbl .tc-recpick{cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);color:var(--mbu-accent-text);border-radius:4px;padding:1px 6px;font:11px Arial;white-space:nowrap}', '.tc-rectbl .tc-recpick:hover{background:var(--mbu-bg-hover)}', '.tc-recpop{position:fixed;z-index:100003;width:410px;overflow:auto;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:6px;box-shadow:0 8px 28px rgba(40,20,80,.28);font:12px Arial}', '.tc-recpop .tc-rpk-hd{position:sticky;top:0;z-index:1}', '.tc-recpop .tc-rpk-hd{padding:7px 10px;background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-accent);border-radius:6px 6px 0 0;display:flex;align-items:baseline;gap:8px}', // #144: title - artist … sec (sec right) '.tc-recpop .tc-rpk-hdmain{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}', '.tc-recpop .tc-rpk-curwrap{border-bottom:1px solid var(--mbu-border)}', // one separator under the whole current-recording group '.tc-recpop .tc-rpk-cur{padding:6px 10px 3px;color:var(--mbu-text);display:flex;align-items:baseline;gap:6px}', '.tc-recpop .tc-rpk-curmain{flex:1;min-width:0}', // #189 title + artist on the left; length pinned right '.tc-recpop .tc-rpk-curactions{display:flex;padding:2px 10px 7px}', // "+ new recording" sits below appears-on, right-aligned '.tc-recpop .tc-rpk-curlbl{color:var(--mbu-text-weak);font-size:11px}.tc-recpop .tc-rpk-curlen{color:var(--mbu-text-weak);font-variant-numeric:tabular-nums;margin-left:auto;flex:none}', '.tc-recpop .tc-rpk-curnone{color:var(--mbu-error)}.tc-recpop .tc-rpk-newcur{color:var(--mbu-ok)}', '.tc-recpop .tc-rpk-newbtn{margin-left:auto;cursor:pointer;border:1px solid var(--mbu-border);background:var(--mbu-bg-raised);color:var(--mbu-ok);border-radius:4px;padding:2px 7px;font:11px Arial}.tc-recpop .tc-rpk-newbtn:hover{background:var(--mbu-ok-bg)}', '.tc-recpop .tc-rpk-qwrap{display:flex;align-items:stretch;gap:6px;margin:8px}', '.tc-recpop .tc-rpk-q{flex:1;min-width:0;padding:5px 7px;border:1px solid var(--mbu-border);border-radius:4px;font:12px Arial;box-sizing:border-box}', '.tc-recpop .tc-rpk-qnew{flex:none;cursor:pointer;border:1px solid var(--mbu-border);background:var(--mbu-bg-raised);color:var(--mbu-ok);border-radius:4px;font:bold 16px Arial;line-height:1;padding:0 10px}.tc-recpop .tc-rpk-qnew:hover{background:var(--mbu-ok-bg)}', '.tc-recpop .tc-rpk-hdby a{color:var(--mbu-info);text-decoration:none}.tc-recpop .tc-rpk-hdby a:hover{text-decoration:underline}', '.tc-recpop .tc-rpk-sec{display:flex;align-items:center;justify-content:space-between;padding:3px 10px;font-size:10px;text-transform:uppercase;letter-spacing:.03em;color:var(--mbu-text-weak);background:var(--mbu-bg-raised)}', '.tc-recpop .tc-rpk-suggsec{cursor:pointer;user-select:none}.tc-recpop .tc-rpk-suggsec:hover{color:var(--mbu-accent-text)}', '.tc-recpop .tc-rpk-suggn{color:var(--mbu-accent-text);font-weight:bold}', // #555 — how many are folded away '.tc-recpop .tc-rpk-caret{display:inline-block;font-size:9px;transition:transform .1s}', '.tc-recpop.tc-sugg-collapsed .tc-rpk-sugg{display:none}.tc-recpop.tc-sugg-collapsed .tc-rpk-caret{transform:rotate(-90deg)}', '.tc-recpop .tc-rpk-relax{text-transform:none;letter-spacing:0;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);border-radius:4px;padding:1px 7px;font:10px Arial;cursor:pointer}', '.tc-recpop .tc-rpk-relax:hover{background:var(--mbu-bg-hover)}.tc-recpop .tc-rpk-relax.on{background:#6f42c1;color:#fff;border-color:var(--mbu-accent)}', '.tc-recpop .tc-rpk-row{padding:5px 10px;cursor:pointer;border-bottom:1px solid var(--mbu-accent)}', '.tc-recpop .tc-rpk-row:hover{background:var(--mbu-bg-hover)}', '.tc-recpop .tc-rpk-main{display:flex;align-items:baseline;gap:6px}', '.tc-recpop .tc-rpk-name{font-weight:600;color:var(--mbu-text)}', '.tc-recpop .tc-rpk-cmt{color:var(--mbu-text-weak);font-size:11px}', '.tc-recpop .tc-rpk-curisrc{padding:0 10px 4px;color:var(--mbu-text-dim);font-size:11px}.tc-recpop .tc-rpk-curisrc-list{font-family:Consolas,monospace;color:var(--mbu-text-dim)}', '.tc-recpop .tc-rpk-len{margin-left:auto;color:var(--mbu-text-dim);font-variant-numeric:tabular-nums;white-space:nowrap}', '.tc-recpop .tc-rpk-by{color:var(--mbu-text-dim);font-size:11px}', '.tc-recpop .tc-rpk-on{color:var(--mbu-text-dim);font-size:11px}', '.tc-recpop .tc-rpk-on a,.tc-recpop .tc-rpk-curon a,.tc-recpop .tc-rpk-name a,.tc-recpop .tc-rpk-by a{color:var(--mbu-info);text-decoration:none}.tc-recpop .tc-rpk-on a:hover,.tc-recpop .tc-rpk-curon a:hover,.tc-recpop .tc-rpk-name a:hover,.tc-recpop .tc-rpk-by a:hover{text-decoration:underline}', '.tc-recpop .tc-rpk-name a{color:inherit}', // title link keeps the result's strong colour; underline on hover only '.tc-recpop .tc-rpk-adis{color:var(--mbu-accent-text);font-size:10px}', // artist disambiguation in the by-line / header #195', '.tc-recpop .tc-rpk-more{color:var(--mbu-text-weak);font-style:italic}', '.tc-recpop .tc-rpk-rgx{color:var(--mbu-text-weak);font-size:10px;font-weight:600}', // header subtitle = the song (track) artist + length; current-recording artist + its full appears-on '.tc-recpop .tc-rpk-hdby{color:var(--mbu-text-dim);font-weight:normal}.tc-recpop .tc-rpk-hdlen{color:var(--mbu-text-weak);font-weight:normal;font-variant-numeric:tabular-nums;margin-left:auto;flex:none}', // #144: push sec to the right edge (aligns with the rows' length column) '.tc-recpop .tc-rpk-curby{color:var(--mbu-text-dim);font-size:12px}', '.tc-recpop .tc-rpk-curon{padding:0 10px 4px;color:var(--mbu-text-dim);font-size:11px}', '.tc-recpop .tc-rpk-isrc{color:var(--mbu-accent-text);font-size:11px;font-family:Consolas,monospace}', '.tc-recpop .tc-rpk-fdiff{background:var(--mbu-bg-raised);color:var(--mbu-error);border-radius:2px;padding:0 2px}', '.tc-recpop .tc-rpk-empty{padding:8px 10px;color:var(--mbu-text-weak);font-style:italic}', // #555 — an IN-PROGRESS placeholder pulses its background, so "still working" // is distinguishable from "finished, nothing found" at a glance instead of // having to read the sentence. Terminal states never get this class. '.tc-recpop .tc-rpk-busy{animation:tc-rpk-pulse 1.15s ease-in-out infinite}', '@keyframes tc-rpk-pulse{0%,100%{background:var(--mbu-bg)}50%{background:var(--mbu-bg-hover)}}', '.tc-rpk-dots{animation:tc-rpk-dots 1.15s ease-in-out infinite;border-radius:3px;padding:0 3px}', '@keyframes tc-rpk-dots{0%,100%{background:transparent}50%{background:var(--mbu-bg-hover)}}', '@media (prefers-reduced-motion:reduce){.tc-recpop .tc-rpk-busy,.tc-rpk-dots{animation:none;background:var(--mbu-bg-raised)}}', // hide the native recording table from the first paint (no flash) and let our table use the // full width instead of MB's .half-width column (#119) 'body.tc-rec-on #track-recording-assignation{display:none!important}', 'body.tc-rec-on #recordings .half-width{max-width:none!important;width:auto!important}', // the recordings tab has one native
per medium (legend "Medium N" + its own assignation // table) plus an Options fieldset. Our Apollo table is inserted INTO the first medium's fieldset, so // we can't hide that one wholesale: hide every OTHER native fieldset (other mediums' tables + Options), // and in the host fieldset strip the box + hide the native legend and table. Leaves only our table. #119 'body.tc-rec-on #recordings fieldset:not(:has(#tc-recwrap)){display:none!important}', 'body.tc-rec-on #recordings fieldset:has(#tc-recwrap){border:none!important;margin:0!important;padding:0!important}', 'body.tc-rec-on #recordings fieldset:has(#tc-recwrap) > legend{display:none!important}', 'body.tc-rec-on #recordings fieldset:has(#tc-recwrap) > table{display:none!important}', // #149: hide every native per-medium "Edit" (load-tracks) button — Apollo // renders all mediums collapsed and triggers the load itself on expand. 'body.tc-rec-on #recordings button[data-click="loadTracks"]{display:none!important}', // MOBILE: the side-by-side Track|Recording table (8 fixed-% columns) // can't fit a phone — stack each comparison into a card: the track line // (#, title, artist, length) on top, then the matched recording line // beneath, marked by the confidence dot. #issue '@media (max-width:820px){', ' #tc-recwrap .tc-rec-tb{gap:5px 8px}', ' .tc-rectbl{display:block}', ' .tc-rectbl > colgroup,.tc-rectbl > thead{display:none}', ' .tc-rectbl > tbody{display:block}', ' .tc-rectbl > tbody > tr.tc-recrow{display:grid;grid-template-columns:20px 1fr auto;column-gap:7px;row-gap:1px;padding:8px 8px}', ' .tc-rectbl.gridrows > tbody > tr.tc-recrow{border-bottom:1px solid var(--mbu-border)}', ' .tc-rectbl > tbody > tr.tc-recrow > td{display:block;padding:0;border:none!important;background:transparent}', ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(1){grid-column:1;grid-row:1;text-align:left}', // # ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(2){grid-column:2;grid-row:1;font-weight:600}', // track title ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(3){grid-column:2;grid-row:2;color:var(--mbu-text-dim);font-size:12px}', // track artist ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(4){grid-column:3;grid-row:1;width:auto}', // track length ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(5){grid-column:1;grid-row:3;text-align:center;padding-top:4px!important}', // dot ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(6){grid-column:2;grid-row:3}', // recording title ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(7){grid-column:2;grid-row:4;color:var(--mbu-text-dim);font-size:12px}', // recording artist ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(8){grid-column:3;grid-row:3;width:auto}', // recording length ' .tc-rectbl > tbody > tr.tc-recrow > td:nth-child(5),.tc-rectbl > tbody > tr.tc-recrow > td:nth-child(6),.tc-rectbl > tbody > tr.tc-recrow > td:nth-child(8){margin-top:3px;padding-top:4px;border-top:1px solid var(--mbu-border)!important}', ' .tc-rectbl td.c-sep{border-left:none;border-right:none}', ' .tc-rectbl tr.tc-recmed td,.tc-rectbl tr.tc-recmed-coll td{display:block}', ' .tc-rectbl .tc-rec-rev,.tc-rectbl .tc-rec-new-btn{visibility:visible}', // touch: no hover '}', ].join('\n'); document.head.appendChild(s); } // build the toolbar + table shell once; the body is (re)rendered separately so a re-render during // auto-match updates rows live without rebuilding/resetting the toolbar. #119 function renderRecMirror(wrap) { wrap.innerHTML = '
' + '' + // flexible filler: its text changes absorb here, never reflowing the bar '' + '' + '' + '' + '' + '' + /* gear moved to the Apollo launcher */ '
' + '' + '' + '' + '' + '' + '
TrackRecording
#TitleArtistLengthTitleArtistLength
'; // wire the toolbar (once) wireCutoff(wrap); const amBtn = wrap.querySelector('.tc-rec-am'); if (amBtn) { amBtn.onclick = () => { if (_autoMatching) stopMatching(); else autoMatchRecordings(); }; // #577 /* #575 round 4: the shell is rebuilt when the Recordings tab is entered, and it is built from static markup reading "⚡ Match" — so walking into the tab during a running pass produced a button that offered to start something already running. Clicking it then went to stopMatching(), and nothing about the button said so: the literal "100% unresponsive to clicks". Re-adopt the running state when the shell is (re)built. */ if (_autoMatching) { amBtn.classList.add('busy'); const lbl = amBtn.querySelector('.tc-rec-am-lbl'); if (lbl) lbl.textContent = _matchStop ? '⏳ Stopping…' : '■ Stop'; amBtn.title = 'Stop matching — the recordings already linked are kept'; } } // #582 — restore the last pass's verdict; the shell above was rebuilt from // static markup and would otherwise come back blank. const amStatus = wrap.querySelector('.tc-rec-amstatus'); if (amStatus && _recLastStatus && !_autoMatching) amStatus.textContent = _recLastStatus; const revCaret = wrap.querySelector('.tc-rec-revcaret'); if (revCaret) revCaret.onclick = () => openMiniMenu(revCaret, [ { label: '↺ Revert all', title: 'revert every recording to its page-load state', onClick: revertAllRecordings }, { label: '✕ Clear all', title: 'set every track to a new recording', onClick: clearAllRecordings }, ]); wireRecCellContextMenu(wrap); renderRecBody(wrap); } // Right-click a recording title/artist cell to toggle its "copy track value to // the recording (on submit)" flag — the same flag the picker checkbox sets, but // without opening the picker. Eligibility proxies the NATIVE update checkbox // (nativeDiffFlag), so it works for casing-only diffs too. #146 // plain → just the clicked field on that row // Ctrl → both fields (that have a diff) on the clicked row // Alt → the clicked field down the whole column (every diffing row) // Ctrl+Alt → both fields on every row = the whole side of the table (#443). The two // sides copy in opposite directions, so the full table is two gestures. function wireRecCellContextMenu(wrap) { const tbl = wrap.querySelector('.tc-rectbl'); if (!tbl) return; const copyOn = (t, f) => f === 'title' ? !!u(t.updateRecordingTitle) : !!u(t.updateRecordingArtist); const eligible = (t, f) => !!t && (nativeDiffFlag(t, f) || copyOn(t, f)); wireRecCellDrag(wrap, tbl, { copyOn, eligible }); tbl.addEventListener('contextmenu', e => { // #635: a right-button drag already did its work on every cell it touched if (_recDragSwallow) { e.preventDefault(); _recDragSwallow = false; Log.debug('#635 contextmenu after a right-drag — swallowed'); return; } const tr = e.target.closest('tr.tc-recrow'); if (!tr) return; const recRows = () => wrap.querySelectorAll('tbody tr.tc-recrow'); const wholeSide = e.ctrlKey && e.altKey; // #443: both fields, every row = the whole side // #443 whole-side sweep for the track side (recording -> track): copy a recording's // title AND artist onto its track for one row, reused across every row in the gesture. // Mirrors the per-field setters below; the shared cache fetches each artist entity once. const _sideEntCache = new Map(); const copyRecToTrack = async (m, i) => { const t = koTrack(m, i); if (!t) return; const rec = u(t.recording); const rn = rec ? u(rec.name) : null; if (rn != null && rn !== '' && u(t.name) !== rn) { _selfEdit = true; try { t.name(rn); } catch (x) {} finally { _selfEdit = false; } } if (u(t.updateRecordingTitle)) setCopy('title', { mi: m, ti: i }, false); // now equal -> clear the moot flag const recAc = rec && u(rec.artistCredit), recNames = recAc && (u(recAc.names) || []); if (recNames && recNames.length) { const nk = n => (n.artist ? (u(u(n.artist).gid) || '') : '') + '|' + (u(n.name) || '') + '|' + (u(n.joinPhrase) || ''); const tNames = u(u(t.artistCredit).names) || []; const same = tNames.length === recNames.length && tNames.every((n, k) => nk(n) === nk(recNames[k])); if (!same) { const names = []; for (const n of recNames) { const a = u(n.artist), gid = a && u(a.gid); let full = null; if (gid) { if (!_sideEntCache.has(gid)) _sideEntCache.set(gid, await fetchEntity(gid)); full = _sideEntCache.get(gid); } names.push({ artist: full || a, name: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' }); // full entity persists the match (#348) } _selfEdit = true; try { t.artistCredit({ names }); } catch (x) { Log.warn(`#443 side copy: track ${m}.${i} artistCredit setter threw: ${x && x.message}`); } finally { _selfEdit = false; } } if (u(t.updateRecordingArtist)) setCopy('artist', { mi: m, ti: i }, false); } }; // #348: right-click a TRACK title cell → set the track NAME to its recording's name, IMMEDIATELY // (the mirror of copying track→recording). It's a real title edit, so the Tracklist tab shows the // row changed and its ↺ reverts it. Alt = do the whole column. if (e.target.closest('td.tc-tkt')) { e.preventDefault(); if (wholeSide) { (async () => { for (const row of recRows()) await copyRecToTrack(+row.dataset.mi, +row.dataset.ti); _tlRefreshed = false; scheduleSync(); rerenderRec(); })(); return; } // #443 whole track side const setFromRec = (m, i) => { const t = koTrack(m, i); const rec = t && u(t.recording); const rn = rec ? u(rec.name) : null; if (rn != null && rn !== '' && u(t.name) !== rn) { _selfEdit = true; try { t.name(rn); } catch (x) {} finally { _selfEdit = false; } } // #420: the track title now equals the recording's, so a pending "rename recording" // flag is a no-op — clear it (and its green indicator) instead of leaving it stale. if (t && u(t.updateRecordingTitle)) { setCopy('title', { mi: m, ti: i }, false); Log.info(`#420 track ${m}.${i}: title copied from recording — cleared the now-moot rename-recording flag`); } }; if (e.altKey) wrap.querySelectorAll('tbody tr.tc-recrow').forEach(row => setFromRec(+row.dataset.mi, +row.dataset.ti)); else setFromRec(+tr.dataset.mi, +tr.dataset.ti); _tlRefreshed = false; scheduleSync(); // #348: title changed here (a self-edit the watcher ignores) — re-sync the Tracklist mirror so the new title + changed-row marker show reliably, not on a racy tab-switch rerenderRec(); return; } // #348: right-click a TRACK artist cell → set the track's ARTIST CREDIT to its recording's, immediately. Alt = whole column. if (e.target.closest('td.tc-tka')) { e.preventDefault(); if (wholeSide) { (async () => { for (const row of recRows()) await copyRecToTrack(+row.dataset.mi, +row.dataset.ti); _tlRefreshed = false; scheduleSync(); rerenderRec(); })(); return; } // #443 whole track side // entity-aware equality: same artist GID + credited-as + join phrase per name. NOT acText — two // different artists sharing a credited name ("Mariz" ≠ "Mariz") must still copy the recording's entity. const nameKey = n => (n.artist ? (u(u(n.artist).gid) || '') : '') + '\x01' + (u(n.name) || '') + '\x01' + (u(n.joinPhrase) || ''); const _entCache = new Map(); // gid → full entity, so a whole-column copy fetches each artist once const fullEntity = async gid => { if (!_entCache.has(gid)) _entCache.set(gid, await fetchEntity(gid)); return _entCache.get(gid); }; const setArtistFromRec = async (m, i) => { const t = koTrack(m, i), rec = t && u(t.recording), recAc = rec && u(rec.artistCredit); const recNames = recAc && (u(recAc.names) || []); if (!recNames || !recNames.length) { Log.warn(`#348 artist copy: track ${m}.${i} — recording has no artist credit (recording ${rec ? 'present' : 'null'}) — nothing to copy`); return; } // #420 (artist twin): once track and recording agree, a pending "update recording // artist" flag is a no-op — clear it so its indicator doesn't linger. const clearMootArtistFlag = () => { if (u(t.updateRecordingArtist)) { setCopy('artist', { mi: m, ti: i }, false); Log.info(`#420 track ${m}.${i}: artist copied from recording — cleared the now-moot update-recording-artist flag`); } }; const tNames = u(u(t.artistCredit).names) || []; if (tNames.length === recNames.length && tNames.every((n, k) => nameKey(n) === nameKey(recNames[k]))) { Log.info(`#348 artist copy: track ${m}.${i} — track artist already identical to the recording's — skipped`); clearMootArtistFlag(); return; } const gids = recNames.map(n => (n.artist ? (u(u(n.artist).gid) || '∅') : '∅')).join(', '); // Fetch the FULL artist entity for each credit (same as the paste-MBID resolve → pickArtist // path). Verified live: writing the recording's own LEAN artist — or W.MB.entity(gid,name) — // gets DROPPED by MB on its next re-derive; only the full entity, with its integer id, persists // the match. Keep the recording's credited-as + join phrase. #348 const names = []; for (const n of recNames) { const a = u(n.artist), gid = a && u(a.gid); const full = gid ? await fullEntity(gid) : null; names.push({ artist: full || a, name: u(n.name) || '', joinPhrase: u(n.joinPhrase) || '' }); } try { _selfEdit = true; try { t.artistCredit({ names }); } finally { _selfEdit = false; } // read back the WRITTEN track credit — ∅ here (vs a real gid in the recording) means // the entity link was dropped and only the credited text stuck (the "set without match" bug). const wrote = (u(u(t.artistCredit).names) || []).map(n => (n.artist ? (u(u(n.artist).gid) || '∅') : '∅')).join(', '); Log.info(`#348 artist copy: track ${m}.${i} ← recording "${acText(u(t.artistCredit))}" (recording gid(s): ${gids} · written track gid(s): ${wrote})`); clearMootArtistFlag(); } catch (x) { Log.warn(`#348 artist copy: track ${m}.${i} — artistCredit setter threw: ${x && x.message}`); } }; (async () => { if (e.altKey) { for (const row of wrap.querySelectorAll('tbody tr.tc-recrow')) await setArtistFromRec(+row.dataset.mi, +row.dataset.ti); } else await setArtistFromRec(+tr.dataset.mi, +tr.dataset.ti); // #348: the copy is a SELF-edit, which the tracklist change-watcher ignores — so the // Tracklist mirror only refreshes via the lazy _tlRefreshed flag on the next tab-switch, // and that races (the copy lands in the model but the tab paints the stale unmatched slot). // Re-sync the mirror ourselves so the resolved artist shows immediately/reliably. _tlRefreshed = false; scheduleSync(); rerenderRec(); })(); return; } const inTitle = !!e.target.closest('td.tc-recname'); const field = inTitle ? 'title' : (e.target.closest('td.tc-recartist') ? 'artist' : null); if (!field) return; // not a recording cell → leave the native menu // On a recording title/artist cell ALWAYS suppress the native menu, even // when there's no difference — toggling on some cells but popping the OS // menu on others was confusing; just do nothing (no native menu) when // there's no copy to offer. #146 (maintainer) e.preventDefault(); const mi = +tr.dataset.mi, ti = +tr.dataset.ti, t0 = koTrack(mi, ti); if (!eligible(t0, field)) return; // no copy available here → do nothing const target = !copyOn(t0, field); // toggle based on the clicked cell's current state const apply = (m, i, f) => { const t = koTrack(m, i); if (eligible(t, f)) setCopy(f, { mi: m, ti: i }, target); }; if (wholeSide) recRows().forEach(row => ['title', 'artist'].forEach(f => apply(+row.dataset.mi, +row.dataset.ti, f))); // #443 whole recording side (both fields, every row) else if (e.ctrlKey) ['title', 'artist'].forEach(f => apply(mi, ti, f)); // whole row else if (e.altKey) recRows().forEach(row => apply(+row.dataset.mi, +row.dataset.ti, field)); // whole column else apply(mi, ti, field); rerenderRec(); }); } // #635: right-button DRAG over the cells = the right-click on each cell it touches, so // several rows or columns are done with the mouse alone (the modifiers still work). // recording side → every touched cell's copy is set to what the FIRST cell toggles to, // so a drag either marks or unmarks, never a mix // track side → every touched cell copies from its recording, immediately // A drag stays on the side it started on (the two sides copy in opposite directions). // A press that never reaches a second cell is an ordinary right-click and is left to the // contextmenu handler; once a drag has reached one, that trailing contextmenu is swallowed. let _recDragSwallow = false; const REC_DRAG_CELLS = { 'td.tc-recname': ['rec', 'title'], 'td.tc-recartist': ['rec', 'artist'], 'td.tc-tkt': ['track', 'title'], 'td.tc-tka': ['track', 'artist'] }; function recDragCell(el) { for (const [sel, [side, field]] of Object.entries(REC_DRAG_CELLS)) { const td = el && el.closest && el.closest(sel), tr = td && td.closest('tr.tc-recrow'); if (td && tr) return { td, side, field, mi: +tr.dataset.mi, ti: +tr.dataset.ti, key: tr.dataset.mi + '.' + tr.dataset.ti + '.' + field }; } return null; } function wireRecCellDrag(wrap, tbl, { copyOn, eligible }) { if (tbl._tcRecDrag) return; tbl._tcRecDrag = true; tbl.addEventListener('mousedown', e => { if (e.button !== 2 || e.ctrlKey || e.altKey || e.shiftKey || e.metaKey) return; // modifiers keep their click meaning const start = recDragCell(e.target); if (!start) return; _recDragSwallow = false; const t0 = koTrack(start.mi, start.ti); const drag = { side: start.side, start, touched: new Map(), moved: false, target: start.side === 'rec' ? !copyOn(t0, start.field) : null, // the rec side's mark/unmark, decided by the first cell menuSeen: false }; // Linux and macOS fire the contextmenu on the PRESS, Windows on the release: note whether the // browser's own one has come already (the track side's dispatched ones are not the browser's) const sawMenu = ev => { if (ev.isTrusted) drag.menuSeen = true; }; window.addEventListener('contextmenu', sawMenu, true); const mark = () => wrap.querySelectorAll('tbody tr.tc-recrow').forEach(tr => { // rerenderRec rebuilds rows: re-mark by key for (const [sel, [side, field]] of Object.entries(REC_DRAG_CELLS)) { if (side !== drag.side) continue; const td = tr.querySelector(sel); if (td) td.classList.toggle('tc-rdrag', drag.touched.has(tr.dataset.mi + '.' + tr.dataset.ti + '.' + field)); } }); const touch = c => { if (drag.touched.has(c.key)) return; drag.touched.set(c.key, c); if (drag.side === 'rec') { const t = koTrack(c.mi, c.ti); if (eligible(t, c.field)) setCopy(c.field, { mi: c.mi, ti: c.ti }, drag.target); else Log.debug(`#635 right-drag: ${c.key} has no copy to offer — skipped`); rerenderRec(); } else { // the right-click's own copy for this one cell (the handler's plain, no-modifier path), // on the cell as it is NOW: the previous copy's rerenderRec may have replaced the row const sel = Object.keys(REC_DRAG_CELLS).find(s => REC_DRAG_CELLS[s][0] === 'track' && REC_DRAG_CELLS[s][1] === c.field); const td = wrap.querySelector(`tbody tr.tc-recrow[data-mi="${c.mi}"][data-ti="${c.ti}"] ${sel}`); if (td) td.dispatchEvent(new MouseEvent('contextmenu', { bubbles: true, cancelable: true, button: 2 })); else Log.warn(`#635 right-drag: row ${c.key} is gone from the table — not copied`); } mark(); }; const move = ev => { if (!(ev.buttons & 2)) return end(ev); const c = recDragCell(document.elementFromPoint(ev.clientX, ev.clientY)); if (!c || c.side !== drag.side || (!drag.moved && c.key === start.key)) return; if (!drag.moved) { drag.moved = true; Log.info(`#635 right-drag on the ${drag.side === 'rec' ? 'recording' : 'track'} side from ${start.key}` + (drag.side === 'rec' ? ` — ${drag.target ? 'marking' : 'unmarking'} copies` : ' — copying from the recordings')); touch(start); } touch(c); }; const end = () => { window.removeEventListener('mousemove', move, true); window.removeEventListener('mouseup', end, true); setTimeout(() => window.removeEventListener('contextmenu', sawMenu, true), 0); // after the release's own contextmenu, if it has one if (!drag.moved) return; // one cell: the contextmenu handler does it // The release's contextmenu (Windows fires it after mouseup) lands on whatever is under // the pointer, which may be outside the table (majkinetor, #635): swallow it page-wide. // Where it fired on the press already (Linux, macOS), nothing comes: waiting for one would // swallow the next right-click instead (a CI run on Linux caught that). if (drag.menuSeen) Log.debug('#635 the contextmenu came with the press — nothing to swallow on release'); else { _recDragSwallow = true; const swallow = ev => { ev.preventDefault(); ev.stopPropagation(); _recDragSwallow = false; window.removeEventListener('contextmenu', swallow, true); Log.debug('#635 contextmenu after a right-drag — swallowed (' + ((ev.target && ev.target.tagName) || '?') + ')'); }; window.addEventListener('contextmenu', swallow, true); setTimeout(() => { _recDragSwallow = false; window.removeEventListener('contextmenu', swallow, true); }, 400); } wrap.querySelectorAll('.tc-rdrag').forEach(td => td.classList.remove('tc-rdrag')); if (drag.side === 'track') { _tlRefreshed = false; scheduleSync(); } rerenderRec(); Log.info(`#635 right-drag done: ${drag.touched.size} cell(s) — ${[...drag.touched.keys()].join(', ')}`); }; window.addEventListener('mousemove', move, true); window.addEventListener('mouseup', end, true); }); } // custom Cutoff picker — a colored-dot dropdown that uses the SAME hex palette as the row dots // (a native copy track title to the recording (on submit)' : '') + (showCopyA ? '' : '') + '' : '') + '
' + '
suggestions ▾
finding suggestions…
' + '
search results
type to search…
'; const newBtn = pop.querySelector('.tc-rpk-qnew'); if (newBtn) newBtn.onclick = () => pickNewRecording(entry); const ctEl = pop.querySelector('.tc-rpk-ct'); if (ctEl) ctEl.onchange = () => { setCopy('title', entry, ctEl.checked); rerenderRec(); }; const caEl = pop.querySelector('.tc-rpk-ca'); if (caEl) caEl.onchange = () => { setCopy('artist', entry, caEl.checked); rerenderRec(); }; // fill the current recording's full "appears on" (all releases, linkable) — not in the page model, so fetch it if (curGid) { wsJson(ORIGIN + '/ws/2/recording/' + curGid + '?fmt=json&inc=artist-credits+releases+release-groups+isrcs', { label: 'linked recording detail', stale: () => !_recPop }) .then(res => { const j = res.json; if (!_recPop) return; // a failed lookup must not render as "appears on: —" (that reads as "nowhere") — #555 if (!j) { const f = pop.querySelector('.tc-rpk-curon-list'); if (f && !res.stale) { f.classList.remove('tc-rpk-dots'); f.textContent = '(lookup failed)'; } return; } const el = pop.querySelector('.tc-rpk-curon-list'); if (el) { const seen = new Set(), rels = []; (j.releases || []).forEach(rl => { const k = rl.id || rl.title; if (rl.title && !seen.has(k)) { seen.add(k); const rg = rl['release-group']; rels.push({ name: rl.title, gid: rl.id, rgGid: rg ? rg.id : null, rgName: rg ? rg.title : null }); } }); el.classList.remove('tc-rpk-dots'); // stop the pulse — this is now a settled answer (#555) el.innerHTML = rels.length ? relLinksHtml(rels, 0) : '—'; } // Artist disambiguations in the header: the page (KO) model doesn't carry // them, so backfill from the WS2 artist-credit once it arrives (#195). const acEl = pop.querySelector('.tc-rpk-curby-ac'); if (acEl && j['artist-credit']) { const h = acLinksWs(j['artist-credit'], true); if (h) acEl.innerHTML = h; } // ISRC line — reveal only when the recording actually has ISRC(s). #196 const isrcWrap = pop.querySelector('.tc-rpk-curisrc'), isrcEl = pop.querySelector('.tc-rpk-curisrc-list'); const isrcs = (j.isrcs || []).filter(Boolean); if (isrcWrap && isrcEl && isrcs.length) { isrcEl.textContent = isrcs.join(', '); isrcWrap.style.display = ''; } }).catch(() => {}); } _recPopDrag(pop.querySelector('.tc-rpk-hd')); // header is the drag handle _recPopReposition(); // dock it right + tall now the content (and height) exist const q = pop.querySelector('.tc-rpk-q'), suggBox = pop.querySelector('.tc-rpk-sugg'), resBox = pop.querySelector('.tc-rpk-res'); // collapsible suggestions — click the header to fold it away and see search results immediately (remembered) const suggSec = pop.querySelector('.tc-rpk-suggsec'); const applySuggCollapse = () => pop.classList.toggle('tc-sugg-collapsed', !!SETTINGS.recSuggCollapsed); if (suggSec) { applySuggCollapse(); suggSec.onclick = () => { SETTINGS.recSuggCollapsed = !SETTINGS.recSuggCollapsed; saveSettings(); applySuggCollapse(); _recPopReposition(); }; } // click a row to pick it — but a click on a link (release / artist) inside the row just follows the // link (new tab) and leaves the picker open; it must NOT pick + close the window. #119 const wire = box => box.querySelectorAll('.tc-rpk-row').forEach(row => { row.onclick = e => { if (e.target.closest('a')) return; pickRecording(entry, data[row.dataset.gid]); }; }); // de-dupe: a recording already shown under SUGGESTIONS is not repeated in SEARCH RESULTS below (#119) const suggGids = new Set(); let lastResults = []; const paintResults = () => { const filtered = lastResults.filter(r => !suggGids.has(r.gid)); resBox.innerHTML = filtered.length ? filtered.map(d => recRowHtml(d, ctx)).join('') : '
' + (lastResults.length ? 'all matches are shown in suggestions' : 'no matches') + '
'; wire(resBox); }; // the count next to the section label — the whole point when collapsed, since the // rows themselves are hidden and there's otherwise no hint anything is in there. #555 const suggN = pop.querySelector('.tc-rpk-suggn'); const setSuggCount = n => { if (suggN) suggN.textContent = n > 0 ? ' (' + n + ')' : ''; }; // suggestions are lazy in MB — render what's there, else trigger findRecordingSuggestions and poll let isrcList = []; // #662: recordings holding the track's ISRC, listed first const renderSugg = () => { const mbList = (typeof ko.suggestedRecordings === 'function' ? (u(ko.suggestedRecordings) || []) : []).map(suggData); const isrcGids = new Set(isrcList.map(d => d.gid)); const list = isrcList.concat(mbList.filter(d => !isrcGids.has(d.gid))); list.forEach(s => { data[s.gid] = s; }); if (!list.length) return false; suggGids.clear(); list.forEach(s => suggGids.add(s.gid)); suggBox.innerHTML = list.map(d => recRowHtml(d, ctx)).join(''); wire(suggBox); setSuggCount(list.length); Log.debug('picker suggestions for track', entry.number, '→', list.length); if (lastResults.length) paintResults(); // suggestions arrived after a search → drop any now-duplicate rows return mbList.length > 0; // keep waiting for MB's own when only the ISRC holders are in }; { const isrc = fcIsrcFor(entry.mi, entry.ti, u(ko.name)); if (isrc) isrcRecordings(isrc).then(recs => { if (!_recPop || !recs.length) return; isrcList = recs; Log.debug('picker: ISRC', isrc, '→', recs.length, 'recording(s) on top'); renderSugg(); }); } if (!renderSugg()) { try { getEditor().recordingAssociation.findRecordingSuggestions(ko); } catch (e) { Log.warn('findRecordingSuggestions failed', e.message); } let tries = 0; const poll = () => { if (!_recPop) return; const loading = typeof ko.loadingSuggestedRecordings === 'function' ? u(ko.loadingSuggestedRecordings) : false; if (!loading && renderSugg()) { rerenderRec(); return; } // also refresh the ⊕ count on the row if (!loading && tries > 3) { suggBox.innerHTML = '
no suggestions
'; return; } if (++tries < 40) setTimeout(poll, 250); }; setTimeout(poll, 250); } q.value = u(ko.name) || ''; q.title = 'free-form — raw MB query, e.g. isrc:USXXX… or artist:"…"'; // auto-query for THIS track. Narrow = title + artist + a ±10s length window (precise). Relaxed // ("show all") = title only, ignoring artist & length — for classical, covers, re-recordings. #119 // the relaxed/narrow choice is remembered (SETTINGS) so it carries across picker opens and reloads. let relax = !!SETTINGS.recRelax; const esq = s => String(s || '').replace(/(["\\])/g, '\\$1'); const autoQuery = () => { const title = u(ko.name), artist = acText(u(ko.artistCredit)), len = u(ko.length); if (relax) return title; // broad title search (covers / loose matches), identical to typing the title — NOT an exact phrase let qy = 'recording:"' + esq(title) + '"'; if (artist) qy += ' AND artist:"' + esq(artist) + '"'; if (len) qy += ' AND dur:[' + Math.max(0, len - 10000) + ' TO ' + (len + 10000) + ']'; return qy; }; let seq = 0, tmr = null; const runSearch = async (query, fallbackTitle) => { const my = ++seq; // paste a recording MBID or a /recording/ URL → resolve and LINK it // immediately (same as pasting an MBID in the artist picker). #189 const gid = mbidFrom(query); if (gid) { resBox.innerHTML = '
resolving recording…
'; const rec = await fetchRecordingById(gid); if (my !== seq || !_recPop) return; if (rec) pickRecording(entry, rec); // links the recording + closes the picker else resBox.innerHTML = '
recording MBID not found
'; return; } // paste an ISRC → resolve to its recording(s). Exactly one → link // immediately (same as an MBID); several → list them to choose from. #196 const isrc = isrcFrom(query); if (isrc) { resBox.innerHTML = '
resolving ISRC ' + esc(isrc) + '…
'; const recs = await fetchRecordingsByIsrc(isrc); if (my !== seq || !_recPop) return; if (recs.length === 1) { pickRecording(entry, recs[0]); return; } // one hit → link + close if (recs.length > 1) { recs.forEach(rr => { data[rr.gid] = rr; }); lastResults = recs; paintResults(); return; } resBox.innerHTML = '
no recording with ISRC ' + esc(isrc) + '
'; return; } resBox.innerHTML = '
searching…
'; // superseded queries are dropped before they cost a request (typing fires one // per pause and the WS2 gate is ~1 req/s); a throttled search says so instead // of rendering an empty list. #555 const sOpts = { stale: () => my !== seq || !_recPop, onThrottle: () => { if (my === seq && _recPop) resBox.innerHTML = '
MusicBrainz is throttling requests — retrying…
'; } }; let results = await searchRecordings(query, sOpts); if (fallbackTitle && results && !results.length) results = await searchRecordings(u(ko.name) || '', sOpts); // smart query too tight → broaden if (my !== seq || !_recPop) return; if (!results) { resBox.innerHTML = '
search failed — MusicBrainz is busy, try again (see the log)
'; return; } results.forEach(rr => { data[rr.gid] = rr; }); lastResults = results; paintResults(); // paintResults hides any recording already listed under suggestions }; // "show all" toggles relaxed mode and re-runs the track-derived search (independent of any manual edit). // the button is painted from the remembered state on open, and the toggle persists it. #119 const relaxBtn = pop.querySelector('.tc-rpk-relax'); const paintRelax = () => { if (!relaxBtn) return; relaxBtn.classList.toggle('on', relax); relaxBtn.textContent = relax ? 'narrow' : 'show all'; relaxBtn.title = relax ? 'back to a precise search (title + artist + ±10s length)' : 'relaxed search — show all recordings with this title, ignoring artist & length'; }; paintRelax(); if (relaxBtn) relaxBtn.onclick = () => { relax = !relax; SETTINGS.recRelax = relax; saveSettings(); paintRelax(); runSearch(autoQuery(), !relax); }; // once the user edits the box, search their raw text (free Lucene); the initial run is the auto query q.oninput = () => { clearTimeout(tmr); tmr = setTimeout(() => runSearch(q.value, false), 300); }; q.focus(); q.select(); runSearch(autoQuery(), !relax); _recPopReposition(); setTimeout(() => { document.addEventListener('mousedown', _recPopOutside, true); document.addEventListener('keydown', _recPopKey, true); window.addEventListener('scroll', _recPopReposition, true); window.addEventListener('resize', _recPopReposition); }, 0); } // the Recordings tab panel (#recordings) — check the PANEL not the inner table (we hide the table) function recordingsVisible() { const p = document.getElementById('recordings'); return !!(p && p.offsetParent !== null); } // hide the native recording-assignment table and render the Apollo comparison table in its place. // Both read/write the same MB model, so toggling Original/Apollo lets you work in either view (#119). function showRecMirror() { _apolloUsed = true; recStyle(); applyHlColor(); snapshotRecOriginals(); // capture the page-load recording associations, for revert // Anchor on a loaded medium's assignation table when present; otherwise (every // medium collapsed, #149) mount into the recordings panel itself so we still // render — each collapsed medium then shows an expand control. const tbl = document.getElementById('track-recording-assignation'); const host = tbl ? tbl.parentElement : document.getElementById('recordings'); if (!host) return; let wrap = document.getElementById('tc-recwrap'); if (!wrap) { wrap = document.createElement('div'); wrap.id = 'tc-recwrap'; host.insertBefore(wrap, tbl || host.firstChild); } document.body.classList.add('tc-rec-on'); // CSS hides the native table + widens the column (no flash) renderRecMirror(wrap); // optional: auto-match the Recordings tab on load (settings), once per page session if (SETTINGS.autoMatchRec && !_recAutoMatchedOnce) { _recAutoMatchedOnce = true; setTimeout(() => autoMatchRecordings(), 0); } } let _recAutoMatchedOnce = false; function hideRecMirror() { document.body.classList.remove('tc-rec-on'); closeRecPop(); _recPopPos = null; // drop any dragged location so the next visit docks fresh const w = document.getElementById('tc-recwrap'); if (w) w.remove(); } /* ═══════════════════════════════════════════════════════════════════════ COMPACT NAVIGATION — hide the native editor's step-tab row + footer and relocate them compactly: a segmented step switcher (Release | Tracklist | Recordings | ⊟ Edit note) on the right of the entity-tab row, the wizard buttons (Cancel / Prev / Next / Finish) top-right by the title, and "Add medium" at the right of the tracklist table. Everything is a proxy to the still-present (visually-hidden) native control, so MB's wizard is untouched and the feature reverts cleanly when toggled off. ═══════════════════════════════════════════════════════════════════════ */ function navOn() { return SETTINGS.apolloEnabled !== false && SETTINGS.compactNav !== false; } // compact nav is part of Apollo — off with the global switch const DIFF_ICON = ''; const STEP_DEFS = [ { key: 'information', label: 'Release', title: 'Release information' }, { key: 'duplicates-tab', label: 'Duplicates', title: 'Release duplicates' }, // Add-release only; hidden when its native tab is absent { key: 'tracklist', label: 'Tracklist', title: 'Tracklist' }, { key: 'recordings', label: 'Recordings', title: 'Recordings' }, { key: 'edit-note', diff: true, title: 'Edit note — review changes & add an edit note' } ]; // only the submit button is kept (Cancel/Prev/Next are reachable via the entity tabs / step switcher); // it shows ONLY when there are pending changes. Both forms are mirrored — whichever MB renders. const WIZ_DEFS = [ { id: 'enter', label: '✓ Enter edit', cls: 'tc-wiz-enter', find: f => f.querySelector('#enter-edit') || [...f.querySelectorAll('button')].find(b => /enter edit/i.test(b.textContent)) }, { id: 'finish', label: '✓ Finish', cls: 'tc-wiz-finish', find: f => [...f.querySelectorAll('button')].find(b => /^\s*finish\s*$/i.test(b.textContent)) } ]; // Right-side paginators (#140) — proxy MB's native footer Cancel / Previous / Next. // MB data-binds their visibility (Prev hidden on the first step, Next on the last), // mirrored in syncNav via `vis(nat)`. Cancel carries the native `.negative` class. const PAGE_DEFS = [ { id: 'cancel', label: '✕ Cancel', cls: 'tc-wiz-cancel', find: f => f.querySelector('button.negative') || [...f.querySelectorAll('button')].find(b => /^\s*cancel\s*$/i.test(b.textContent)) }, { id: 'prev', label: '‹ Prev', cls: 'tc-wiz-prev', find: f => [...f.querySelectorAll('button')].find(b => /previous/i.test(b.textContent)) }, { id: 'next', label: 'Next ›', cls: 'tc-wiz-next', find: f => [...f.querySelectorAll('button')].find(b => /^\s*next\s*»?\s*$/i.test(b.textContent)) } ]; function hasChanges() { try { const re = W.MB && W.MB.releaseEditor; return !!(re && typeof re.allowsSubmission === 'function' && re.allowsSubmission()); } catch (e) { return false; } } function editorEl() { return document.getElementById('release-editor'); } function stepNavEl() { const e = editorEl(); return e && e.querySelector(':scope > ul.ui-tabs-nav'); } function navFooterEl() { const e = editorEl(); return e && e.querySelector(':scope > div.buttons'); } function stepLink(key) { const n = stepNavEl(); return n && n.querySelector('a[href="#' + key + '"]'); } function activeStepKey() { const n = stepNavEl(); const a = n && n.querySelector('li.ui-tabs-active a'); return a ? (a.getAttribute('href') || '').slice(1) : ''; } function vis(el) { return !!(el && el.style.display !== 'none' && !el.disabled); } // native inline display reflects MB's per-step show/hide let _navStyled = false; function navStyle() { if (_navStyled) return; _navStyled = true; const css = MBU_TOKENS + MBU_UI_CSS + ` .tc-nav-vh{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0!important} .tc-nav-steps{display:inline-flex;align-items:stretch;border:1px solid var(--mbu-accent);border-radius:7px;overflow:hidden;background:var(--mbu-bg);font-family:Arial,Helvetica,sans-serif} .tc-nav-step{font:600 13px Arial;padding:5px 14px;border:none;border-right:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-deep-text);cursor:pointer;line-height:1.5;white-space:nowrap} .tc-nav-step:last-child{border-right:none} .tc-nav-step:hover{background:var(--mbu-bg-raised)} .tc-nav-step.active{background:#6a4d9a;color:var(--mbu-text-on-accent)} .tc-nav-step.tc-nav-diff{display:flex;align-items:center;padding:5px 10px} .tc-nav-step.tc-nav-diff svg{width:15px;height:15px;display:block} /* mirror MB's native tab states: disabled (e.g. Recordings until the tracklist is complete) + error-tab (validation warnings) */ .tc-nav-step:disabled,.tc-nav-step.tc-nav-disabled{opacity:.45;cursor:not-allowed;color:var(--mbu-accent-text);background:var(--mbu-bg)} .tc-nav-step.tc-nav-warn{background:var(--mbu-error-bg);color:var(--mbu-error)} .tc-nav-step.tc-nav-warn.active{background:var(--mbu-error-bg);color:var(--mbu-error)} .tc-nav-step.tc-nav-warn::before{content:"⚠";margin-right:5px} .tabs.tc-nav-sticky{position:sticky;top:0;background:var(--mbu-bg);z-index:20} /* freeze the nav row when scrolling */ /* MB's green TAGGER badge (Picard) sits at the header's top-right and collides with the frozen nav — lift it clear above the nav row, and keep the sticky nav above it so it's never covered */ body:has(.tabs.tc-nav-sticky) .releaseheader span.tagger-icon{position:relative;z-index:1;transform:translateY(-16px)} /* with the step-tab row hidden, strip the editor's jQuery-UI frame + the panel's top padding so no empty box is left */ #release-editor.tc-nav-on{margin-top:0;padding:0;border:none;background:none;box-shadow:none} #release-editor.tc-nav-on > .ui-tabs-panel{padding-top:0;border:none} #tc-nav-steps-wrap{position:absolute;right:0;bottom:6px;z-index:5} #tc-nav-right{display:flex;align-items:center;gap:10px} #tc-nav-wiz{display:inline-flex;align-items:center;gap:2px} .tc-nav-wbtn{font:13px Arial;padding:3px 9px;border:1px solid transparent;background:none;border-radius:5px;cursor:pointer;color:var(--mbu-text-dim);display:inline-flex;align-items:center;gap:4px;white-space:nowrap} .tc-nav-wbtn:not(:disabled):hover{background:var(--mbu-bg-raised);border-color:var(--mbu-border)} .tc-nav-wbtn:disabled{opacity:.4;cursor:default;font-weight:normal;color:var(--mbu-text-weak);background:none;border-color:transparent} .tc-nav-wbtn.tc-wiz-finish,.tc-nav-wbtn.tc-wiz-enter{color:var(--mbu-ok);font-weight:600} .tc-nav-wbtn.tc-wiz-finish:hover,.tc-nav-wbtn.tc-wiz-enter:hover{background:var(--mbu-ok-bg);border-color:var(--mbu-ok)} .tc-addmed{font:13px Arial;padding:4px 12px;border:1px solid var(--mbu-accent);background:var(--mbu-bg);color:var(--mbu-accent-text);border-radius:5px;cursor:pointer;margin-left:auto} .tc-addmed:hover{background:var(--mbu-bg-raised)} /* #140 — full-width nav bar: step switcher + Finish on the left, Cancel/Prev/Next paginators on the right */ #tc-nav-bar{display:flex;align-items:center;justify-content:space-between;gap:12px;position:sticky;top:0;z-index:20;background:var(--mbu-bg);padding:5px 2px 6px;border-bottom:1px solid var(--mbu-accent);margin-bottom:6px} #tc-nav-left{display:flex;align-items:center;gap:10px;min-width:0;flex-wrap:wrap} #tc-nav-pager{display:inline-flex;align-items:center;gap:4px;flex-shrink:0} .tc-nav-wbtn.tc-wiz-cancel{color:var(--mbu-error)} .tc-nav-wbtn.tc-wiz-cancel:not(:disabled):hover{background:var(--mbu-error-bg);border-color:var(--mbu-error)} .tc-nav-wbtn.tc-wiz-prev,.tc-nav-wbtn.tc-wiz-next{color:var(--mbu-accent-deep-text);font-weight:600;border-color:var(--mbu-accent);background:var(--mbu-bg-raised)} .tc-nav-wbtn.tc-wiz-prev:not(:disabled):hover,.tc-nav-wbtn.tc-wiz-next:not(:disabled):hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} /* #141 Zen editing — the nav-bar release title (shown only in zen) + hiding the page chrome */ #tc-nav-title{display:none} /* two lines (album / artist + versions), centred, tight so they fit the nav button height (#141) */ body.tc-zen-on #tc-nav-title{display:flex;flex-direction:column;justify-content:center;flex:1 1 0;min-width:0;text-align:center;line-height:1.15;padding:0 14px} #tc-nav-title a{color:inherit;text-decoration:none}#tc-nav-title a:hover{text-decoration:underline} #tc-nav-title .tc-nav-title-album{font:600 14px Arial;color:var(--mbu-accent-deep-text);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* #290: mirror MB's native "modification pending" mark (span.mp, #ffdd99) on the release name — the native header that carries it is hidden by zen. Inline span so it hugs the text; same gold as native. */ #tc-nav-title .tc-nav-title-name{border-radius:3px;padding:0 1px} #tc-nav-title.tc-nav-title-pending .tc-nav-title-name{background:var(--mbu-warn-bg);padding:0 5px} #tc-nav-title.tc-nav-title-pending .tc-nav-title-name a{color:var(--mbu-warn)} #tc-nav-title .tc-nav-title-artist{font:12px Arial;color:var(--mbu-text-dim);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} #tc-nav-title .tc-nav-title-ver{color:var(--mbu-text-weak)} body.tc-zen-on .header,body.tc-zen-on .releaseheader,body.tc-zen-on #page > .tabs,body.tc-zen-on #footer{display:none!important} /* zen: drop the page's top spacing so the sticky nav bar pins flush to the top and doesn't drift on scroll (#141) */ body.tc-zen-on #page{padding-top:0!important;margin-top:0!important} body.tc-zen-on #page.fullwidth{padding-top:0!important;margin-top:0!important} /* MOBILE: the top nav bar wraps — step switcher + Finish on line 1, the Cancel/Prev/Next paginators stay grouped, and (in zen) the release title drops to its own full-width line so nothing gets squeezed or clipped. */ @media (max-width: 820px){ #tc-nav-bar{flex-wrap:wrap;gap:5px 8px;padding:5px 2px} #tc-nav-left{flex:1 1 auto} #tc-nav-pager{order:2} .tc-nav-step,.tc-nav-wbtn{padding:3px 8px;font-size:12px} body.tc-zen-on #tc-nav-title{order:3;flex:1 1 100%;text-align:left;padding:1px 2px 0} body.tc-zen-on #tc-nav-title .tc-nav-title-album, body.tc-zen-on #tc-nav-title .tc-nav-title-artist{white-space:normal} }`; const s = document.createElement('style'); s.id = 'tc-nav-style'; s.textContent = css; document.head.appendChild(s); } // build (once) the full-width nav bar at the top of the editor (#140): // left → step switcher (Release | Tracklist | Recordings | ⊟) + Finish/Enter // right → Cancel / Prev / Next paginators // Everything proxies the still-present (visually-hidden) native control. function buildNav() { navStyle(); if (document.getElementById('tc-nav-bar')) return; // already built const ed = editorEl(); if (!ed) return; // step switcher const steps = document.createElement('div'); steps.className = 'tc-nav-steps'; steps.id = 'tc-nav-steps'; STEP_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-step' + (d.diff ? ' tc-nav-diff' : ''); b.dataset.step = d.key; b.dataset.baseTitle = d.title; b.innerHTML = d.diff ? DIFF_ICON : d.label; b.title = d.title; b.onclick = () => { const l = stepLink(d.key); if (l) l.click(); setTimeout(syncNav, 40); }; steps.appendChild(b); }); // submit button (Finish / Enter edit) — sits to the right of the switcher const wiz = document.createElement('div'); wiz.id = 'tc-nav-wiz'; WIZ_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-wbtn ' + d.cls; b.dataset.wiz = d.id; b.textContent = d.label; b.onclick = () => { ensureApolloEditNote(); const f = navFooterEl(); const nat = f && d.find(f); if (nat) nat.click(); setTimeout(syncNav, 40); }; // #130: set the edit note on the compact-nav submit path too wiz.appendChild(b); }); // right-side paginators (Cancel / Prev / Next) const pager = document.createElement('div'); pager.id = 'tc-nav-pager'; PAGE_DEFS.forEach(d => { const b = document.createElement('button'); b.className = 'tc-nav-wbtn ' + d.cls; b.dataset.page = d.id; b.textContent = d.label; b.onclick = () => { const f = navFooterEl(); const nat = f && d.find(f); if (nat) nat.click(); setTimeout(syncNav, 40); }; pager.appendChild(b); }); const left = document.createElement('div'); left.id = 'tc-nav-left'; left.append(steps, wiz); const title = document.createElement('div'); title.id = 'tc-nav-title'; // #141 Zen: release title · artist · versions (shown only in zen) const bar = document.createElement('div'); bar.id = 'tc-nav-bar'; bar.append(left, title, pager); ed.insertBefore(bar, ed.firstChild); // a dedicated full-width row at the top of the editor (frozen on scroll) fillNavTitle(); } // Zen editing (#141): hide the site header, release header, entity tabs + footer, // leaving just the Apollo nav bar (which gains the release title) and the editor. function applyZen() { const on = !!(SETTINGS.zenMode && navOn() && editorEl()); document.body.classList.toggle('tc-zen-on', on); if (on) fillNavTitle(); } // populate the nav-bar title: " by (N versions)", all links — // mirrors the native release header that zen hides. Cheap; only fills once. function fillNavTitle() { const el = document.getElementById('tc-nav-title'); if (!el) return; let rel; try { rel = release(); } catch (e) { return; } if (!rel) return; const gid = u(rel.gid), name = u(rel.name) || ''; if (!name) return; const artist = acLinks(u(rel.artistCredit)) || ''; // live-update: rebuild only when the title or artist actually changed (not every tick) (#141) const sig = name + '\x01' + artist; if (el.dataset.sig === sig) return; el.dataset.sig = sig; const album = gid ? '
' + esc(name) + '' : esc(name); // version count — reuse the native release header's "see all versions" link let ver = ''; const rh = document.querySelector('.releaseheader'); const va = rh && [...rh.querySelectorAll('a')].find(a => /version/i.test(a.textContent || '')); if (va) { const m = (va.textContent.match(/(\d+)/) || [])[1]; ver = ' (' + (m ? m + ' versions' : 'all versions') + ')'; } el.innerHTML = '
' + album + '
' + '
' + artist + ver + '
'; } // keep the proxies in sync with the native state each tick function syncNav() { if (!navOn()) return; const active = activeStepKey(); document.querySelectorAll('#tc-nav-steps .tc-nav-step').forEach(b => { mbuCls(b, 'active', b.dataset.step === active); const link = stepLink(b.dataset.step), li = link && link.closest('li'); // mirror MB's native tab state if (!link) { mbuProp(b.style, 'display', 'none'); return; } // step's native tab not present on this page (e.g. Duplicates exists only on Add) mbuProp(b.style, 'display', ''); const dis = !!(li && li.classList.contains('ui-state-disabled')); const panel = document.getElementById(b.dataset.step); // MB sets error-tab for some errors but not link errors — also scan the panel for a visible field-error const err = !!((li && li.classList.contains('error-tab')) || (panel && panel.querySelector('.field-error[data-visible="1"]'))); mbuProp(b, 'disabled', dis); mbuCls(b, 'tc-nav-disabled', dis); mbuCls(b, 'tc-nav-warn', err); mbuProp(b, 'title', (dis && link && link.title) ? link.title : (b.dataset.baseTitle || b.title)); // disabled → MB's "enter all track info…" hint }); const f = navFooterEl(); const changed = hasChanges(); // the submit button appears only when there are pending changes // #290: MIRROR MB's native "modification pending" mark. MB wraps the release // title in (gold #ffdd99) when the entity has open edits — // it's there on entrance. Zen hides the native header, so reflect that exact // state onto the release name in our toolbar — read straight from the DOM, no // logic of our own. The .mp node stays in the DOM even while the header is hidden. const navTitle = document.getElementById('tc-nav-title'); if (navTitle) mbuCls(navTitle, 'tc-nav-title-pending', !!document.querySelector('.releaseheader h1 .mp')); WIZ_DEFS.forEach(d => { const proxy = document.querySelector('#tc-nav-wiz [data-wiz="' + d.id + '"]'); if (!proxy) return; const nat = f && d.find(f); mbuProp(proxy.style, 'display', (vis(nat) && changed) ? '' : 'none'); }); // paginators stay in a fixed position — Prev/Next are never hidden, just disabled // when MB's native button isn't applicable (Prev on the first step, Next on the last) (#140) PAGE_DEFS.forEach(d => { const proxy = document.querySelector('#tc-nav-pager [data-page="' + d.id + '"]'); if (!proxy) return; const nat = f && d.find(f); mbuProp(proxy, 'disabled', !vis(nat)); }); updateStickyOffsets(); } // stack the sticky Apollo toolbars BELOW the frozen entity-tab row (both default to top:0 and would // otherwise overlap, hiding the pinned tab row). The row height is dynamic, so measure it each sync. function updateStickyOffsets() { const tabs = document.getElementById('tc-nav-bar'); // #140: the full-width nav bar is the frozen header now const h = (navOn() && tabs) ? tabs.offsetHeight : 0; const w = document.getElementById('tc-mirror-wrap'); if (w) w.style.top = h ? h + 'px' : ''; const r = document.querySelector('#tc-recwrap .tc-rec-tb'); if (r) r.style.top = h ? h + 'px' : ''; } // the native "Add medium" OPENER — it lives in the editor footer (present on the Tracklist step) and // opens MB's add-medium parser dialog. (NB: the button inside #add-medium-dialog is the dialog's commit // button, disabled until tracks are parsed — proxying that one does nothing.) function nativeAddMediumBtn() { const f = navFooterEl(); let b = f && [...f.querySelectorAll('button')].find(x => /add medium/i.test(x.textContent)); if (b) return b; const tl = document.getElementById('tracklist'); return tl ? [...tl.querySelectorAll('button')].find(x => /add medium/i.test(x.textContent) && !x.closest('#add-medium-dialog')) : null; } // move "Add medium" to the right end of the Apollo tracklist (opposite "Add tracks"), proxying the native opener function relocateAddMedium() { const nat = nativeAddMediumBtn(); const want = navOn() && document.getElementById('tc-mirror-wrap') && nat; if (!want) { const p = document.getElementById('tc-addmed'); if (p) p.remove(); return; } if (document.getElementById('tc-addmed')) return; // proxy already placed (the add-row may rebuild → re-add then) const btn = document.createElement('button'); btn.id = 'tc-addmed'; btn.className = 'tc-addmed'; btn.title = 'add a new medium'; btn.textContent = '+ Add medium'; btn.onclick = () => { const n = nativeAddMediumBtn(); if (n) n.click(); }; const rows = document.querySelectorAll('.tc-medsec .tc-addrow'); const host = rows[rows.length - 1]; if (host) host.appendChild(btn); // far right of the last medium's add-tracks row else { const secs = document.querySelectorAll('.tc-medsec'); const last = secs[secs.length - 1]; if (last) { const row = document.createElement('div'); row.className = 'tc-addrow'; row.appendChild(btn); last.appendChild(row); } } // locked last medium → own right-aligned row } function applyNav() { if (!editorEl()) return; if (navOn()) { buildNav(); editorEl().classList.add('tc-nav-on'); const sn = stepNavEl(); if (sn) sn.classList.add('tc-nav-vh'); const f = navFooterEl(); if (f) f.classList.add('tc-nav-vh'); syncNav(); } else { editorEl().classList.remove('tc-nav-on'); const sn = stepNavEl(); if (sn) sn.classList.remove('tc-nav-vh'); const f = navFooterEl(); if (f) f.classList.remove('tc-nav-vh'); const tabs = document.querySelector('#page > .tabs, .tabs'); if (tabs) tabs.classList.remove('tc-nav-sticky'); ['tc-nav-bar', 'tc-nav-steps-wrap', 'tc-nav-addbar'].forEach(id => { const e = document.getElementById(id); if (e) e.remove(); }); } relocateAddMedium(); updateStickyOffsets(); } /* ── edit note: credit Apollo when it's used, appended to the bottom (keep import-script notes) ── */ let _apolloUsed = false; // set true once a tracklist/recordings mirror is shown — i.e. Apollo is in use function ensureApolloEditNote() { if (!_apolloUsed) return; const ta = document.getElementById('edit-note-text'); if (!ta) return; // MB's plain edit-note textarea (name=edit_note) const cur = ta.value || ''; if (/Apollo Editor/i.test(cur)) return; // already credited — don't duplicate const s = (typeof GM_info !== 'undefined' && GM_info.script) || {}; // same shape as the other scripts' edit notes const note = (s.name || 'Apollo Editor') + ' v' + scriptVersion() + ' by ' + (s.author || 'majkinetor') + ' - ' + (s.homepageURL || s.homepage || HELP_URL); const kept = cur.replace(/\s+$/, ''); // keep any existing note (import scripts etc.), append below const val = kept ? kept + '\n\n' + note : note; // use the native setter so a React-controlled textarea actually picks up the change (plain .value can be ignored — #130) try { const set = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set; set.call(ta, val); } catch (e) { ta.value = val; } ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } // append the note the moment the user submits — by then any import-script note is already present function watchSubmit() { document.addEventListener('click', e => { const b = e.target && e.target.closest && e.target.closest('button'); if (!b) return; const f = navFooterEl(); if (f && f.contains(b) && /^\s*(finish|enter edit)\s*$/i.test(b.textContent || '')) ensureApolloEditNote(); }, true); // capture phase: runs before MB reads the textarea for submission } // #412: pulse the pinned compact-nav bar while MB is submitting the edit — a big edit can take a // while. Drive it purely off MB's OWN indicator: the release editor inserts a // `Submitting edits…` node (a Knockout `if`-bound `.loading-message`) the moment submission // starts, and removes it if the submit fails. So we don't watch clicks at all — we watch for that // node appearing (→ pulse) and disappearing (→ stop). It can't fire while editing because the node // simply isn't in the DOM until MB actually submits; a successful submit navigates away. const _isSubmitNode = n => n && n.nodeType === 1 && /submitting edits/i.test(n.textContent || ''); function watchSubmitFlash() { const obs = new MutationObserver(muts => { for (const m of muts) { for (const n of m.addedNodes) if (_isSubmitNode(n)) document.body.classList.add('tc-saving'); for (const n of m.removedNodes) if (_isSubmitNode(n)) document.body.classList.remove('tc-saving'); } }); obs.observe(document.body, { childList: true, subtree: true }); } /* ── Release information takeover (#129): tidy the first tab — hide the help bubble, clean the external links, and add an Apollo gear. Toggled by the shared Original/Apollo button. ── */ let _riStyled = false; let _riExtFs = null; // the External-links
, remembered so we can move it back function riStyle() { if (_riStyled) return; _riStyled = true; const css = MBU_TOKENS + MBU_UI_CSS + ` body.tc-ri-on .tc-ri-helphidden{display:none!important} /* #652: the release artist's and labels' badges and offers, by their fields (the Tracklist's colours) */ .tc-ri-am{display:flex;flex-wrap:wrap;gap:4px 10px;margin:4px 0 0;font:12px var(--mbu-font)} /* #652: credited-as in the label column, a row per artist line, lined up with it */ body.tc-ri-on #information .tc-ri-artlbl > :not(.tc-ri-lead){display:none!important} #information .tc-ri-artlbl{vertical-align:top;padding-top:0} .tc-ri-art .tc-aslot{position:relative} .tc-ri-leadrow{position:absolute;top:0;bottom:0;right:calc(100% + 10px);width:var(--tc-ri-lead-w,160px);display:flex;align-items:center;justify-content:flex-end;gap:6px;box-sizing:border-box} .tc-ri-leadrow .tc-credwrap{flex:1 1 auto;min-width:0;display:flex;align-items:center} #information .tc-ri-leadrow input.tc-cred{width:100%!important;max-width:none!important} /* otherwise the Tracklist's credited-as box, as is */ .tc-ri-art .tc-aslot{margin:0 0 4px} .tc-ri-art .tc-mirror td.c-art{box-shadow:none!important} .tc-ri-art .tc-search{flex:1 1 auto!important;min-width:0;border-radius:var(--mbu-radius)!important} #information .tc-ri-art .tc-search:not(.tc-has-nm) input.nm,#information .tc-ri-art .tc-search input.nm:focus{flex:1 1 auto!important;min-width:120px!important} /* a filled, unfocused name hugs its text like the Tracklist (× then disambiguation right after it) */ #information .tc-ri-art .tc-joinwrap{flex:0 0 64px;justify-content:flex-end} /* narrow: the name and its disambiguation need the room */ #information .tc-ri-art input.tc-join{width:auto!important;flex:1 1 auto;min-width:0!important;text-align:right} .tc-ri-rb{flex:0 0 auto;min-width:44px;display:inline-flex;justify-content:flex-start;font:12px var(--mbu-font)} .tc-ri-chip{display:inline-flex;align-items:center;gap:5px} .tc-ri-nm{color:var(--mbu-text-dim)} /* #652: the Tracklist's artist cell in place of MusicBrainz's artist field */ body.tc-ri-on #information td.release-artist.tc-ri-artcell > :not(.tc-ri-art):not(.tc-ri-am){display:none!important} .tc-ri-art .tc-mirror{width:100%;border-collapse:collapse;table-layout:fixed} .tc-ri-art .tc-mirror td.c-art{padding:0;overflow:visible!important} .tc-ri-art,.tc-ri-art .tc-mirror{overflow:visible!important} /* MusicBrainz pins the form's inputs to 354px with !important: give the cell's fields back their Tracklist sizes */ #information .tc-ri-art :is(input.nm,input.tc-join){width:auto!important;max-width:none!important;min-width:0!important} #information .tc-ri-art .tc-search:not(.matched):not(.tc-has-nm) input.nm{flex:1 1 auto} .tc-ri-art .tc-acts{width:auto;min-width:22px} /* inside the label field, left of its search icon: the cell is too narrow for it beside the field */ #information span.autocomplete:has(> .tc-ri-lab){position:relative} .tc-ri-lab{position:absolute;right:28px;top:50%;transform:translateY(-50%);line-height:1;pointer-events:auto} html:has(.mmthf-pin) .tc-ri-lab{right:48px} /* Mammoth pins its icon inside the field, just left of the search icon: sit clear of it */ :is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge{font-size:10px;font-weight:bold;border-radius:9px;padding:1px 7px;color:var(--mbu-text-on-accent);white-space:nowrap;cursor:help;text-transform:none} :is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.rg{background:#1f8a4c}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.set{background:#6c757d}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.high{background:#2f6fd6}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.disc{background:#0a7a8c} :is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.low{background:#e0a800}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.user{background:var(--mbu-accent)}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.cred{background:#b5179e}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.alias{background:#1f8a7a}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.pos{background:#a0522d}:is(.tc-ri-am,.tc-ri-lab,.tc-ri-rb) .tc-badge.plat{background:#0b6e99} :is(.tc-ri-am,.tc-ri-rb) .tc-badge.tc-ri-pick{cursor:pointer} .tc-ri-am .tc-tic{display:inline-flex;align-items:center;justify-content:center;min-width:22px;min-height:22px;color:#0a7a8c;text-decoration:none;cursor:pointer} .tc-ri-am .tc-tic.discogs-conflict{color:#c0392b}.tc-ri-am .tc-tic.discogs-mismatch{color:#b4791f} .tc-ri-am .tc-ri-mk{all:unset;cursor:pointer;min-width:22px;min-height:22px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);color:var(--mbu-accent-text);font-size:13px} .tc-ri-am .tc-ri-mk:hover{background:var(--mbu-bg-hover)} /* hide the inline "?" help / info icons + guidance across the whole Release-information panel */ body.tc-ri-on #information .tooltip-wrapper, body.tc-ri-on #information .icon.help, body.tc-ri-on #information span.img.help, body.tc-ri-on #information .inline-help, body.tc-ri-on #information a.help, body.tc-ri-on #information .guidance, body.tc-ri-on #information .guidance-popover, /* hide help bubbles only — never a functional editor bubble (URL cleanup, add/edit link) */ body.tc-ri-on #information .bubble:not(:has(input,button,select,textarea)){display:none!important} /* MB's link-editor popups must clear our sticky compact-nav (z-index 20), else the first link's popup opens under the nav bar and looks cut off */ body.tc-ri-on .dialog.popover, body.tc-ri-on .bubble:has(input,button,select,textarea){z-index:50} /* #461: "add a new artist/label/…" opens an iframe inside a .modal-backdrop that makes its OWN stacking context at z-index:auto(0) — so the iframe's internal z-index can't escape to clear the artist-credit bubble we lift to 50 above. Elevate the whole backdrop context so the creation form sits on top of the credit editor, not trapped underneath it. */ body.tc-ri-on .modal-backdrop{z-index:210} /* ---- two-column layout: form on the left, external links lifted into the (now-used) right column ---- */ body.tc-ri-on #information{display:flex;flex-wrap:wrap;gap:14px 30px;align-items:flex-start;max-width:100%;box-sizing:border-box} /* wrap → links stack below the form on a narrow window instead of overlapping it */ body.tc-ri-on #information > div.half-width{flex:0 1 620px;min-width:0;width:auto} /* form keeps its natural width */ /* ---- #143: compact, low-noise Release-info form, matching Apollo's purple ---- */ body.tc-ri-on #information > div.half-width fieldset{border:none;margin:0 0 12px;padding:0} /* drop the boxy fieldset frames */ body.tc-ri-on #information > div.half-width legend{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:var(--mbu-accent-text);padding:0 0 5px;margin:0 0 4px;border-bottom:1px solid var(--mbu-accent);width:100%;box-sizing:border-box} body.tc-ri-on #information table.row-form{border-collapse:collapse;width:100%} body.tc-ri-on #information table.row-form > tbody > tr > td{padding:2px 6px;vertical-align:middle} /* tighter rows */ body.tc-ri-on #information table.row-form label{font-size:12px;color:var(--mbu-text-dim);font-weight:normal} /* the original bolds only the Title + Artist labels among all the field captions — match that, keeping the rest (and the checkbox labels) light so they aren't intrusive (#143) */ body.tc-ri-on #information table.row-form label[for="title"], body.tc-ri-on #information table.row-form label[for="release-artist"]{font-weight:600;color:var(--mbu-text-dim)} body.tc-ri-on #information input[type=text], body.tc-ri-on #information select, body.tc-ri-on #information textarea{font-size:12px;padding:2px 6px;border:1px solid var(--mbu-accent);border-radius:4px;box-shadow:none} /* no background → MB's green auto-fill tint survives */ body.tc-ri-on #information input[type=text]:focus, body.tc-ri-on #information select:focus, body.tc-ri-on #information textarea:focus{border-color:var(--mbu-accent);outline:none} /* "Additional information": the native annotation textarea renders narrower than the disambiguation input, and this fieldset's wider "Disambiguation:" caption pushed both fields ~23px right of the fields above. Pin its label column to the same 150px the main form uses so the fields left-align with Title/Barcode, and make both fields fill the column so they're the same width and right-align with the rest. */ /* :not(#external-links-editor) — MB's external-links editor is ALSO a table.row-form inside the information fieldset; without the exclusion this 150px label-column width hit its favicon cell (normally 30px) and shoved every link URL ~150px to the right, misaligning the icons. */ body.tc-ri-on #information fieldset.information table.row-form:not(#external-links-editor) > tbody > tr > td:first-child{width:150px} body.tc-ri-on #information fieldset.information textarea#annotation, body.tc-ri-on #information fieldset.information input#comment{width:100%!important;max-width:none!important;box-sizing:border-box} /* MB pins the annotation textarea to 354px via an !important rule in its (cross-origin) stylesheet — override it so the field fills the column like the input */ body.tc-ri-on #information .buttons button,body.tc-ri-on #information button.styled-button{font-size:12px} body.tc-ri-on #information .lookup-performed{background-color:var(--mbu-ok-bg)!important;color:var(--mbu-text)!important} /* soften MB's bright auto-fill green to a pale tint (#143) */ body.tc-ri-on #information > div.documentation{display:none} /* the contextual help text — replaced by the links column */ /* #143: on-demand help popover. The native field bubbles still carry the clickable link to the *selected* entity ("You selected …"); we surface that next to the focused field instead of the removed help column — without bringing back the verbose style-guide noise. (unscoped: the element lives on ; visibility is gated by the .on class, only added while Apollo is on.) */ body.tc-ri-on #tc-ri-rightcol{flex:1 1 340px;min-width:300px;max-width:100%;box-sizing:border-box} /* links take the remaining width, but wrap below the form when there isn't room for both; never wider than the row */ /* External links matches the form sections: no boxy border, same compact purple header (#143) */ body.tc-ri-on #tc-ri-rightcol > fieldset{margin-top:0;max-width:100%;min-width:0;box-sizing:border-box;border:none;padding:0} body.tc-ri-on #tc-ri-rightcol > fieldset > legend{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:var(--mbu-accent-text);padding:0 0 5px;margin:0 0 4px;border-bottom:1px solid var(--mbu-accent);width:100%;box-sizing:border-box} body.tc-ri-on #tc-ri-rightcol #external-links-editor{max-width:100%;box-sizing:border-box} /* #297: front cover thumbnail under the external-links section (display only) */ #tc-ri-cover{margin:14px 0 0} #tc-ri-cover .tc-ri-cover-h{font:600 11px Arial;letter-spacing:.06em;text-transform:uppercase;color:var(--mbu-accent-text);padding:0 0 5px;margin:0 0 6px;border-bottom:1px solid var(--mbu-accent)} #tc-ri-cover a{display:inline-block;line-height:0} /* shrink-wrap the image so only the cover itself is the link, not the empty space beside it */ #tc-ri-cover img{display:block;width:180px;max-width:100%;height:auto;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);box-shadow:0 1px 5px rgba(40,20,80,.14)} /* ---- external links as a grid: the URL row spans every column, the link's type combos flow into aligned columns beneath it, and "Add another relationship" (the [+]) lands in the last cell. ---- */ body.tc-ri-on #external-links-editor{display:block} body.tc-ri-on #external-links-editor > tbody{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(185px,100%),1fr));column-gap:14px;row-gap:2px;padding-left:45px;align-items:center;box-sizing:border-box} /* URL line spans all columns; pulled back so the favicon sits at the column's left edge */ body.tc-ri-on #external-links-editor tr.external-link-item{grid-column:1 / -1;display:flex;align-items:center;gap:9px;padding:7px 6px 1px;margin-left:-45px;border-radius:var(--mbu-radius);position:relative} body.tc-ri-on #external-links-editor tr.external-link-item:hover{background:var(--mbu-bg-raised)} body.tc-ri-on #external-links-editor tr.external-link-item > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.external-link-item > td:first-child{flex:none;width:30px;height:30px;display:flex;align-items:center;justify-content:center;cursor:context-menu;position:relative;top:4px} /* larger favicon → right-click to edit URL */ body.tc-ri-on #external-links-editor .favicon{transform:scale(1.45);transform-origin:center;margin-right:0} /* master's size; drop MB's 4px margin-right that pushed the scaled icon off-centre and clipped it (#143) */ body.tc-ri-on #external-links-editor tr.external-link-item > td:last-child{flex:1;min-width:0} body.tc-ri-on #external-links-editor a.url{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;font-size:12px} /* hover edit/remove icons removed. link-actions -> display:contents; the edit pencils are kept in the DOM (positioned, invisible) so our JS proxy clicks still anchor MB's editor popup near the link instead of 0,0 */ body.tc-ri-on #external-links-editor td.link-actions{display:contents} body.tc-ri-on #external-links-editor td.link-actions > button.edit-item{position:absolute;left:6px;top:8px;width:18px;height:18px;opacity:0;pointer-events:none;margin:0;padding:0} /* whole-link remove ("Remove link") — revealed on URL-row hover at the right end (no layout shift) */ body.tc-ri-on #external-links-editor tr.external-link-item td.link-actions > button.remove-item{display:inline-flex;align-items:center;order:9;margin:0 2px 0 8px;transform:scale(.85);opacity:0;transition:opacity .12s} body.tc-ri-on #external-links-editor tr.external-link-item:hover td.link-actions > button.remove-item{opacity:.5} body.tc-ri-on #external-links-editor tr.external-link-item td.link-actions > button.remove-item:hover{opacity:1} /* each type combo is one grid cell: [x] [type fills the cell] [video] [!]. A fixed left slot is reserved for the per-type [x] so the type text lines up whether or not a remove button is present (single vs multi type) */ body.tc-ri-on #external-links-editor tr.relationship-item{display:flex;align-items:center;gap:5px;min-width:0;padding:0 0 1px 22px;position:relative} body.tc-ri-on #external-links-editor tr.relationship-item > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.relationship-item > td:first-child{display:none} /* the cell content is a 3-track grid [ type (1fr) | video | ! ] so the select always ends at the same x and the carets line up across rows, whether or not a video checkbox / error badge is present */ body.tc-ri-on #external-links-editor tr.relationship-item > td:last-child{display:grid;grid-template-columns:minmax(0,1fr) 18px 16px;align-items:center;column-gap:2px;flex:1;min-width:0} body.tc-ri-on #external-links-editor tr.relationship-item .relationship-content{grid-column:1;display:flex;align-items:center;min-width:0} body.tc-ri-on #external-links-editor tr.relationship-item .relationship-content > label:first-child{display:none} /* the "Type:" caption */ /* a relationship with a date period ("stream for free (1111-11-11 – 1112-11-11)") needs more room than one 185px type cell, but not the whole row — span two cells so several dated types still share a wide row. The type stays on one line; only a very long date wraps to a second line *inside* the cell, so it never overflows into the neighbouring type (overlapping its remove ✗) (#143). */ body.tc-ri-on #external-links-editor tr.relationship-item:has(.date-period){grid-column:span 2} body.tc-ri-on #external-links-editor tr.relationship-item:has(.date-period) .relationship-name{flex-wrap:wrap;white-space:nowrap;overflow:visible} /* let a very long date wrap to a 2nd line inside the cell (not clipped) */ /* per-type remove [x] sits in the reserved left slot (absolute), so the type text starts at the same x with or without it. left:6px centres MB's native ✗ sprite (≈13.6px) on the same x as the "add another relationship" [+] that wraps directly below it when the types stack — at left:0 the native sprite landed ~6px too far left of the [+] (the old #154 flat glyph was wider, so it happened to line up). (#160) */ body.tc-ri-on #external-links-editor tr.relationship-item td.link-actions > button[class*="remove"]{display:inline-flex;align-items:center;position:absolute;left:6px;top:50%;transform:translateY(-50%) scale(.85);margin:0;opacity:.6;transition:opacity .12s} body.tc-ri-on #external-links-editor tr.relationship-item td.link-actions > button[class*="remove"]:hover{opacity:1} /* type text (committed) / select (editable) fills the cell */ body.tc-ri-on #external-links-editor tr.relationship-item .relationship-name{display:flex;align-items:center;flex:1;min-width:0;font-size:12px;color:var(--mbu-accent-deep-text);background:transparent;border:none;border-radius:0;padding:0;font-weight:normal;cursor:context-menu;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} /* keep the type on one line — never wrap it one-word-per-line as the column resizes (#143) */ body.tc-ri-on #external-links-editor tr.relationship-item .relationship-name:hover{color:var(--mbu-accent-deep-text)} /* editable type dropdowns — appearance:none so the text starts flush at the cell edge; a custom caret keeps the affordance. padding-right reserves room for the 10px caret with a small gap; keep it tight (not 15px) so the type label has a few px of slack and never clips its last glyph under sub-pixel rounding at 100% browser zoom on a scaled display (#143). */ body.tc-ri-on #external-links-editor select{-webkit-appearance:none;-moz-appearance:none;appearance:none;font-size:12px;color:var(--mbu-accent-deep-text);background-color:transparent;background-image:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='10'%20height='7'%20viewBox='0%200%2010%207'%3E%3Cpath%20d='M1%201l4%204%204-4'%20fill='none'%20stroke='%235a3e94'%20stroke-width='1.5'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:right 5px center;border:1px solid var(--mbu-border-strong);border-radius:var(--mbu-radius);box-shadow:none;padding:1px 18px 1px 5px;height:auto;margin:0;flex:1;min-width:0;width:100%;cursor:pointer} /* #609: bordered, so an empty (no type yet) combo reads as a field to click, not a stray chevron */ body.tc-ri-on #external-links-editor select:hover{color:var(--mbu-accent-deep-text);border-color:var(--mbu-accent)} body.tc-ri-on #external-links-editor select:focus-visible{border-color:var(--mbu-accent);outline:none} /* video attribute → a compact glyph toggle (no "video" caption) */ body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container{grid-column:2;justify-self:start;display:inline-flex;align-items:center;margin:0} body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container label{font-size:0;display:inline-flex;align-items:center;cursor:pointer} /* error → a compact "!" badge (full text on hover via title) so it doesn't reflow the combos when it appears */ body.tc-ri-on #external-links-editor tr.relationship-item .error.field-error{grid-column:3;justify-self:start;font-size:0;display:inline-flex;align-items:center;justify-content:center;width:15px;height:15px;border-radius:50%;background:var(--mbu-error-bg);border:1px solid var(--mbu-error);margin:0;cursor:help} body.tc-ri-on #external-links-editor tr.relationship-item .error.field-error::before{content:"!";font:bold 11px/1 Arial;color:var(--mbu-error)} /* #169: MB flags a relationship/URL with pending edits via a small This relationship has open edits. that the compact type cell clips away. Rather than re-fit the icon (alignment is fragile here), surface it with COLOUR — an amber type label + a left accent bar on the row. Pure :has(), no layout shift (inset box-shadow). */ body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]){box-shadow:inset 2px 0 0 0 #e8920c} body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) .relationship-name, body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) select{color:var(--mbu-warn)} body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) .relationship-name:hover, body.tc-ri-on #external-links-editor tr.relationship-item:has(img.info[alt*="open edits" i]) select:hover{color:var(--mbu-warn)} /* same cue when the open edit is on the URL itself (pending add/remove of the link) */ body.tc-ri-on #external-links-editor tr.external-link-item:has(img.info[alt*="open edits" i]){box-shadow:inset 2px 0 0 0 #e8920c} body.tc-ri-on #external-links-editor tr.external-link-item:has(img.info[alt*="open edits" i]) a.url{color:var(--mbu-warn)} /* "Add another relationship" (the [+]) — flows into the last grid cell; padding-left matches the per-type [x] inset so they line up */ body.tc-ri-on #external-links-editor tr.add-relationship{display:flex;align-items:center;margin:0;padding:0 0 0 6px} body.tc-ri-on #external-links-editor tr.add-relationship > td{padding:0;border:none} body.tc-ri-on #external-links-editor tr.add-relationship > td:empty{display:none} body.tc-ri-on #external-links-editor tr.add-relationship td.add-item{display:inline-grid} /* size the cell to the [+] button */ /* the [+] is a touch smaller than the per-type [x] remove */ body.tc-ri-on #external-links-editor tr.add-relationship button.add-item{font-size:0;width:13px;height:13px;border-radius:50%;border:1px solid var(--mbu-accent);background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center;padding:0;margin:0;line-height:1} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item::before{content:"+";font:bold 9px/1 Arial;color:var(--mbu-accent-text)} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} body.tc-ri-on #external-links-editor tr.add-relationship button.add-item:hover::before{color:var(--mbu-accent-text)} /* the "add another link" input row */ body.tc-ri-on #external-links-editor tr.external-link-item .value.with-button input{width:100%} /* collapse the "Add another link" field into a [+] button that expands to a full input on click/focus */ /* #543: keyed on :placeholder-shown (= no value yet), NOT on the placeholder TEXT as it once was. Changing that text to "Paste one or more links" silently un-matched every rule below: the row stopped collapsing to [+] and drew over the Check-links button. */ body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown{box-sizing:border-box;width:22px;min-width:0;height:22px;padding:0;margin:2px 0;border:1px solid var(--mbu-accent);border-radius:50%;background-color:transparent;color:transparent;cursor:pointer;background-image:url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='11'%20height='11'%20viewBox='0%200%2011%2011'%3E%3Cpath%20d='M5.5%201v9M1%205.5h9'%20stroke='%239a8fc0'%20stroke-width='1.6'%20stroke-linecap='round'/%3E%3C/svg%3E");background-repeat:no-repeat;background-position:center;transition:width .12s ease} body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown:hover{background-color:var(--mbu-bg-hover);border-color:var(--mbu-accent)} body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown::placeholder{color:transparent} /* hide the hint text inside the collapsed [+] — it is shown on focus */ body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown:focus{width:100%;height:auto;padding:4px 7px;border:1px solid var(--mbu-border);border-radius:4px;background-color:var(--mbu-bg);background-image:none;color:var(--mbu-text);cursor:text} body.tc-ri-on #external-links-editor input[type=url]:placeholder-shown:focus::placeholder{color:var(--mbu-text-weak)} /* ---- dead-link checker ---- */ #tc-ri-toolbar{position:absolute;right:10px;bottom:8px;display:flex;align-items:center;gap:8px;z-index:3} /* the add-link field expands to full width on focus and would sit under the Check-links button — hide it while editing */ body.tc-ri-on #tc-ri-rightcol > fieldset:has(#external-links-editor input[type=url]:placeholder-shown:focus) > #tc-ri-toolbar{display:none} #tc-ri-check{font:12px Arial;display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);background:var(--mbu-bg-raised);color:var(--mbu-accent-deep-text);cursor:pointer} #tc-ri-check:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} #tc-ri-check:disabled{opacity:.6;cursor:default} /* #545 (majkinetor): "Auto match should be visible while working, currently it isn't so slow MB response make it look like nothing is happening." Same busy idiom as the link checker below — spinner in the button, the button disabled so a second run cannot be started on top of the first (the _autoMatching guard silently swallowed that click). */ #tc-recwrap .tc-rec-am .tc-spin{width:11px;height:11px;border:2px solid var(--mbu-accent-soft);border-top-color:var(--mbu-accent);border-radius:50%;animation:tc-spin .7s linear infinite;display:none;margin-right:5px;vertical-align:-1px} #tc-recwrap .tc-rec-am.busy .tc-spin{display:inline-block} #tc-recwrap .tc-rec-am[disabled]{opacity:.6;cursor:progress} #tc-recwrap .tc-rec-amstatus.busy{color:var(--mbu-accent-text);font-weight:600} #tc-ri-check .tc-spin{width:12px;height:12px;border:2px solid var(--mbu-accent-soft);border-top-color:var(--mbu-accent);border-radius:50%;animation:tc-spin .7s linear infinite;display:none} #tc-ri-check.busy .tc-spin{display:inline-block} @keyframes tc-spin{to{transform:rotate(360deg)}} #tc-ri-check-status{font:12px Arial;color:var(--mbu-text-dim)} /* a dead link (4xx/5xx/unreachable): faded favicon + struck URL, like Platform Check's not-found state */ body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead .favicon{filter:grayscale(1);opacity:.45} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead a.url{text-decoration:line-through;opacity:.55} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-dead a.url::after{content:" ✖ " attr(data-tc-deadcode);color:var(--mbu-error);font-size:11px;text-decoration:none;opacity:.9} body.tc-ri-on #external-links-editor tr.external-link-item.tc-link-ok a.url::after{content:" ✓";color:var(--mbu-ok);font-size:11px;opacity:.7} /* annotation editor: a bordered box wrapping the toolbar + (bigger) textarea + in-place preview */ body.tc-ri-on #tc-anno-wrap{border:1px solid var(--mbu-accent);border-radius:7px;background:var(--mbu-bg);overflow:hidden;box-sizing:border-box} #tc-anno-bar{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px 8px;background:var(--mbu-bg-raised);border-bottom:1px solid var(--mbu-accent)} #tc-anno-bar button{font:12px Arial;display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);background:var(--mbu-bg);color:var(--mbu-accent-deep-text);cursor:pointer} #tc-anno-bar button:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} #tc-anno-bar button:disabled{opacity:.6;cursor:default} #tc-anno-bar button.tc-anno-icon{padding:4px 8px;font-size:13px;line-height:1;font-weight:700} #tc-anno-bar .tc-mk-ico{width:22px;height:14px;display:block} #tc-anno-bar .tc-mk-mb{width:17px;height:17px;border-radius:3px} #tc-anno-bar .tc-mk-sq{width:15px;height:15px} /* maximize: the editor fills the viewport over a dimmed backdrop (Esc or the button restores it) */ body.tc-anno-max-open{overflow:hidden} body.tc-anno-max-open::before{content:'';position:fixed;inset:0;background:rgba(30,20,55,.42);z-index:10000} body.tc-ri-on #tc-anno-wrap.tc-anno-max{position:fixed;inset:14px;z-index:10001;display:flex;flex-direction:column;max-width:none;box-shadow:0 14px 50px rgba(35,20,70,.45)} #tc-anno-wrap.tc-anno-max #tc-anno-body{flex:1 1 auto;min-height:0} body.tc-ri-on #tc-anno-wrap.tc-anno-max textarea,#tc-anno-wrap.tc-anno-max #tc-anno-preview{min-height:0;height:auto} #tc-anno-wrap.tc-anno-max #tc-anno-history{flex:1 1 auto;min-height:0;max-height:none} #tc-anno-bar #tc-anno-help{width:25px;justify-content:center;color:var(--mbu-accent-text)} #tc-anno-bar.tc-anno-prev-on #tc-anno-preview-btn{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} #tc-anno-bar.tc-anno-hist-on #tc-anno-history-btn{background:var(--mbu-accent);color:var(--mbu-text-on-accent);border-color:var(--mbu-accent)} #tc-anno-bar.tc-anno-hist-on button:not(#tc-anno-history-btn):not(#tc-anno-max){opacity:.4;pointer-events:none} /* History active → only History + maximize stay usable */ #tc-anno-status{font:italic 11px var(--mbu-font);font-weight:normal;color:var(--mbu-accent-text);letter-spacing:0;text-transform:none} /* shown next to the Annotation: label */ /* three toolbar groups: [Preview Clear] [markup ?] [History] (the 1:3 spacers place markup/? left-of-centre) */ #tc-anno-bar .tc-anno-sp1{flex:1 1 0;min-width:14px} #tc-anno-bar .tc-anno-sp2{flex:3 1 0;min-width:14px} /* editor body: the active textarea on the left; the live preview splits in on the right when toggled */ #tc-anno-body{display:flex;align-items:stretch;min-height:240px} #tc-anno-edit{flex:1 1 0;min-width:0;display:flex;flex-direction:column} body.tc-ri-on #tc-anno-wrap textarea{display:block;width:100%!important;max-width:none!important;min-height:240px;flex:1 1 auto;border:none!important;border-radius:0;padding:9px 11px;resize:vertical;box-shadow:none;box-sizing:border-box;font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;font-size:12px;line-height:1.55;tab-size:4} /* standalone Edit annotation page: Changelog + Annotation rows stacked full-width with the label above */ body.tc-anno-page #content > form > .row:not(.no-label){display:block;margin:0 0 16px;max-width:1100px} body.tc-anno-page #content > form > .row:not(.no-label) > label{display:block;width:auto;text-align:left;float:none;font:600 12px Arial;letter-spacing:.02em;color:var(--mbu-text-dim);margin:0 0 5px} body.tc-anno-page #content > form > .row > input[type=text]{width:100%;max-width:680px;box-sizing:border-box;padding:5px 8px;border:1px solid var(--mbu-accent);border-radius:5px} body.tc-anno-page #content > form > fieldset.editnote{max-width:1100px} /* fill the remaining viewport height (height is set per-resize in JS) */ body.tc-anno-page #tc-anno-wrap{display:flex;flex-direction:column} body.tc-anno-page #tc-anno-wrap > #tc-anno-body,body.tc-anno-page #tc-anno-wrap > #tc-anno-history{flex:1 1 auto;min-height:0;max-height:none} body.tc-anno-page #tc-anno-wrap textarea,body.tc-anno-page #tc-anno-wrap #tc-anno-preview{min-height:0} /* Changelog row becomes [label above] then [input + Enter edit] side by side */ body.tc-anno-page #content > form > .row.tc-cl-row{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px} body.tc-anno-page #content > form > .row.tc-cl-row > label{flex:1 1 100%;margin-bottom:4px} body.tc-anno-page #content > form > .row.tc-cl-row > input[type=text]{flex:1 1 auto;width:auto;max-width:600px} body.tc-anno-page #tc-anno-submit{flex:0 0 auto;font:600 13px Arial;padding:7px 18px;border:1px solid var(--mbu-ok);border-radius:var(--mbu-radius);background:#3aa55f;color:var(--mbu-text-on-accent);cursor:pointer} body.tc-anno-page #tc-anno-submit:hover{background:#2c8a4d} #tc-anno-preview{flex:1 1 0;min-width:0;min-height:240px;padding:10px 13px;background:var(--mbu-bg);border-left:1px solid var(--mbu-accent);font-size:13px;line-height:1.5;color:var(--mbu-text);overflow:auto;word-break:break-word;box-sizing:border-box} #tc-anno-preview .tc-anno-empty{color:var(--mbu-text-weak);font-style:italic} #tc-anno-preview p{margin:0 0 8px} #tc-anno-preview .tc-anno-h{margin:10px 0 6px;color:var(--mbu-accent-deep-text);font-weight:700;line-height:1.25} #tc-anno-preview h1.tc-anno-h{font-size:18px} #tc-anno-preview h2.tc-anno-h{font-size:16px} #tc-anno-preview h3.tc-anno-h,#tc-anno-preview h4.tc-anno-h,#tc-anno-preview h5.tc-anno-h,#tc-anno-preview h6.tc-anno-h{font-size:14px} #tc-anno-preview .tc-anno-ul{margin:0 0 8px;padding-left:22px} #tc-anno-preview .tc-anno-pre{margin:0 0 8px;padding:8px 10px;background:var(--mbu-bg-hover);border-radius:4px;font-family:Consolas,monospace;font-size:12px;white-space:pre-wrap} #tc-anno-preview hr{border:none;border-top:1px solid var(--mbu-accent);margin:10px 0} #tc-anno-preview a{color:var(--mbu-accent-text);text-decoration:none} #tc-anno-preview a:hover{text-decoration:underline} /* syntax help popover (hover the ? button) */ #tc-anno-help-pop{position:fixed;z-index:10000;display:none;width:370px;max-width:calc(100vw - 16px);max-height:calc(100vh - 16px);overflow-y:auto;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:8px;box-shadow:0 8px 26px rgba(60,40,110,.22);padding:10px 13px;font:12px Arial;color:var(--mbu-text);line-height:1.5} #tc-anno-help-pop.on{display:block} #tc-anno-help-pop table{border-collapse:collapse;margin:6px 0;width:100%} #tc-anno-help-pop td{padding:2px 7px 2px 0;vertical-align:top} #tc-anno-help-pop td:last-child{color:var(--mbu-text-dim)} #tc-anno-help-pop code{background:var(--mbu-bg-hover);border-radius:3px;padding:0 4px;font-family:Consolas,monospace;color:var(--mbu-accent-deep-text)} #tc-anno-help-pop .tc-help-dim{color:var(--mbu-text-weak)} /* Disambiguation + Annotation span the full column with their label stacked ABOVE (not the 150px label column). :has targets exactly those two rows, so the relocated External-links table is untouched. */ body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment), body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation){display:block;width:100%;margin:0 0 14px} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td{display:block;width:100%!important;padding:0} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td:first-child, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td:first-child{text-align:left!important;padding:0 0 4px} body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #comment) > td:first-child label, body.tc-ri-on #information fieldset.information table.row-form > tbody > tr:has(> td #annotation) > td:first-child label{display:block;width:auto;float:none;text-align:left!important;font:600 12px Arial;letter-spacing:.02em;color:var(--mbu-text-dim)} body.tc-ri-on #information fieldset.information input#comment{width:100%!important;max-width:none!important;box-sizing:border-box} /* annotation History: the selected version rendered on the LEFT, user cards on the RIGHT */ #tc-anno-history{display:flex;min-height:240px;max-height:520px;overflow:hidden;box-sizing:border-box} #tc-anno-history .tc-hist-view{flex:1 1 auto;order:1;overflow:auto;padding:11px 14px;font-size:13px;line-height:1.5;color:var(--mbu-text);word-break:break-word} #tc-anno-history .tc-hist-list{flex:0 0 250px;order:2;overflow-y:auto;overflow-x:hidden;background:var(--mbu-bg-raised);border-left:1px solid var(--mbu-accent)} #tc-anno-history .tc-hist-card{display:flex;gap:9px;align-items:flex-start;width:100%;box-sizing:border-box;text-align:left;border:none;border-bottom:1px solid var(--mbu-accent);background:none;cursor:pointer;padding:9px 11px} #tc-anno-history .tc-hist-card:hover{background:var(--mbu-bg-hover)} #tc-anno-history .tc-hist-card.on{background:var(--mbu-bg-hover);box-shadow:inset -3px 0 0 #5f3ec0} #tc-anno-history .tc-hist-av{width:30px;height:30px;border-radius:50%;flex:0 0 auto;object-fit:cover;background:var(--mbu-bg-hover);border:1px solid var(--mbu-border)} #tc-anno-history .tc-hist-meta{display:flex;flex-direction:column;min-width:0;flex:1 1 auto;font:12px Arial} #tc-anno-history .tc-hist-editor{font-weight:600;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-hist-date{color:var(--mbu-text-dim);font-size:11px} #tc-anno-history .tc-hist-cl{color:var(--mbu-text-weak);font-style:italic;font-size:11px;margin-top:2px} #tc-anno-history .tc-hist-cur{color:var(--mbu-ok);font-style:normal} #tc-anno-history .tc-hist-clmsg{color:var(--mbu-accent-deep-text);font-style:italic;font-size:11px;margin-top:2px;max-width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} #tc-anno-history .tc-hist-revert{align-self:center;flex:0 0 auto;width:26px;height:26px;border:1px solid var(--mbu-accent);border-radius:5px;background:var(--mbu-bg);color:var(--mbu-accent-deep-text);font-size:15px;line-height:1;cursor:pointer;opacity:0;transition:opacity .12s} #tc-anno-history .tc-hist-card.on .tc-hist-revert,#tc-anno-history .tc-hist-card:hover .tc-hist-revert{opacity:1} /* visible on the selected card, or any card on hover */ #tc-anno-history .tc-hist-revert:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)} #tc-anno-history .tc-hist-revert:disabled{opacity:.5;cursor:default} #tc-anno-history .tc-hist-msg{color:var(--mbu-text-weak);font-style:italic;font-size:12px;padding:6px 2px} #tc-anno-history .tc-hist-bar{display:flex;align-items:center;gap:10px;margin:0 0 10px;flex-wrap:wrap} #tc-anno-history .tc-hist-use{font:12px Arial;padding:4px 11px;border:1px solid var(--mbu-accent);border-radius:var(--mbu-radius);background:var(--mbu-bg-raised);color:var(--mbu-accent-deep-text);cursor:pointer} #tc-anno-history .tc-hist-use:hover{background:var(--mbu-bg-hover)} #tc-anno-history .tc-hist-vmeta{font:11px Arial;color:var(--mbu-text-weak)} #tc-anno-history .tc-anno-rendered h1{font-size:18px;margin:8px 0 6px;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-anno-rendered h2{font-size:16px;margin:8px 0 6px;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-anno-rendered h3{font-size:14px;margin:8px 0 6px;color:var(--mbu-accent-deep-text)} #tc-anno-history .tc-anno-rendered p{margin:0 0 8px} #tc-anno-history .tc-anno-rendered ul{margin:0 0 8px;padding-left:22px} #tc-anno-history .tc-anno-rendered a{color:var(--mbu-accent-text);text-decoration:none} #tc-anno-history .tc-anno-rendered a:hover{text-decoration:underline} #tc-anno-history .tc-anno-rendered .annotation-details,#tc-anno-history .tc-anno-rendered h2.annotation{display:none}`; const s = document.createElement('style'); s.id = 'tc-ri-style'; s.textContent = css; document.head.appendChild(s); } // move the External-links fieldset into a dedicated right column (or back home when Apollo is off). // Only the server-rendered
wrapper is moved — React's editor root inside it is untouched. function relocateLinks(on) { const panel = document.getElementById('information'); if (!panel) return; const half = panel.querySelector(':scope > div.half-width'); if (!half) return; if (!_riExtFs || !_riExtFs.isConnected) { // find the External-links fieldset by its editor table, or — while that's still loading — by its legend, // so the section moves to the right column immediately instead of showing "Loading…" at the bottom const ext = document.getElementById('external-links-editor'); _riExtFs = ext ? ext.closest('fieldset') : [...half.querySelectorAll('fieldset')].find(f => /external links/i.test(f.querySelector('legend')?.textContent || '')); } const fs = _riExtFs; if (!fs) return; if (on) { let col = panel.querySelector(':scope > #tc-ri-rightcol'); if (!col) { col = document.createElement('div'); col.id = 'tc-ri-rightcol'; panel.appendChild(col); } if (fs.parentElement !== col) { if (!fs._tcHome) fs._tcHome = { parent: fs.parentElement, next: fs.nextElementSibling }; col.appendChild(fs); } ensureCheckToolbar(col); riCover(col); // #297: front cover thumbnail below the external-links section } else if (fs._tcHome && fs.parentElement !== fs._tcHome.parent) { // the Check-links toolbar is appended *inside* the fieldset, so it would travel home with it and orphan // onto the native Release-information tab (#160) — drop it before moving the fieldset back fs.querySelector(':scope > #tc-ri-toolbar')?.remove(); fs._tcHome.parent.insertBefore(fs, fs._tcHome.next && fs._tcHome.next.isConnected ? fs._tcHome.next : null); const col = panel.querySelector(':scope > #tc-ri-rightcol'); col?.querySelector(':scope > #tc-ri-cover')?.remove(); if (col && !col.children.length) col.remove(); } } // #297: the current release MBID (live editor gid, else from the URL); '' on /release/add function currentMbid() { try { const r = u(getEditor().rootField.release); const g = r && u(r.gid); if (g) return g; } catch (e) {} return (location.pathname.match(MBID_RE) || [''])[0]; } // Append/refresh a front-cover thumbnail (Cover Art Archive) at the bottom of the // right column, below external links. Display only; hides itself when there's no art. function riCover(col) { const mbid = currentMbid(); let box = col.querySelector(':scope > #tc-ri-cover'); if (!mbid) { box?.remove(); return; } if (box && box.dataset.mbid === mbid) { col.appendChild(box); return; } // keep, just ensure it's last box?.remove(); box = document.createElement('div'); box.id = 'tc-ri-cover'; box.dataset.mbid = mbid; box.style.display = 'none'; // hidden until the cover actually loads — no "FRONT COVER" flash on coverless releases box.innerHTML = '
Front cover
'; const a = document.createElement('a'); a.href = ORIGIN + '/release/' + mbid + '/cover-art'; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Cover art (Cover Art Archive)'; const img = document.createElement('img'); img.alt = 'Front cover'; img.referrerPolicy = 'no-referrer'; // not lazy: a hidden (display:none) box would never load a lazy image // reveal only once the image loads. On error the box stays hidden — but is KEPT // (marked for this mbid) so riCover doesn't rebuild + re-request it every relayout // tick (#297: front-250 request loop / OpaqueResponseBlocking on coverless releases). img.onload = () => { box.style.display = ''; }; img.src = 'https://coverartarchive.org/release/' + mbid + '/front-250'; a.appendChild(img); box.appendChild(a); col.appendChild(box); } // ---- dead-link checker (#138): check each external link's HTTP status, fade the dead ones, and turn on // "This relationship has ended" for each of a dead link's relationship types ---- const _deadLinks = new Map(); // url -> { dead, code } — kept so marks survive React re-renders of the editor const GMX = (typeof GM_xmlhttpRequest !== 'undefined' && GM_xmlhttpRequest) || (typeof GM !== 'undefined' && GM && GM.xmlHttpRequest) || null; // HEAD (then GET on 405/403/0) → { status, dead }. dead = 4xx/5xx or unreachable. function checkUrl(url) { return new Promise(resolve => { if (!GMX) { resolve({ status: null, dead: null }); return; } // no GM (e.g. page-context) → unknown let done = false; const fin = r => { if (!done) { done = true; resolve(r); } }; const req = method => { try { GMX({ method, url, timeout: 15000, onload: r => { const s = r.status; if (method === 'HEAD' && (s === 405 || s === 501 || s === 403 || s === 0)) return req('GET'); fin({ status: s, dead: !s || s >= 400 }); }, onerror: () => method === 'HEAD' ? req('GET') : fin({ status: 0, dead: true }), ontimeout: () => fin({ status: 0, dead: true }) }); } catch (e) { fin({ status: -1, dead: true }); } }; req('HEAD'); }); } // each external-link row with a real URL, paired with its and the relationship-item rows beneath it function linkRows() { const ext = document.getElementById('external-links-editor'); if (!ext) return []; const rows = [...ext.querySelectorAll('tr')]; const out = []; rows.forEach((r, i) => { if (!r.classList.contains('external-link-item')) return; const a = r.querySelector('a.url'); if (!a) return; // skip the "add another link" input row const rels = []; for (let k = i + 1; k < rows.length; k++) { const n = rows[k]; if (n.classList.contains('external-link-item')) break; if (n.classList.contains('relationship-item')) rels.push(n); } out.push({ row: r, url: a.href, rels }); }); return out; } function markLinkRow(row, dead, code) { row.classList.toggle('tc-link-dead', dead === true); row.classList.toggle('tc-link-ok', dead === false); const a = row.querySelector('a.url'); if (a) { if (dead && code) a.setAttribute('data-tc-deadcode', code); else a.removeAttribute('data-tc-deadcode'); } } function remarkDeadLinks() { // re-apply marks after the React editor re-renders (called from the observer) if (!_deadLinks.size) return; linkRows().forEach(({ row, url }) => { const v = _deadLinks.get(url); if (v) markLinkRow(row, v.dead, v.code); }); } // open a relationship's edit dialog, tick "This relationship has ended", click Done function setRelEnded(relRow) { return new Promise(resolve => { const edit = relRow.querySelector('button.edit-item'); if (!edit) { resolve(false); return; } edit.click(); setTimeout(() => { const dlg = [...document.querySelectorAll('.dialog.popover,[role="dialog"],.bubble')].find(d => d.offsetParent !== null && /relationship has ended|has ended/i.test(d.textContent)); if (!dlg) { resolve(false); return; } const cb = [...dlg.querySelectorAll('input[type=checkbox]')].find(c => /ended/i.test((c.closest('label') || c.parentElement || {}).textContent || '')); if (cb && !cb.checked) cb.click(); const done = [...dlg.querySelectorAll('button')].find(b => /^\s*done\s*$/i.test(b.textContent)); setTimeout(() => { if (done) done.click(); resolve(!!cb); }, 70); }, 240); }); } /* -- #543 paste several external links at once ---------------------------- majkinetor: "When multiple URLs are pasted, in (+) line, Apollo should add them all at once ... Find out only links, no matter where they are, so they could be intermingled with text", e.g. Bandcamp: https://digthiswayrecords.bandcamp.com/album/musical-breed Spotify: https://open.spotify.com/album/3ibDwnUIydFebHj3pNW9WR or `Available on [Bandcamp](url) & [Spotify](url)`. So the paste is MINED for urls rather than parsed as a list: anything that is not a url is ignored, which covers both shapes without a format to learn. (ROpdebee's mb_multi_external_links does this today, but it stopped matching MB's markup and looks unmaintained -- he had to patch its selector by hand.) */ const AL_URL_RE = /\bhttps?:\/\/[^\s<>"'`\)\]}]+/gi; function alExtractUrls(text) { const out = []; const seen = new Set(); for (const raw of String(text || '').match(AL_URL_RE) || []) { const url = raw.replace(/[.,;:!?]+$/, ''); let ok = false; try { const u = new URL(url); ok = !!u.hostname && u.hostname.includes('.'); } catch (e) { ok = false; } if (!ok) continue; const key = url.toLowerCase(); if (seen.has(key)) continue; // the same link twice in one paste is one link seen.add(key); out.push(url); } return out; } /* MB leaves the link type blank for hosts its classifier cannot decide, and a blank REQUIRED select blocks the whole release editor's submit -- not just that row. Platform Check already solved this; the table is duplicated here rather than shared, the same way the pattern engine is (single-file userscripts). KEEP IN SYNC with platform_check.user.js's TYPE_FORCE. Order matters: the first id MB actually offers for that row wins, because MB only lists types applicable to the host. */ const AL_TYPE_FORCE = [ { test: u => /music\.apple\.com\/.*\/album\//i.test(u), ids: ['980', '85'] }, { test: u => /[a-z0-9-]+\.bandcamp\.com\/album\//i.test(u), ids: ['85', '980'] }, { test: u => /hdtracks\.com\//i.test(u), ids: ['74'] }, { test: u => /volumo\.com\/album\//i.test(u), ids: ['74'] }, { test: u => /qobuz\.com\/(?:[a-z]{2}-[a-z]{2}\/)?album\//i.test(u), ids: ['74', '980'] }, { test: u => /^https?:\/\/(?:www\.|m\.)?soundcloud\.com\//i.test(u), ids: ['85', '980'] }, { test: u => /^https?:\/\/(?:www\.)?audiomack\.com\//i.test(u), ids: ['85', '980'] }, ]; const alWaitFor = (fn, ms) => new Promise(resolve => { const t0 = Date.now(); const tick = () => { let v = null; try { v = fn(); } catch (e) {} if (v) return resolve(v); if (Date.now() - t0 > (ms || 3000)) return resolve(null); setTimeout(tick, 60); }; tick(); }); const alAddInput = () => [...document.querySelectorAll('#external-links-editor input[type=url]')].find(i => !i.value); // Fill ONE url into the editor's empty (+) input and wait for MB to accept it // (it renders the row and hands us a fresh empty input for the next one). async function alAddUrl(url) { const input = alAddInput(); if (!input) return { url, ok: false, why: 'no empty link input' }; const setVal = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value').set; setVal.call(input, url); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); // The row keeps its url in the while the editor is open — an // only appears once MB renders it as a committed link — so don't wait for // one (that burned 4s per url waiting for something that never comes). const row = input.closest('tr'); const force = AL_TYPE_FORCE.find(f => f.test(url)); let typed = null; if (force && row) { const select = await alWaitFor(() => { const sib = row.nextElementSibling; return (sib && sib.classList.contains('relationship-item')) ? sib.querySelector('select.link-type') : null; }, 3000); // only fill a select MB left empty -- never override a type it did resolve if (select && !select.value) { const opt = force.ids.map(id => [...select.options].find(o => o.value === id)).find(Boolean); if (opt) { const setSel = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, 'value').set; setSel.call(select, opt.value); select.dispatchEvent(new Event('change', { bubbles: true })); typed = opt.textContent.trim(); } } } // MB gives us a NEW empty input once the url is accepted; if it never does, // the url was rejected (duplicate/invalid) and the next one would overwrite it. const fresh = await alWaitFor(() => { const n = alAddInput(); return (n && n !== input) ? n : null; }, 4000); return { url, ok: !!fresh, typed, why: fresh ? '' : 'MusicBrainz did not accept it (duplicate or unsupported?)' }; } let _alBusy = false; async function alAddUrls(urls) { if (_alBusy) return []; _alBusy = true; const results = []; try { for (const u of urls) results.push(await alAddUrl(u)); } finally { _alBusy = false; } const ok = results.filter(r => r.ok).length; const typed = results.filter(r => r.typed).length; Log.info('multi-link paste: added ' + ok + '/' + results.length + ' url(s)' + (typed ? ', set ' + typed + ' link type(s) MusicBrainz left blank' : '')); results.filter(r => !r.ok).forEach(r => Log.warn('multi-link paste: ' + r.url + ' -- ' + r.why)); return results; } /* majkinetor: "Make a hint show that it can be multiple links." MB's own placeholder is "Add link", which gives no reason to try pasting a block — the feature is invisible until someone happens to attempt it. React rewrites the input on re-render, so the hint is re-applied by the same observer that watches the editor rather than set once. */ const AL_HINT = 'Paste one or more links'; function alApplyHint() { document.querySelectorAll('#external-links-editor input[type=url]').forEach(i => { if (!i.value && i.placeholder !== AL_HINT) i.placeholder = AL_HINT; }); } let _alHintObs = null; function installLinkHint() { if (_alHintObs) return; const ed = document.getElementById('external-links-editor'); if (!ed) return; // retried by the caller's own observer tick _alHintObs = new MutationObserver(alApplyHint); _alHintObs.observe(ed, { childList: true, subtree: true }); alApplyHint(); } // One delegated listener: the editor re-renders its rows constantly, so // binding to the input itself would not survive. let _alPasteHooked = false; function installMultiLinkPaste() { if (_alPasteHooked) return; _alPasteHooked = true; // the editor mounts a moment after the release editor is ready installLinkHint(); if (!_alHintObs) { const t = setInterval(() => { installLinkHint(); if (_alHintObs) clearInterval(t); }, 300); setTimeout(() => clearInterval(t), 15000); } document.addEventListener('paste', ev => { const input = ev.target; if (!input || input.tagName !== 'INPUT' || input.type !== 'url') return; if (!input.closest('#external-links-editor')) return; const cd = ev.clipboardData || window.clipboardData; const text = cd && cd.getData ? cd.getData('text') : ''; if (!text) return; const urls = alExtractUrls(text); // Exactly one url and nothing else pasted: that is MB's own case, so leave // it alone -- no reason to take over ordinary single-link pasting. if (urls.length === 1 && urls[0] === text.trim()) return; if (!urls.length) return; ev.preventDefault(); alAddUrls(urls); }, true); } let _checking = false; async function checkAllLinks(setEnded) { if (_checking) return; _checking = true; const btn = document.getElementById('tc-ri-check'), stat = document.getElementById('tc-ri-check-status'); const links = linkRows(); if (btn) { btn.classList.add('busy'); btn.disabled = true; } if (stat) stat.textContent = 'checking ' + links.length + ' link(s)…'; let dead = 0, done = 0; const queue = links.slice(); const worker = async () => { while (queue.length) { const L = queue.shift(); const r = await checkUrl(L.url); _deadLinks.set(L.url, { dead: r.dead, code: r.status }); markLinkRow(L.row, r.dead, r.status); if (r.dead && setEnded) { for (const rel of L.rels) await setRelEnded(rel); } if (r.dead) dead++; done++; if (stat) stat.textContent = 'checked ' + done + '/' + links.length + (dead ? ' · ' + dead + ' dead' : ''); } }; await Promise.all([worker(), worker(), worker()]); if (btn) { btn.classList.remove('busy'); btn.disabled = false; } if (stat) stat.textContent = links.length ? (dead ? dead + ' dead link(s)' + (setEnded ? ' — marked “ended”' : '') : 'all ' + links.length + ' OK') : 'no links'; _checking = false; } // the "Check links" button + status, pinned at the bottom-right of the External-links box (across from the // add-link [+]). Hidden when the release has no links yet. Lives in the fieldset wrapper, not the React editor. function ensureCheckToolbar(col) { const fs = _riExtFs && _riExtFs.isConnected ? _riExtFs : (col && col.querySelector('fieldset')); if (!fs) return; let bar = fs.querySelector(':scope > #tc-ri-toolbar'); if (!bar) { fs.style.position = fs.style.position || 'relative'; bar = document.createElement('div'); bar.id = 'tc-ri-toolbar'; bar.innerHTML = ''; bar.querySelector('#tc-ri-check').onclick = () => checkAllLinks(true); fs.appendChild(bar); } bar.style.display = linkRows().length ? '' : 'none'; // no links → no button } // ── Annotation editor: a small toolbar above the release annotation textarea, with a live // Preview (MB markup → HTML), Clear, and — inspired by kellnerd's annotationConverter — a // Markdown→MB converter and a WS2 "resolve names" action that labels bare MB entity URLs. ── const ANNO_NAME_FIELD = { artist:'name', label:'name', area:'name', place:'name', instrument:'name', series:'name', event:'name', genre:'name', 'release-group':'title', release:'title', recording:'title', work:'title' }; // release-group MUST precede release in the alternation (else "release" matches the prefix of "release-group/…") const ANNO_ENTITY_RE = /https?:\/\/(?:beta\.)?musicbrainz\.org\/(artist|label|area|place|instrument|series|event|genre|release-group|release|recording|work)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})/i; const _annoName = new Map(); // entity url → resolved display name (shared by Resolve-names + Preview) const _annoEsc = s => s.replace(/&/g, '&').replace(//g, '>'); // build nested
    /
      from a flat list of {level>=1, html, ordered} (MB nests by 4-space indentation; // "a." marks an auto-numbered list, "*" a bullet list) function bulletsToHtml(items) { let out = '', depth = 0, openLi = false; const stack = []; const open = ord => { const t = ord ? 'ol' : 'ul', c = ord ? 'tc-anno-ol' : 'tc-anno-ul'; out += `<${t} class="${c}">`; stack.push(t); }; const close = () => { out += ``; }; for (const it of items) { const lvl = Math.max(1, it.level); if (lvl > depth) { while (depth < lvl) { open(it.ordered); depth++; openLi = false; } } else { if (openLi) { out += ''; openLi = false; } while (depth > lvl) { close(); depth--; if (depth > 0) out += ''; } } out += '
    1. ' + it.html; openLi = true; } if (openLi) out += '
    2. '; while (depth > 0) { close(); depth--; if (depth > 0) out += ''; } return out; } // MB annotation markup → HTML, replicating the documented subset (musicbrainz.org/doc/Annotation): // ''italic'' '''bold''', = h1 = .. === h3 ===, [url] / [url|text] / bare-url links, ---- rule, // (4n)-space "*" nested bullets, 8-space code, [/] literal brackets. Pure + sync (no network). function annoToHtml(src) { if (!src || !src.trim()) return '(nothing to preview)'; src = String(src).replace(/[/g, '\x01').replace(/]/g, '\x02'); // protect literal brackets const inline = txt => { let s = _annoEsc(txt); const links = []; // pull links out first so '' / ''' never touch a URL const stash = html => '\x03' + (links.push(html) - 1) + '\x04'; // only http(s)/ftp are linkified (as MB does) — anything else (e.g. javascript:) renders as plain text const anchor = (url, label) => { url = url.trim(); if (!/^(?:https?|ftp):\/\//i.test(url)) return null; const name = _annoName.get(url); return stash(`
      ${label != null ? label : (name ? _annoEsc(name) : _annoEsc(url))}`); }; s = s.replace(/\[([^\]|]+)\|([^\]]*)\]/g, (_m, url, text) => anchor(url, text ? _annoEsc(text) : null) ?? _annoEsc(_m)); s = s.replace(/\[([^\]|]+)\]/g, (_m, url) => anchor(url, null) ?? _annoEsc(_m)); s = s.replace(/(^|[\s(])((?:https?|ftp):\/\/[^\s<>]+[^\s<>.,;:!?)])/g, (_m, pre, url) => pre + (anchor(url, null) ?? _annoEsc(url))); s = s.replace(/'''''(.+?)'''''/g, '$1').replace(/'''(.+?)'''/g, '$1').replace(/''(.+?)''/g, '$1'); s = s.replace(/\x03(\d+)\x04/g, (_m, i) => links[+i]); // restore links return s; }; const lines = src.split(/\r?\n/), out = []; let i = 0; while (i < lines.length) { const ln = lines[i]; let m; if (/^\s*$/.test(ln)) { i++; continue; } if (/^-{4,}\s*$/.test(ln)) { out.push('
      '); i++; continue; } if ((m = ln.match(/^(={1,6})\s*(.*?)\s*=*\s*$/)) && m[2]) { const n = Math.min(m[1].length, 6); out.push(`${inline(m[2])}`); i++; continue; } // lists BEFORE code: a "(4n)-space * " line is a level-n bullet, "(4n)-space a. " an auto-numbered item // (MB nests by indentation); only 8-space lines that are NOT a list item are code. if (/^ {4,}(?:\*|[a-z]\.)[ \t]+/i.test(ln)) { const items = []; let bm; while (i < lines.length && (bm = lines[i].match(/^( +)(\*|[a-z]\.)[ \t]+(.*)$/i))) { items.push({ level: Math.max(1, Math.floor(bm[1].length / 4)), ordered: bm[2] !== '*', html: inline(bm[3]) }); i++; } out.push(bulletsToHtml(items)); continue; } if (/^ {8}/.test(ln)) { const buf = []; while (i < lines.length && /^ {8}/.test(lines[i]) && !/^ {4,}(?:\*|[a-z]\.)[ \t]/i.test(lines[i])) { buf.push(_annoEsc(lines[i].slice(8))); i++; } out.push('
      ' + buf.join('\n') + '
      '); continue; } const buf = []; // a paragraph: consume the CURRENT line first (do-while → i ALWAYS advances, so an do { buf.push(inline(lines[i])); i++; } // empty-title "= =" heading can't spin forever), while (i < lines.length && !/^\s*$/.test(lines[i]) && !/^-{4,}\s*$/.test(lines[i]) && !/^={1,6}\s/.test(lines[i]) && !/^ {4,}(?:\*|[a-z]\.)[ \t]+/i.test(lines[i]) && !/^ {8}/.test(lines[i])); // then following non-blank, non-block lines out.push('

      ' + buf.join('
      ') + '

      '); } return out.join('').replace(/\x01/g, '[').replace(/\x02/g, ']'); } // Markdown → MB annotation markup (kellnerd-inspired). Pure + sync. function mdToAnno(src) { if (!src) return src; const urls = [], blocks = []; // protect URLs (* / _) and code blocks from the inline passes const stashU = u => '\x05' + (urls.push(u) - 1) + '\x06'; const stashB = b => '\x07' + (blocks.push(b) - 1) + '\x08'; src = src.replace(/^```[^\n]*\n([\s\S]*?)\n```[ \t]*$/gm, (_m, code) => stashB(code.split('\n').map(l => ' ' + l).join('\n'))); // ```fenced``` → MB 8-space block src = src.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_m, text, url) => `[${stashU(url)}|${text}]`); // [text](url) → [url|text] src = src.replace(/(^|[\s(])((?:https?|ftp):\/\/[^\s<>]+)/g, (_m, pre, url) => pre + stashU(url)); // bare urls src = src.replace(/\[([^\[\]]*)\]/g, (m, inner) => inner.includes('\x05') ? m : '[' + inner + ']'); // a non-link [x] → encoded brackets, so MB doesn't read it as a (broken) link src = src.replace(/^(#{1,6})[ \t]+(.*?)[ \t]*#*[ \t]*$/gm, (_m, h, t) => { const n = Math.min(h.length, 3); const e = '='.repeat(n); return `${e} ${t} ${e}`; }); src = src.replace(/(\*\*|__)(.+?)\1/g, "'''$2'''"); // **bold** / __bold__ src = src.replace(/(? { const sp = ind.replace(/\t/g, ' ').length; return ' '.repeat((Math.floor(sp / 2) + 1) * 4) + 'a. '; }); // markdown numbered list → MB "a." (MB auto-numbers a., not 1.) src = src.replace(/^([ \t]*)[-*+][ \t]+/gm, (_m, ind) => { const sp = ind.replace(/\t/g, ' ').length; return ' '.repeat((Math.floor(sp / 2) + 1) * 4) + '* '; }); // markdown bullet (2-space-per-level indent) → MB (4n)-space bullet src = src.replace(/\x07(\d+)\x08/g, (_m, i) => blocks[+i]); return src.replace(/\x05(\d+)\x06/g, (_m, i) => urls[+i]); } // MB annotation markup → Markdown (reverse of mdToAnno; powers the Markdown toggle's "back" direction) function annoToMd(src) { if (!src) return src; const blocks = []; // pull MB code blocks out first so '''/'' inside them aren't touched const stashB = b => '\x07' + (blocks.push(b) - 1) + '\x08'; src = src.replace(/(?:^ {8}(?! *(?:\*|[a-z]\.)[ \t]).*(?:\n|$))+/gim, m => { const trail = m.endsWith('\n') ? '\n' : ''; const code = m.replace(/\n$/, '').split('\n').map(l => l.slice(8)).join('\n'); return stashB('```\n' + code + '\n```') + trail; }); // MB 8-space block (not a nested list item) → ```fenced``` src = src.replace(/\[([^\]|]+)\|([^\]]*)\]/g, (_m, url, text) => text ? `[${text}](${url})` : url); // [url|text] → [text](url); [url|] (empty label) → bare url src = src.replace(/\[((?:https?|ftp):\/\/[^\]|]+)\]/g, (_m, url) => url); // [url] → bare url src = src.replace(/'''''(.+?)'''''/g, '***$1***').replace(/'''(.+?)'''/g, '**$1**').replace(/''(.+?)''/g, '*$1*'); src = src.replace(/^(={1,6})[ \t]*(.*?)[ \t]*=*[ \t]*$/gm, (_m, e, t) => '#'.repeat(e.length) + ' ' + t); // = H = → # H src = src.replace(/^( {4,})[a-z]\.[ \t]+/gim, (_m, ind) => ' '.repeat(Math.max(0, Math.floor(ind.length / 4) - 1)) + '1. '); // MB "a." auto-numbered → markdown "1." numbered list src = src.replace(/^( {4,})\*[ \t]+/gm, (_m, ind) => ' '.repeat(Math.max(0, Math.floor(ind.length / 4) - 1)) + '- '); // MB (4n)-space bullet → markdown (2-space-per-level) bullet src = src.replace(/^-{4,}[ \t]*$/gm, '---'); // ---- → --- src = src.replace(/[/g, '[').replace(/]/g, ']'); // decode literal brackets back to plain [ ] (literal in Markdown) return src.replace(/\x07(\d+)\x08/g, (_m, i) => blocks[+i]); } function annoReplaceAsync(str, re, fn) { // async String.replace (kellnerd's replaceAsync) const parts = []; let last = 0, m; re.lastIndex = 0; while ((m = re.exec(str)) !== null) { parts.push(str.slice(last, m.index), fn(...m, m.index, str)); last = m.index + m[0].length; if (!re.global) break; } parts.push(str.slice(last)); return Promise.all(parts).then(p => p.join('')); } async function annoLookupName(type, mbid, full) { // WS2 entity name, cached type = type.toLowerCase(); if (_annoName.has(full)) return _annoName.get(full); try { const res = await wsJson(`${location.origin}/ws/2/${type}/${mbid}?fmt=json`, { label: 'annotation entity name' }); const j = res.json; if (!j) return null; const name = j[ANNO_NAME_FIELD[type] || 'name']; if (name) { _annoName.set(full, name); return name; } return null; } catch { return null; } } // is this URL an MB entity URL? (tolerates a trailing path like /release//annotations) → {type,mbid} function annoEntity(url) { const m = ANNO_ENTITY_RE.exec(url); ANNO_ENTITY_RE.lastIndex = 0; return m ? { type: m[1].toLowerCase(), mbid: m[2] } : null; } // add the entity name to every MB entity link that doesn't already have one — handles MB [url] / [url|] // and Markdown []()/bare URLs, in either editing mode. Links that already carry a label are left alone. // Captures the URL first, THEN tests it for an entity, so trailing path segments don't break the match. async function annoResolveNames(src, md) { // md=true → emit Markdown links [Name](url); else MB links [url|Name] const lbl = async (url) => { const e = annoEntity(url); return e ? await annoLookupName(e.type, e.mbid, url) : null; }; if (!md) src = await annoReplaceAsync(src, /\[([^\]|]+)\|?\]/g, async (m, url) => { url = url.trim(); const n = await lbl(url); return n ? `[${url}|${n}]` : m; }); // MB [url] / [url|] src = await annoReplaceAsync(src, /\[\]\(([^)\s]+)\)/g, async (m, url) => { const n = await lbl(url); return n ? `[${n}](${url})` : m; }); // Markdown [](url) // a bare URL (not already inside a [..] or (..) link) → named, in the active markup src = await annoReplaceAsync(src, /(?\]]+)/g, async (m, url) => { const n = await lbl(url); return n ? (md ? `[${n}](${url})` : `[${url}|${n}]`) : m; }); return src; } // annotation History: parse the /annotations page into a version list, and pull a single version's // rendered annotation HTML from its "View this version" page (musicbrainz.org, same-origin fetch). async function annoFetchHistory(mbid) { const r = await fetch(`${location.origin}/release/${mbid}/annotations`, { credentials: 'same-origin' }); if (!r.ok) throw new Error('history ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'text/html'); const out = []; doc.querySelectorAll('table tr').forEach(tr => { const view = [...tr.querySelectorAll('a')].find(a => /this version/i.test(a.textContent || '')); if (!view) return; const ua = tr.querySelector('a[href^="/user/"]'); const editor = ua?.textContent.trim() || ''; const avatar = ua?.querySelector('img')?.getAttribute('src') || ''; const date = [...tr.querySelectorAll('td')].map(c => c.textContent.trim()).find(t => /\d{4}-\d{2}-\d{2}/.test(t)) || ''; const cl = (view.parentElement.textContent.match(/\(([^)]*)\)/) || ['', ''])[1]; out.push({ editor, avatar, date, changelog: /no changelog/i.test(cl) ? '' : cl, url: view.getAttribute('href') }); }); return out; } async function annoFetchVersion(url) { const r = await fetch(new URL(url, location.origin).href, { credentials: 'same-origin' }); if (!r.ok) throw new Error('version ' + r.status); const doc = new DOMParser().parseFromString(await r.text(), 'text/html'); const body = doc.querySelector('.annotation-body'); return body ? body.innerHTML : '(this version is empty)'; } // reconstruct MB markup from a rendered annotation's HTML (MB exposes no raw text per revision) — used to // load a past version back into the editor. Lossy on exotic markup, faithful for the common elements. function annoHtmlToMb(html) { if (/this annotation is empty/i.test(html)) return ''; const root = new DOMParser().parseFromString('
      ' + html + '
      ', 'text/html').body.firstChild; const inline = node => { let s = ''; node.childNodes.forEach(n => { if (n.nodeType === 3) s += n.textContent; else if (n.nodeType === 1) { const t = n.tagName.toLowerCase(), inner = inline(n); if (t === 'strong' || t === 'b') s += "'''" + inner + "'''"; else if (t === 'em' || t === 'i') s += "''" + inner + "''"; else if (t === 'a') { const href = n.getAttribute('href') || ''; s += href ? `[${/^https?:\/\//i.test(href) ? href : location.origin + href}|${inner}]` : inner; } else if (t === 'br') s += '\n'; else s += inner; } }); return s; }; const lines = []; const listWalk = (listNode, ordered, level) => { [...listNode.children].forEach(li => { if (li.tagName.toLowerCase() !== 'li') return; const clone = li.cloneNode(true); clone.querySelectorAll(':scope > ul, :scope > ol').forEach(s => s.remove()); lines.push(' '.repeat(level * 4) + (ordered ? 'a. ' : '* ') + inline(clone).trim()); [...li.children].forEach(c => { const ct = c.tagName.toLowerCase(); if (ct === 'ul' || ct === 'ol') listWalk(c, ct === 'ol', level + 1); }); }); }; const walk = node => node.childNodes.forEach(n => { if (n.nodeType === 3) { if (n.textContent.trim()) lines.push(n.textContent.trim(), ''); return; } if (n.nodeType !== 1) return; const t = n.tagName.toLowerCase(); if (/^h[1-6]$/.test(t)) { const e = '='.repeat(Math.min(+t[1], 3)); lines.push(`${e} ${inline(n).trim()} ${e}`, ''); } else if (t === 'p') lines.push(inline(n).replace(/\n+$/, '').trim(), ''); else if (t === 'ul' || t === 'ol') { listWalk(n, t === 'ol', 1); lines.push(''); } else if (t === 'pre') { inline(n).replace(/\n$/, '').split('\n').forEach(l => lines.push(' ' + l)); lines.push(''); } else if (t === 'hr') lines.push('----', ''); else if (t === 'div' || t === 'blockquote') walk(n); else { const x = inline(n).trim(); if (x) lines.push(x, ''); } }); walk(root); return lines.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, ''); } // write into the annotation textarea so MB's model (knockout 'change' / React 'input') picks it up + dirties function annoSet(ta, value) { const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(ta, value); ta.dispatchEvent(new Event('input', { bubbles: true })); ta.dispatchEvent(new Event('change', { bubbles: true })); } // Enter on a list line continues it (same indent+marker — bullet "*"/"-" or numbered "a."/"1."); an empty // item ends the list. Pure → testable. function annoContinueBullet(value, pos) { const lineStart = value.lastIndexOf('\n', pos - 1) + 1; let lineEnd = value.indexOf('\n', pos); if (lineEnd < 0) lineEnd = value.length; const m = value.slice(lineStart, lineEnd).match(/^([ \t]*)([*+-]|\d+\.|[a-z]\.)([ \t]+)(.*)$/i); if (!m) return null; if (m[4].trim() === '') return { value: value.slice(0, lineStart) + value.slice(lineEnd), caret: lineStart }; // empty item → end list const prefix = m[1] + m[2] + m[3]; return { value: value.slice(0, pos) + '\n' + prefix + value.slice(pos), caret: pos + 1 + prefix.length }; } // Ctrl/Cmd+B/I: wrap the selection with `marker`, or — with no selection — surround the word under the cursor. Pure. function annoWrap(value, selStart, selEnd, marker) { let a = selStart, b = selEnd; if (a === b) { while (a > 0 && /\w/.test(value[a - 1])) a--; while (b < value.length && /\w/.test(value[b])) b++; } return { value: value.slice(0, a) + marker + value.slice(a, b) + marker + value.slice(b), selStart: a + marker.length, selEnd: b + marker.length }; } // Tab on a selection cycles the lines through plain → bullet → numbered → bullet…; Shift+Tab (strip=true) // removes any list marker. Pure → testable. function annoListSelection(value, selStart, selEnd, raw, strip) { let s = value.lastIndexOf('\n', selStart - 1) + 1; let e = selEnd; if (e > s && value[e - 1] === '\n') e--; let lineEnd = value.indexOf('\n', e); if (lineEnd < 0) lineEnd = value.length; const block = value.slice(s, lineEnd); const first = (block.split('\n').find(l => l.trim() !== '') || '').match(/^[ \t]*([-*+]|\d+\.|[a-z]\.)/i); const ordered = !!(first && /[-*+]/.test(first[1])); // currently bullets → switch to numbered; else → bullets const repl = block.split('\n').map(ln => { if (ln.trim() === '') return ln; if (strip) return ln.replace(/^[ \t]*(?:[-*+]|\d+\.|[a-z]\.)[ \t]+/i, ''); // remove the list marker, keep the text const txt = ln.replace(/^[ \t]*(?:[-*+]|\d+\.|[a-z]\.)?[ \t]*/i, ''); // drop leading ws + any existing marker return raw ? (ordered ? ' a. ' : ' * ') + txt : (ordered ? '1. ' : '- ') + txt; }).join('\n'); return { value: value.slice(0, s) + repl + value.slice(lineEnd), selStart: s, selEnd: s + repl.length }; } // "Join lines": collapse the selected lines — or, with no selection, the paragraph at the caret — into a // single line, turning every interior newline into one space so hard-wrapped text (e.g. Bandcamp credits) // reflows. Leaves blank-line paragraph boundaries as the natural edge of an empty selection. Pure → testable. function annoJoinBlock(value, selStart, selEnd) { let a, b; if (selStart === selEnd) { // no selection → expand to the run of non-blank lines around the caret a = value.lastIndexOf('\n', selStart - 1) + 1; while (a > 0) { const ps = value.lastIndexOf('\n', a - 2) + 1; if (!value.slice(ps, a - 1).trim()) break; a = ps; } b = value.indexOf('\n', selEnd); if (b < 0) b = value.length; while (b < value.length) { let ne = value.indexOf('\n', b + 1); if (ne < 0) ne = value.length; if (!value.slice(b + 1, ne).trim()) break; b = ne; } } else { // selection → expand to whole lines a = value.lastIndexOf('\n', selStart - 1) + 1; let e = selEnd; if (e > a && value[e - 1] === '\n') e--; b = value.indexOf('\n', e); if (b < 0) b = value.length; } const joined = value.slice(a, b).replace(/[ \t]*\r?\n[ \t]*/g, ' ').replace(/[ \t]{2,}/g, ' ').replace(/^[ \t]+|[ \t]+$/g, ''); return { value: value.slice(0, a) + joined + value.slice(b), selStart: a, selEnd: a + joined.length }; } // Wrap #annotation in a bordered editor box (toolbar + a Markdown editing surface + the raw MB field + an // in-place preview). Markdown is the DEFAULT surface; the real #annotation field always holds MB markup (so // saving is always correct) — Markdown edits are converted into it live. Mounted ONCE per #annotation node // (the 500ms applyReleaseInfo poll must not rebuild it — that was the flicker). const ANNO_MD_LOGO = ''; const ANNO_MAX_ICON = ''; // Join lines: two lines pulled together toward one (a downward merge between a top and bottom rule). const ANNO_JOIN_ICON = ''; const ANNO_MIN_ICON = ''; const ANNO_MB_LOGO = 'MB'; const ANNO_HELP_HTML = 'Markdown (converted to MusicBrainz markup on save)' + '' + '' + '' + '' + '' + '' + '' + '' + '' + '
      **bold** *italic*bold / italic
      # H1 ## H2 ### H3headings
      - itembullet list (indent 2 spaces to nest)
      1. itemnumbered list (MB auto-numbers)
      [text](url) · bare URLlink
      ```code```code block
      ---horizontal rule
      [x]shown literally (auto-encoded)
      ' + 'Shortcuts' + '' + '' + '' + '' + '' + '
      Ctrl/Cmd+B / +Ibold / italic (selection or word)
      Entercontinue the current list
      Tabindent · on a selection → bullet list (Tab again → numbered, again → bullet…)
      Shift+Tabon a selection → remove the list marker
      ' + '
      A MusicBrainz entity URL (bare or []()) gets its name added automatically.
      '; function ensureAnnotationToolbar(taArg) { const ta = taArg || document.getElementById('annotation'); if (!ta) return; // the MB annotation field — always holds MB markup if (ta._tcAnnoMounted && ta._tcAnnoMounted.isConnected) return; const mbid = (location.pathname.match(/\/release\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})(?:\/|$)/) || [])[1]; // present on /release//edit, absent on /release/add const wrap = document.createElement('div'); wrap.id = 'tc-anno-wrap'; const bar = document.createElement('div'); bar.id = 'tc-anno-bar'; bar.innerHTML = '' + '' + '' + '' + '' + '' + '' + '' + (mbid ? '' : ''); const body = document.createElement('div'); body.id = 'tc-anno-body'; const editPane = document.createElement('div'); editPane.id = 'tc-anno-edit'; const md = document.createElement('textarea'); md.id = 'tc-anno-mdinput'; md.spellcheck = false; // the Markdown editing surface const prev = document.createElement('div'); prev.id = 'tc-anno-preview'; prev.style.display = 'none'; const hist = document.createElement('div'); hist.id = 'tc-anno-history'; hist.style.display = 'none'; const helpPop = document.createElement('div'); helpPop.id = 'tc-anno-help-pop'; helpPop.innerHTML = ANNO_HELP_HTML; ta.parentNode.insertBefore(wrap, ta); editPane.append(md, ta); body.append(editPane, prev); wrap.append(bar, body, hist, helpPop); ta._tcAnnoMounted = wrap; // the field's row — a in the release editor, a div.row on the standalone Edit annotation page const annoRow = wrap.closest('tr, .row'); // release editor: put Disambiguation above Annotation (both already span the full column via CSS) const commRow = document.getElementById('comment')?.closest('tr'); if (annoRow && commRow && commRow !== annoRow && annoRow.previousElementSibling !== commRow) annoRow.parentNode.insertBefore(commRow, annoRow); // status messages appear next to the "Annotation:" label (not in the toolbar) const statusEl = document.createElement('span'); statusEl.id = 'tc-anno-status'; const labelCell = annoRow?.querySelector('td:first-child label') || annoRow?.querySelector('td:first-child') || annoRow?.querySelector('label'); if (labelCell) labelCell.appendChild(statusEl); const $ = id => bar.querySelector('#' + id); const status = (msg, ms) => { statusEl.textContent = msg ? ' — ' + msg : ''; if (ms) setTimeout(() => { if (statusEl.textContent === ' — ' + msg) statusEl.textContent = ''; }, ms); }; // apply a new value as a minimal range edit via execCommand, so it joins the textarea's NATIVE undo stack // (Ctrl+Z undoes Ctrl+B/I, Tab lists, Enter continuation, …). Falls back to the native setter if unsupported. const editTa = (el, val, s, e2) => { const old = el.value; if (old !== val) { let p = 0; const lim = Math.min(old.length, val.length); while (p < lim && old[p] === val[p]) p++; let so = old.length, sn = val.length; while (so > p && sn > p && old[so - 1] === val[sn - 1]) { so--; sn--; } const ins = val.slice(p, sn); el.focus(); el.setSelectionRange(p, so); let ok = false; try { ok = ins ? document.execCommand('insertText', false, ins) : (so > p ? document.execCommand('delete') : true); } catch { ok = false; } if (!ok || el.value !== val) { const set = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value').set; set.call(el, val); el.dispatchEvent(new Event('input', { bubbles: true })); } // fallback: no native undo, but correct } el.setSelectionRange(s, e2 == null ? s : e2); el.dispatchEvent(new Event('change', { bubbles: true })); }; const syncMdToField = () => annoSet(ta, mdToAnno(md.value)); // Markdown surface → MB field (keeps the model correct) const activeEl = () => surface === 'raw' ? ta : md; let surface = 'md', previewing = false, view = 'edit'; // surface: md|raw · previewing: split preview · view: edit|history const renderPreview = () => { if (previewing && view === 'edit') prev.innerHTML = annoToHtml(ta.value); }; const apply = () => { body.style.display = view === 'edit' ? 'flex' : 'none'; hist.style.display = view === 'history' ? '' : 'none'; md.style.display = surface === 'md' ? '' : 'none'; ta.style.display = surface === 'raw' ? '' : 'none'; prev.style.display = previewing && view === 'edit' ? '' : 'none'; bar.classList.toggle('tc-anno-prev-on', previewing); bar.classList.toggle('tc-anno-hist-on', view === 'history'); const mdBtn = $('tc-anno-md'); mdBtn.innerHTML = surface === 'md' ? ANNO_MD_LOGO : ANNO_MB_LOGO; mdBtn.title = surface === 'md' ? 'Editing as Markdown — click to edit the raw MusicBrainz markup' : 'Editing raw MusicBrainz markup — click to edit as Markdown'; renderPreview(); }; let previewT; md.addEventListener('input', () => { if (surface === 'md') syncMdToField(); clearTimeout(previewT); previewT = setTimeout(renderPreview, 120); }); ta.addEventListener('input', () => { if (surface === 'raw') { clearTimeout(previewT); previewT = setTimeout(renderPreview, 120); } }); const wireKeys = el => el.addEventListener('keydown', e => { const raw = el === ta; if (e.key === 'Enter' && !e.shiftKey && el.selectionStart === el.selectionEnd) { const r = annoContinueBullet(el.value, el.selectionStart); if (r) { e.preventDefault(); editTa(el, r.value, r.caret); } } else if (e.key === 'Tab') { e.preventDefault(); if (el.selectionStart !== el.selectionEnd) { // Tab → bullet → numbered → bullet…; Shift+Tab → remove the list marker const r = annoListSelection(el.value, el.selectionStart, el.selectionEnd, raw, e.shiftKey); editTa(el, r.value, r.selStart, r.selEnd); } else if (!e.shiftKey) { const p = el.selectionStart, v = el.value; editTa(el, v.slice(0, p) + '\t' + v.slice(p), p + 1); } // plain Tab → insert a tab } else if ((e.ctrlKey || e.metaKey) && !e.altKey && /^[biBI]$/.test(e.key)) { e.preventDefault(); const bold = e.key.toLowerCase() === 'b'; const marker = raw ? (bold ? "'''" : "''") : (bold ? '**' : '*'); const r = annoWrap(el.value, el.selectionStart, el.selectionEnd, marker); editTa(el, r.value, r.selStart, r.selEnd); } }); wireKeys(md); wireKeys(ta); // auto-resolve unnamed MB entity links (no button) — on blur of the editing surface, and once on mount let resolving = false; const autoResolve = async (el) => { if (resolving || !ANNO_ENTITY_RE.test(el.value)) return; resolving = true; const before = el.value; try { const after = await annoResolveNames(before, el === md); if (after !== before && el.value === before) { editTa(el, after, Math.min(el.selectionStart, after.length)); status('named entity links', 2000); } } finally { resolving = false; } }; md.addEventListener('blur', () => autoResolve(md)); ta.addEventListener('blur', () => autoResolve(ta)); $('tc-anno-preview-btn').onclick = () => { previewing = !previewing; apply(); }; $('tc-anno-md').onclick = () => { if (surface === 'md') syncMdToField(); surface = surface === 'md' ? 'raw' : 'md'; if (surface === 'md') md.value = annoToMd(ta.value); apply(); activeEl().focus(); }; $('tc-anno-clear').onclick = () => { md.value = ''; annoSet(ta, ''); renderPreview(); }; // Join lines: reflow the selected lines (or the caret's paragraph) into one — works on whichever surface is active $('tc-anno-join').onclick = () => { const el = activeEl(); const r = annoJoinBlock(el.value, el.selectionStart, el.selectionEnd); editTa(el, r.value, r.selStart, r.selEnd); el.focus(); }; // maximize / restore the editor (fills the viewport) const setMax = on => { wrap.classList.toggle('tc-anno-max', on); document.body.classList.toggle('tc-anno-max-open', on); if (on) wrap.style.height = ''; else if (wrap._tcFill) wrap._tcFill(); const b = $('tc-anno-max'); b.innerHTML = on ? ANNO_MIN_ICON : ANNO_MAX_ICON; b.title = on ? 'Restore the editor (Esc)' : 'Maximize the editor (Esc to restore)'; renderPreview(); }; $('tc-anno-max').onclick = () => setMax(!wrap.classList.contains('tc-anno-max')); wrap.addEventListener('keydown', e => { if (e.key === 'Escape' && wrap.classList.contains('tc-anno-max')) { setMax(false); activeEl().focus(); } }); // click-to-show help popover (#521, majkinetor: "it pops out when you // casually move mouse" — hover was too eager). Toggles on click, closes // on a click anywhere else or Escape. const help = $('tc-anno-help'); const showHelp = () => { helpPop.classList.add('on'); const r = help.getBoundingClientRect(), ph = helpPop.offsetHeight, pw = helpPop.offsetWidth; const left = Math.max(8, Math.min(r.left, window.innerWidth - pw - 8)); let top = r.bottom + 6; if (top + ph > window.innerHeight - 8) top = Math.max(8, r.top - ph - 6); // flip above the button if it would overflow the bottom if (top < 8) top = 8; helpPop.style.left = Math.round(left) + 'px'; helpPop.style.top = Math.round(top) + 'px'; }; const hideHelp = () => helpPop.classList.remove('on'); help.addEventListener('click', e => { e.stopPropagation(); if (helpPop.classList.contains('on')) hideHelp(); else showHelp(); }); const outsideHideHelp = e => { if (helpPop.classList.contains('on') && !helpPop.contains(e.target) && e.target !== help) hideHelp(); }; document.addEventListener('mousedown', outsideHideHelp, true); // removed in unmountAnnotation() wrap._tcOutsideHideHelp = outsideHideHelp; wrap.addEventListener('keydown', e => { if (e.key === 'Escape' && helpPop.classList.contains('on')) { e.stopPropagation(); hideHelp(); } }); // History — version list as user cards on the RIGHT, the selected version rendered on the LEFT const renderHistory = async () => { hist.innerHTML = '
      Loading history…
      '; let versions; try { versions = await annoFetchHistory(mbid); } catch { hist.innerHTML = '
      Failed to load history.
      '; return; } if (!versions.length) { hist.innerHTML = '
      No annotation history yet.
      '; return; } hist.innerHTML = '
      Select a version to display it.
      '; const list = hist.querySelector('.tc-hist-list'), vw = hist.querySelector('.tc-hist-view'); versions.forEach((v, idx) => { const card = document.createElement('div'); card.className = 'tc-hist-card'; card.tabIndex = 0; card.innerHTML = (v.avatar ? `` : '') + `${_annoEsc(v.editor)}${_annoEsc(v.date)}` + (idx === 0 ? 'current' : '') + (v.changelog ? `“${_annoEsc(v.changelog)}”` : '') + '' + ``; card.onclick = async (e) => { if (e.target.closest('.tc-hist-revert')) return; list.querySelectorAll('.tc-hist-card').forEach(c => c.classList.remove('on')); card.classList.add('on'); vw.innerHTML = '
      Loading…
      '; try { vw.innerHTML = '
      ' + await annoFetchVersion(v.url) + '
      '; } catch { vw.innerHTML = '
      Failed to load this version.
      '; } }; const revert = card.querySelector('.tc-hist-revert'); if (revert) revert.onclick = async (e) => { e.stopPropagation(); revert.disabled = true; try { const mb = annoHtmlToMb(await annoFetchVersion(v.url)); annoSet(ta, mb); md.value = annoToMd(ta.value); view = 'edit'; apply(); activeEl().focus(); status('reverted to ' + v.date + ' — review before submitting', 4000); } catch { status('failed to load that version', 3000); } finally { revert.disabled = false; } }; list.appendChild(card); }); }; if ($('tc-anno-history-btn')) $('tc-anno-history-btn').onclick = () => { view = view === 'history' ? 'edit' : 'history'; apply(); if (view === 'history') renderHistory(); }; md.value = annoToMd(ta.value); // seed the Markdown surface from existing MB markup (no annoSet → no spurious dirty) apply(); // reflect EXTERNAL changes to the annotation (Scribe, scripts, revert) in the Markdown surface. // ko's `value` binding sets the textarea WITHOUT firing input, so a DOM listener can't see it — // subscribe to the observable. Our own md→field writes round-trip equal, so they're ignored (no loop). try { const annoObs = getEditor().rootField.release().annotation; if (annoObs && typeof annoObs.subscribe === 'function' && !ta._tcAnnoSub) { ta._tcAnnoSub = annoObs.subscribe(v => { const nv = v || ''; if (mdToAnno(md.value) === nv) return; // our own write / already in sync md.value = annoToMd(nv); // render the preview from nv directly — ta.value may not be updated yet (ko subscriber // order isn't guaranteed), which would leave the split preview showing the OLD annotation. if (previewing && view === 'edit') prev.innerHTML = annoToHtml(nv); }); } } catch (e) {} setTimeout(() => autoResolve(activeEl()), 400); // name any unnamed links already in the annotation } function annoWant() { return apolloEnabled() && SETTINGS.modifyAnnotation !== false; } // global Apollo toggle + the "Modify annotations" setting // tear the editor down and put the native textarea back (when the setting is turned off) function unmountAnnotation(taArg) { const ta = taArg || document.getElementById('annotation'); const wrap = ta && ta._tcAnnoMounted; if (!wrap || !wrap.isConnected) { if (ta) ta._tcAnnoMounted = null; return; } if (wrap._tcOutsideHideHelp) document.removeEventListener('mousedown', wrap._tcOutsideHideHelp, true); // #521 — a document-level listener, not cleaned up by wrap.remove() ta.style.display = ''; wrap.parentNode.insertBefore(ta, wrap); wrap.remove(); ta._tcAnnoMounted = null; document.getElementById('tc-anno-status')?.remove(); } // standalone /release//edit_annotation page: mount our editor on the annotation field, move the // Changelog above it (like Disambiguation in /edit). Gated by the same "Modify annotations" setting. function applyAnnotationPage() { if (!ANNO_PAGE_RE.test(location.pathname)) return; const ta = document.querySelector('textarea[name="edit-annotation.text"]'); if (!ta) return; ensureLauncher(); // the floating Original / Apollo switcher + ⚙ settings, same as the release editor const form = ta.closest('form'), hide = annoWant(); if (hide) { document.body.classList.add('tc-ri-on', 'tc-anno-page'); if (!document.getElementById('tc-ri-style')) riStyle(); ensureAnnotationToolbar(ta); const annoRow = (ta._tcAnnoMounted || ta).closest('.row'), clRow = document.querySelector('input[name="edit-annotation.changelog"]')?.closest('.row'); if (annoRow && clRow && clRow !== annoRow && annoRow.previousElementSibling !== clRow) annoRow.parentNode.insertBefore(clRow, annoRow); } else { unmountAnnotation(ta); document.body.classList.remove('tc-anno-page'); } // hide everything below the editor — the Edit note (the Change note already serves that role), the "Make all // edits votable" row, the native Preview button, and MB's formatting guide — keeping only "Enter edit". const annoRowEl = (ta._tcAnnoMounted || ta).closest('.row'), els = new Set(); const fh = [...document.querySelectorAll('#content h3')].find(h => /annotation formatting/i.test(h.textContent || '')); if (fh) { els.add(fh); for (let n = fh.nextElementSibling; n; n = n.nextElementSibling) els.add(n); } // the guide is the h3 + everything after it (NOT its parent, which is #content!) [...document.querySelectorAll('#content h2')].forEach(h => { if (/^\s*edit note\s*$/i.test((h.textContent || '').trim())) { els.add(h); let n = h.nextElementSibling; while (n && n.tagName === 'P') { els.add(n); n = n.nextElementSibling; } } }); if (form && annoRowEl) { const kids = [...form.children], ai = kids.indexOf(annoRowEl); kids.forEach((ch, i) => { if (i > ai) els.add(ch); }); } // everything below the editor, incl. the native buttons els.forEach(el => { el.style.display = hide ? 'none' : ''; }); // our own "Enter edit" (the native one is hidden) — placed to the right of the Changelog input; just clicks // the native submit so MB's flow runs const clInput = document.querySelector('input[name="edit-annotation.changelog"]'), clRow = clInput?.closest('.row'); let sub = document.getElementById('tc-anno-submit'); if (hide && !sub && clInput) { sub = document.createElement('button'); sub.id = 'tc-anno-submit'; sub.type = 'button'; sub.textContent = '✓ Enter edit'; sub.onclick = () => { const b = form && [...form.querySelectorAll('button, input[type=submit]')].find(x => x.id !== 'tc-anno-submit' && /enter edit/i.test((x.textContent || x.value || '').trim())); if (b) b.click(); }; // the NATIVE submit (not ourselves) clInput.after(sub); } if (clRow) clRow.classList.toggle('tc-cl-row', hide); if (sub) sub.style.display = hide ? '' : 'none'; document.getElementById('tc-anno-fouc')?.remove(); // reveal the (now transformed) form — no native flash // make the editor fill the remaining viewport height (down to just above the footer) const w = ta._tcAnnoMounted; if (hide && w) { if (!w._tcFill) { w._tcFill = () => { if (!w.isConnected || w.classList.contains('tc-anno-max')) { w.style.height = ''; return; } w.style.height = Math.max(300, window.innerHeight - w.getBoundingClientRect().top - 18) + 'px'; }; window.addEventListener('resize', w._tcFill); } requestAnimationFrame(w._tcFill); } else if (w && w._tcFill) w.style.height = ''; } // MB's contextual guidance box(es) — anything outside #information that's just the style-guidelines help // (the in-panel ones are hidden by CSS via #information .bubble/.guidance) function nativeHelpBubbles() { const out = new Set(); const isHelp = e => !e.querySelector('input,button,select,textarea'); // a functional editor bubble (URL cleanup, add/edit link) has controls — never hide it document.querySelectorAll('#release-editor .bubble, #release-editor .guidance, #release-editor .guidance-popover, #page .bubble').forEach(e => { if (isHelp(e)) out.add(e); }); [...document.querySelectorAll('#page div')].forEach(e => { if (e.offsetParent === null || document.getElementById('information')?.contains(e)) return; if (e.querySelector('a[href*="style"]') && (e.textContent || '').length < 400 && !e.querySelector('input,button,select,textarea,table,fieldset,h2')) out.add(e); }); return [...out]; } // clicking the favicon edits the URL (edit1); clicking the type chip edits the relationship type (edit2). // Both proxy to MB's own (hover-hidden) pencil buttons so the native editor bubble does the actual work. let _riClicksWired = false; function wireLinkClicks() { if (_riClicksWired) return; _riClicksWired = true; // right-click the favicon → edit URL; right-click a type → edit type. Both proxy to MB's own pencil button. document.addEventListener('contextmenu', e => { if (!document.body.classList.contains('tc-ri-on')) return; const ext = document.getElementById('external-links-editor'); if (!ext || !ext.contains(e.target)) return; const type = e.target.closest('.relationship-name, .relationship-content, select.link-type'); if (type) { const btn = type.closest('tr.relationship-item')?.querySelector('button.edit-item'); if (btn) { e.preventDefault(); btn.click(); } return; } const linkRow = e.target.closest('tr.external-link-item'); if (linkRow && e.target.closest('td:first-child')) { // the favicon cell const btn = linkRow.querySelector('button.edit-item'); if (btn) { e.preventDefault(); btn.click(); } } }); } // #391 right-click a release-event or label ✕ → a GT-style "Remove all but this one" batch cleanup. // Keeps the row you right-clicked and removes every OTHER entry straight off the KO observableArray. let _riBatchWired = false; function wireReleaseInfoBatch() { if (_riBatchWired) return; _riBatchWired = true; document.addEventListener('contextmenu', e => { const btn = e.target.closest('button.remove-item.remove-release-event, button.remove-item.remove-release-label'); if (!btn) return; const isEvent = btn.classList.contains('remove-release-event'); const rel = release(); if (!rel) return; const arr = isEvent ? rel.events : rel.labels; if (typeof arr !== 'function' || typeof arr.remove !== 'function') return; const list = arr() || []; const n = list.length - 1; // everything except the clicked row if (n < 1) return; // 0–1 rows → nothing to batch-remove; let the native menu through e.preventDefault(); const sel = isEvent ? 'button.remove-item.remove-release-event' : 'button.remove-item.remove-release-label'; const idx = [...document.querySelectorAll(sel)].indexOf(btn); // the clicked row's position = its array index const keep = idx >= 0 ? list[idx] : list[0]; const noun = isEvent ? 'release event' : 'label'; openMiniMenu(btn, [{ label: `Remove all but this one (${n})`, title: `Remove the other ${n} ${noun}${n > 1 ? 's' : ''}, keeping this row`, onClick: () => { for (const it of (arr() || []).slice()) { if (it !== keep) { try { arr.remove(it); } catch (x) {} } } } }]); }, true); } // Tell the user the favicon + type are right-click-editable (the affordance isn't obvious). // Re-applied each tick via applyReleaseInfo, so React re-renders that drop the title get it back. function annotateLinkEditHints() { const ext = document.getElementById('external-links-editor'); if (!ext) return; const URL_HINT = 'Right-click to edit the URL', TYPE_HINT = 'Right-click to edit the relationship type'; ext.querySelectorAll('tr.external-link-item > td:first-child').forEach(td => { if (td.title !== URL_HINT) td.title = URL_HINT; }); ext.querySelectorAll('tr.relationship-item .relationship-name, tr.relationship-item select.link-type').forEach(el => { if (el.title !== TYPE_HINT) el.title = TYPE_HINT; }); } // MB indents hierarchical link-type options with leading spaces (" purchase for download"); the