// ==UserScript== // @name Group Therapy // @namespace https://github.com/majkinetor/musicbrainz-userscripts // @version 2026.10.16 // @description MusicBrainz relationship helpers: batch-delete rel groups from a right-click menu, page-wide hover highlight with a count tooltip, and copy/move credits between recordings & clone release credits. Chrome-light — context menus + hover, no toolbar. // @author majkinetor // @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48ZyBmaWxsPSJub25lIiBzdHJva2U9IiM1YjZiN2EiIHN0cm9rZS13aWR0aD0iNyIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIj48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9Ijk0IiB5Mj0iNDIiLz48bGluZSB4MT0iMzQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48bGluZSB4MT0iOTQiIHkxPSI0MiIgeDI9IjY0IiB5Mj0iOTQiLz48L2c+PGcgZmlsbD0iIzJlOWU1YiIgc3Ryb2tlPSIjMjU2ZjQzIiBzdHJva2Utd2lkdGg9IjQiPjxjaXJjbGUgY3g9IjM0IiBjeT0iNDIiIHI9IjE2Ii8+PGNpcmNsZSBjeD0iOTQiIGN5PSI0MiIgcj0iMTYiLz48Y2lyY2xlIGN4PSI2NCIgY3k9Ijk0IiByPSIxNiIvPjwvZz48L3N2Zz4= // @match *://*.musicbrainz.org/release/*/edit-relationships // @match *://*.musicbrainz.org/artist/* // @match *://*.musicbrainz.org/label/* // @match *://*.musicbrainz.org/place/* // @grant GM_getValue // @grant GM_setValue // @grant GM_openInTab // @grant unsafeWindow // @run-at document-end // @noframes // ==/UserScript== /* eslint-disable no-undef */ (function () { 'use strict'; // one copy per page: with String Theory and a standalone install both on, the newer one runs (#653) if (!mbuClaim('group_therapy', 'Group Therapy')) 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)) { const r = await fetch(url, init); if (!mbnWebsite(url) || !(await mbnIsCheck(r))) return r; // #733: MusicBrainz's browser check answered instead: nothing was done (a POST is safe // to repeat), so pass the check and ask again return (await mbnReverify(url, init, o)) ? fetch(url, init) : r; } 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; } function mbnWebsite(url) { const s = String(url || ''); if (mbnGated(s)) return false; if (/^https?:\/\/([a-z0-9-]+\.)*musicbrainz\.org(\/|$)/i.test(s)) return true; return /^\/(?!\/)/.test(s) && typeof location !== 'undefined' && /(^|\.)musicbrainz\.org$/i.test(location.hostname); } async function mbnIsCheck(r) { try { if (!r || !/text\/html/i.test((r.headers && r.headers.get('content-type')) || '')) return false; const head = (await r.clone().text()).slice(0, 3000); return /\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(head); } catch (e) { return false; } } function mbnReverify(url, init, o) { o = o || {}; const log = (lvl, m) => { try { if (o.log) o.log(lvl, m); else (lvl === 'warn' ? console.warn : console.info)('[MusicBrainz gate] ' + m); } catch (e) {} }; const g = globalThis.__mbnCheck || (globalThis.__mbnCheck = { busy: null, failedAt: 0 }); if (g.busy) return g.busy; let target; try { target = new URL(String(url), location.href); } catch (e) { return Promise.resolve(false); } if (target.host !== location.host || typeof document === 'undefined') { log('warn', 'MusicBrainz asked to verify the browser — open any MusicBrainz page, then retry'); return Promise.resolve(false); } if (Date.now() - g.failedAt < 60000) return Promise.resolve(false); // just failed: don't loop // a POST's URL can't be loaded: the check is per browser, so any light GET passes it const method = String((init && init.method) || 'GET').toUpperCase(); const src = method === 'GET' ? target.href : location.origin + '/ws/js/entity/89ad4ac3-39f7-470e-963a-56509c546377'; const t0 = Date.now(); log('warn', 'MusicBrainz asked to verify the browser again ("Verifying your browser") — passing it in a hidden frame'); g.busy = new Promise(resolve => { const fr = document.createElement('iframe'); // fresh each time, never re-navigated fr.setAttribute('aria-hidden', 'true'); fr.setAttribute('data-mbn-check', ''); fr.tabIndex = -1; fr.style.cssText = 'position:fixed;width:1px;height:1px;left:-9999px;top:0;border:0;opacity:0;pointer-events:none'; let loads = 0, done = false, timer = 0; const finish = ok => { if (done) return; done = true; clearTimeout(timer); fr.remove(); g.busy = null; if (ok) log('info', 'browser check passed in ' + (Date.now() - t0) + ' ms — retrying'); else { g.failedAt = Date.now(); const m = 'MusicBrainz wants to verify your browser — open any MusicBrainz page in another tab, then retry'; log('warn', m + ' (the hidden frame did not clear it in 20 s)'); try { if (o.notify) o.notify(m); } catch (e) {} } resolve(ok); }; // the first load is the check; it posts its answer and is redirected to the real URL fr.onload = () => { loads++; let still; try { const d = fr.contentDocument; still = !d || /<title>\s*Verifying your browser\s*<\/title>|JavaScript is required to access this page/i.test(d.documentElement ? d.documentElement.outerHTML.slice(0, 3000) : ''); } catch (e) { still = loads < 2; } if (!still) finish(true); }; timer = setTimeout(() => finish(false), 20000); fr.src = src; (document.body || document.documentElement).appendChild(fr); }); return g.busy; } // </ST-NET> const VERSION = (typeof GM_info !== 'undefined' && GM_info && GM_info.script && GM_info.script.version) || '?'; // from the @version header at runtime const W = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : window); // ── tiny DOM helpers ────────────────────────────────────────────────────── // #522 follow-up (majkinetor, live): "why is LastPass recognizing edits as // passwords?" — password managers heuristically flag plain text inputs // that carry no autocomplete/ignore hint. Every <input> this tool creates // is a search/filter/edit box, never a credential, so opt every one of // them out up front rather than chasing individual false positives. const el = (tag, cls, txt) => { const e = document.createElement(tag); if (cls) e.className = cls; if (txt != null) e.textContent = txt; if (tag === 'input') { 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 trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; // MB renders each rel as <tr class="<role-kebab>"> … <div class="relationship-item"> <button class="icon remove-item">×</button> <a href="/artist|work|…/<mbid>">name</a> … const REMOVE_SEL = 'button.icon.remove-item'; const pickRoleLabel = tr => { const l = tr && tr.querySelector('th.link-phrase label'); return l ? (l.textContent || '').replace(/:\s*$/, '').trim() : 'role'; }; // medium number a track row belongs to — the nearest preceding `tr.subh` ("1▼CD" → 1). Each medium // is its own <tbody>, so we scan the table's rows in document order (not just siblings). Cached per row. // #623 (sweep): one pass over the table fills both caches for every row, instead of // re-scanning all its rows once per row asked about (O(rows²) on a big box set — the // hover highlight asks for every hit, and the format lookup was never cached at all). const _medCache = new WeakMap(), _fmtCache = new WeakMap(); const indexMedia = tbl => { let med = null, fmt = ''; for (const row of tbl.querySelectorAll('tr')) { if (row.classList && row.classList.contains('subh')) { const t = row.textContent || '', m = t.match(/(\d+)/); med = m ? m[1] : null; fmt = t.replace(/^\s*\d+\s*/, '').replace(/^[^A-Za-z0-9]+/, '').trim(); } else { _medCache.set(row, med); _fmtCache.set(row, fmt); } } }; const mediumNumberOf = tr => { if (!tr) return null; if (!_medCache.has(tr)) { const tbl = tr.closest && tr.closest('table'); if (!tbl) return null; indexMedia(tbl); } return _medCache.has(tr) ? _medCache.get(tr) : null; }; // medium FORMAT label for a track row (from the nearest preceding tr.subh, e.g. "1▼CD" → "CD", "2▼12″ Vinyl" → "12″ Vinyl") const mediumFormatOf = tr => { if (!tr) return ''; if (!_fmtCache.has(tr)) { const tbl = tr.closest && tr.closest('table'); if (!tbl) return ''; indexMedia(tbl); } return _fmtCache.get(tr) || ''; }; // track position label from the row's position cell — handles vinyl/multi-disc numbers ("D5", "A1") // as well as plain "5". On multi-medium releases, a plain number is prefixed with the medium so // "1" on CD 1 vs CD 2 read as "1.1" / "2.1". Returns a string (or null for non-track rows). // `multi` (optional): whether the release has several media, when the caller already // knows — the highlight asks once, not once per hit (#623) const posLabel = (tr, multi) => { if (!tr || !tr.querySelector) return null; const c = tr.querySelector('td.pos'); let t = c ? (c.textContent || '').trim() : ''; if (!t) { const m = (tr.textContent || '').match(/^\s*(\d+)\b/); t = m ? m[1] : ''; } if (!t) return null; if (/^\d+$/.test(t) && (multi != null ? multi : document.querySelectorAll('tr.subh').length > 1)) { const med = mediumNumberOf(tr); if (med) t = med + '.' + t; } return t; }; const targetHref = item => { const a = item && item.querySelector('a[href*="/artist/"], a[href*="/work/"], a[href*="/label/"], a[href*="/place/"], a[href*="/recording/"], a[href*="/url/"], a[href*="/event/"], a[href*="/instrument/"]'); return a ? a.getAttribute('href') : null; }; const targetLabel = item => { const a = item && item.querySelector('a[href*="/"]'); return a ? (a.textContent || '').trim() : 'target'; }; const itemHasHref = (item, href) => !!(href && item.querySelector(`a[href="${CSS.escape(href)}"]`)); // a rel's "role" for grouping = its link type PLUS its attributes — because e.g. every instrument rel // shares the one "instrument" link type and the specific instrument (drums, shakers, …) is an // attribute; matching on the CSS role class alone would lump drums + shakers + vocals together. const _roleKeyCache = new WeakMap(); function relRoleKey(item) { if (_roleKeyCache.has(item)) return _roleKeyCache.get(item); const rel = relFromNode(item); let key = null; if (rel) { let attrs = ''; try { if (rel.attributes) attrs = [...W.MB.tree.iterate(rel.attributes)].map(a => a.typeID).sort((x, y) => x - y).join(','); } catch (e) {} key = rel.linkTypeID + '#' + attrs; } _roleKeyCache.set(item, key); return key; } // collect peer relationship-items matching a scope relative to a seed × button function collect(seedBtn, scope) { const seedItem = seedBtn.closest('.relationship-item'); if (!seedItem) return []; const seedKey = relRoleKey(seedItem), href = targetHref(seedItem); return [...document.querySelectorAll('.relationship-item')].filter(item => { if (scope === 'role') return relRoleKey(item) === seedKey; if (scope === 'target') return itemHasHref(item, href); return relRoleKey(item) === seedKey && itemHasHref(item, href); // role+target }); } const removeButtons = items => items.map(it => it.querySelector(REMOVE_SEL)).filter(Boolean); // ── edit-note signature — stamped into MB's edit-note field ONLY when GT actually changes something ── const GT_HOMEPAGE = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/group_therapy/README.md'; function editNoteSig() { let s = {}; try { if (typeof GM_info !== 'undefined' && GM_info.script) s = GM_info.script; } catch (e) {} const name = (s.name || 'Group Therapy').split(/\s+[—–-]\s+/)[0].trim(); // drop the "— MusicBrainz relationship helper" suffix // Same shape as every other script in the repo: "<Name> v<version> by <author> - <url>". // GT used to put the author before the version, which is the odd one out and made // version stats unparseable for anything reading edit notes (dev/script-metrics #570). return `${name} v${s.version || VERSION} by ${s.author || 'majkinetor'} - ${s.homepageURL || s.homepage || GT_HOMEPAGE}`; } // Stamp our signature into MB's edit-note field and, under it, an accumulating list of what GT did // ("Copied credits from track 4 to tracks 1–5", "Removed guitar (14)"). Any note that preceded ours // (another script's) is preserved ahead of our block. Idempotent per identical action line. function stampEditNote(action) { const ta = document.querySelector('textarea.edit-note, #edit-note-text'); if (!ta) return; const sig = editNoteSig(), cur = ta.value || ''; let pre = cur.replace(/\s+$/, ''), ourLines = []; const idx = cur.indexOf(sig); if (idx >= 0) { pre = cur.slice(0, idx).replace(/\s+$/, ''); ourLines = cur.slice(idx + sig.length).split('\n').map(l => l.trim()).filter(Boolean); } if (action && !ourLines.includes(action)) ourLines.push(action); const block = ourLines.length ? `${sig}\n\n${ourLines.join('\n')}` : sig; const next = pre ? `${pre}\n\n${block}` : block; try { const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(ta), 'value').set; set.call(ta, next); ta.dispatchEvent(new Event('input', { bubbles: true })); } catch (e) {} } function markUsed(action) { try { stampEditNote(action); } catch (e) {} } // ── subtle context menu ─────────────────────────────────────────────────── let menuEl = null; function closeMenu() { if (menuEl) { menuEl.remove(); menuEl = null; document.removeEventListener('mousedown', onDocDown, true); document.removeEventListener('keydown', onKey, true); } } function onDocDown(e) { if (menuEl && !menuEl.contains(e.target)) closeMenu(); } function onKey(e) { if (e.key === 'Escape') closeMenu(); } function openMenu(x, y, items) { // items: [{label, sub, danger, run} | 'sep'] closeMenu(); menuEl = el('div', 'gt-menu'); for (const it of items) { if (it === 'sep') { menuEl.appendChild(el('div', 'gt-sep')); continue; } if (it.header != null) { const h = el('div', 'gt-hdr', it.header); it._set = v => { try { h.textContent = v; } catch (e) {} }; menuEl.appendChild(h); continue; } // #377 live header if (it.note != null) { menuEl.appendChild(el('div', 'gt-note', it.note)); continue; } // #597: a text field inside the menu — the track selector for the vertical // moves. onInput reports back {text, ok, title} so the row underneath can // say what the spec actually matched BEFORE anything is copied, the same // way the text parser's Scope does. if (it.input) { const wrap = el('div', 'gt-mi-input'); const inp = el('input', 'gt-mi-in'); inp.type = 'text'; inp.placeholder = it.input.placeholder || ''; inp.title = it.input.title || ''; inp.value = it.input.value || ''; const info = el('div', 'gt-mi-ininfo', ''); const upd = () => { const res = it.input.onInput(inp.value) || {}; info.textContent = res.text || ''; info.style.color = res.ok ? 'var(--mbu-ok)' : 'var(--mbu-warn)'; info.title = res.title || ''; }; inp.addEventListener('input', upd); // A menu whose CONTENT depends on the spec (the ⬇ checklist is built // from the tracks being scanned) has to be rebuilt, and rebuilding on // every keystroke would yank the field out from under the typing. So // the live echo runs on input and the rebuild waits for `change` — // Enter, or clicking away. if (it.input.onChange) inp.addEventListener('change', () => it.input.onChange(inp.value)); wrap.append(inp, info); menuEl.appendChild(wrap); upd(); setTimeout(() => { try { inp.focus(); } catch (e) {} }, 0); continue; } if (it.checklist) { // per-credit toggles for copy/move — clicking a box toggles, doesn't close the menu const box = el('div', 'gt-ck-list'); it.checklist.forEach(entry => { const lab = el('label', 'gt-ck'); const cb = el('input', 'gt-ck-cb'); cb.type = 'checkbox'; cb.checked = entry.checked !== false; entry.cb = cb; cb.addEventListener('change', () => { if (it.onToggle) it.onToggle(); }); // whole row toggles (it's a <label>); right-click selects only this role — SHIFT-right-click ADDS // this role to the current selection instead of replacing it (#377) lab.addEventListener('contextmenu', ev => { ev.preventDefault(); ev.stopPropagation(); it.checklist.forEach(en => { if (!en.cb) return; if (ev.shiftKey) { if (en.role === entry.role) en.cb.checked = true; } else en.cb.checked = en.role === entry.role; }); if (it.onToggle) it.onToggle(); }); lab.appendChild(cb); lab.appendChild(el('span', 'gt-ck-pos', `[${entry.pos}]`)); lab.appendChild(el('span', 'gt-ck-tx', entry.text)); if (it.rowBtns) { const acts = el('span', 'gt-ck-acts'); it.rowBtns.forEach(rb => { const b = el('button', 'gt-ck-act', rb.label); b.type = 'button'; b.title = rb.title || ''; b.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); rb.run(entry); }); acts.appendChild(b); }); lab.appendChild(acts); } // #377 [A]/[R] per-row select buttons box.appendChild(lab); }); menuEl.appendChild(box); continue; } const row = el('button', 'gt-mi' + (it.danger ? ' gt-danger' : '')); const top = el('div', 'gt-mi-top'); top.appendChild(el('span', 'gt-mi-l', it.label)); if (it.sub != null) { const badge = el('span', 'gt-mi-s', it.sub); top.appendChild(badge); it._setSub = v => { try { badge.textContent = v; } catch (e) {} }; } row.appendChild(top); if (it.lines && it.lines.length) { // #338: fully-detailed per-track blast breakdown const box = el('div', 'gt-mi-lines'), MAX = 12; it.lines.slice(0, MAX).forEach(ln => { const l = el('div', 'gt-mi-ln'); l.appendChild(el('span', 'gt-mi-pos', `[${ln.pos}]`)); l.appendChild(el('span', 'gt-mi-tx', ln.text)); box.appendChild(l); }); if (it.lines.length > MAX) box.appendChild(el('div', 'gt-mi-ln gt-mi-more', `… ${it.lines.length - MAX} more`)); row.appendChild(box); } row.onclick = () => { closeMenu(); try { it.run(); } catch (e) {} }; menuEl.appendChild(row); } document.body.appendChild(menuEl); // keep on-screen const r = menuEl.getBoundingClientRect(); menuEl.style.left = Math.min(x, window.innerWidth - r.width - 8) + 'px'; menuEl.style.top = Math.min(y, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onDocDown, true); document.addEventListener('keydown', onKey, true); }, 0); } // ── batch delete: right-click a rel's × → remove a whole group ───────────── // We never fabricate a removal — we click MB's own peer × buttons, so React // handles each exactly like a manual click (works on existing + new rels). function runRemoval(items, desc) { const btns = removeButtons(items); for (const b of btns) { try { b.click(); } catch (e) {} } if (btns.length) markUsed(desc || `Removed ${btns.length} relationship${btns.length > 1 ? 's' : ''}`); } // per-rel facts: its track position (null = release-level), role label, and target name function itemInfo(it) { const roleTr = it.closest && it.closest('tr'); const trackTr = it.closest && it.closest('tr.track'); const a = it.querySelector('a[href*="/"]'); // #373 a work credit (writer/composer/…) belongs to its WORK, not the track it's nested under — so the // ×-delete blast-radius counts and lists it by work, not by track position. const rc = relClass(it); const pos = (rc && rc.kind === 'work' && rc.work) ? ('“' + (val(rc.work.name) || 'work') + '”') : posLabel(trackTr); return { pos, role: pickRoleLabel(roleTr), target: a ? (a.textContent || '').trim() : '' }; } // fully-detailed blast breakdown: group the group's rels by track, and per track list the varying // dimension — the targets (for a role scope), the roles (for a target scope), the role (for both). function breakdown(items, scope) { const byPos = new Map(); for (const it of items) { const info = itemInfo(it), key = info.pos == null ? 'R' : info.pos; if (!byPos.has(key)) byPos.set(key, { pos: info.pos, vals: [] }); byPos.get(key).vals.push(scope === 'role' ? info.target : info.role); } return [...byPos.values()].sort((a, b) => { if (a.pos == null) return 1; if (b.pos == null) return -1; return String(a.pos).localeCompare(String(b.pos), undefined, { numeric: true }); }) .map(r => ({ pos: r.pos == null ? 'rel' : r.pos, text: [...new Set(r.vals.filter(Boolean))].join(', ') })); } // keys of the recordings / works MB currently has selected (ticked checkboxes) function selectionKeys() { const re = RE(), recs = new Set(), works = new Set(); if (!re) return { recs, works }; const add = (tree, set) => { try { for (const e of W.MB.tree.iterate(tree)) { const w = Array.isArray(e) ? e[1] : e; if (w) set.add((w.gid || '') + '|' + w.id); } } catch (e) {} }; add(re.state.selectedRecordings, recs); add(re.state.selectedWorks, works); return { recs, works }; } // is a rel item on one of the selected recordings/works? function itemInSelection(item, sel) { const rel = relFromNode(item); if (!rel) return true; // unreadable → don't exclude for (const e of [rel.entity0, rel.entity1]) { if (!e) continue; const key = (e.gid || '') + '|' + e.id; if (e.entityType === 'recording' && sel.recs.has(key)) return true; if (e.entityType === 'work' && sel.works.has(key)) return true; } return false; } function onContextMenu(ev) { // #338 P2: right-click a recording's checkbox → copy/move its credits to the ticked recordings; // right-click a work checkbox → copy/move its work rels the same way const recCb = ev.target.closest && ev.target.closest('input.recording'); if (recCb) { const tr = recCb.closest('tr.track'); if (tr) { ev.preventDefault(); openCopyMenu(tr, ev.clientX, ev.clientY); } return; } const workCb = ev.target.closest && ev.target.closest('input.work'); if (workCb) { ev.preventDefault(); openWorkMenu(workCb, ev.clientX, ev.clientY); return; } // #399 right-click an entity NAME (or anywhere in a rel item that isn't an action control) → open that // rel's edit dialog by clicking its pencil. Runs FIRST (before the recording-of / copy menus below) so a // name always opens the dialog — previously a recording→work rel's work name fell to openRecOfMenu, and a // NEW work's name (no /entity/<gid> href) matched nothing at all. We key off the item + "not a button/ // checkbox" instead of the href, so it works for new entities too. The ×/pencil/+/checkbox still reach // their own handlers below (right-click the PENCIL for the recording-of copy menu; the name → dialog). const nameItem = ev.target.closest && !ev.target.closest('button, input') && ev.target.closest('.relationship-item'); if (nameItem) { const pencil = nameItem.querySelector('button.icon.edit-item'); if (pencil) { ev.preventDefault(); ev.stopPropagation(); try { pencil.click(); } catch (e) {} return; } } // #374 right-click a "recording of" rel (or its pencil) → copy its attributes + dates onto the selected // recordings' own recording-of rels (or all if none selected). Handled before the credit +/pencil below. const roItem = ev.target.closest && !ev.target.closest(REMOVE_SEL) && ev.target.closest('.relationship-item'); // not the × — that keeps its delete menu (#374 review) if (roItem) { const rr = relFromNode(roItem); if (rr && rr.entity0 && rr.entity0.entityType === 'recording' && rr.entity1 && rr.entity1.entityType === 'work') { ev.preventDefault(); openRecOfMenu(roItem, ev.clientX, ev.clientY); return; } } // #373 right-click the role-group "+" (add another) → copy scoped to that role's credits; right-click a // rel's pencil (edit) → copy scoped to just that one credit. Both reuse the recording copy menu. const addBtn = ev.target.closest && ev.target.closest('button.add-item.add-another-entity'); if (addBtn) { const tr = addBtn.closest('tr.track'), grp = addBtn.closest('tr'); if (grp) { ev.preventDefault(); const items = [...grp.querySelectorAll('.relationship-item')], set = new Set(items), rc = items.length ? relClass(items[0]) : null; if (rc && rc.kind === 'work') openWorkMenu(rc.work, ev.clientX, ev.clientY, rel => !!(rel.item && set.has(rel.item))); // #373 work role group → work copy else if (rc && rc.kind === 'rec' && tr) openCopyMenu(tr, ev.clientX, ev.clientY, rel => !!(rel.item && set.has(rel.item))); } return; } const editBtn = ev.target.closest && ev.target.closest('button.icon.edit-item'); if (editBtn) { const tr = editBtn.closest('tr.track'), item = editBtn.closest('.relationship-item'); if (item) { ev.preventDefault(); const rc = relClass(item); // #470 the pencil menu also carries "Replace role…" for this credit const repl = replaceRoleMenuItems(item); if (rc && rc.kind === 'work') openWorkMenu(rc.work, ev.clientX, ev.clientY, rel => rel.item === item, repl); // #373 work credit pencil → work copy, scoped else if (rc && rc.kind === 'rec' && tr) openCopyMenu(tr, ev.clientX, ev.clientY, rel => rel.item === item, repl); // recof pencil is handled above (#374 openRecOfMenu) where present; otherwise no recording menu } return; } const btn = ev.target.closest && ev.target.closest(REMOVE_SEL); if (!btn) return; // not a rel × — let the browser menu through ev.preventDefault(); const seedRow = btn.closest('tr'), seedItem = btn.closest('.relationship-item'); const roleLabel = pickRoleLabel(seedRow), tgt = targetLabel(seedItem); let roleItems = collect(btn, 'role'), tgtItems = collect(btn, 'target'), bothItems = collect(btn, 'role-and-target'); // scope the group removals to the selected recordings/works, if any are ticked (#338) const sel = selectionKeys(); let scopeNote = null; if (sel.recs.size || sel.works.size) { const keep = it => itemInSelection(it, sel); roleItems = roleItems.filter(keep); tgtItems = tgtItems.filter(keep); bothItems = bothItems.filter(keep); const parts = []; if (sel.recs.size) parts.push(`${sel.recs.size} recording${sel.recs.size > 1 ? 's' : ''}`); if (sel.works.size) parts.push(`${sel.works.size} work${sel.works.size > 1 ? 's' : ''}`); scopeNote = `scoped to ${parts.join(' + ')} selected`; } const trunc = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; const desc = (label, n) => `${label.replace(/^Remove\s+/, 'Removed ')} (${n})` + (scopeNote ? ` — ${scopeNote}` : ''); const opt = (label, its, scope) => ({ label, sub: String(its.length), lines: breakdown(its, scope), danger: true, run: () => runRemoval(its, desc(label, its.length)) }); const items = [ { label: `Remove this one`, run: () => { try { btn.click(); markUsed(`Removed ${roleLabel}${tgt ? ` — ${tgt}` : ''}`); } catch (e) {} } }, 'sep', ]; if (scopeNote) items.push({ note: scopeNote }); items.push( opt(`Remove ${trunc(roleLabel, 46)}`, roleItems, 'role'), opt(`Remove “${trunc(tgt, 46)}”`, tgtItems, 'target'), opt(`Remove ${trunc(roleLabel, 24)} + ${trunc(tgt, 24)}`, bothItems, 'role-and-target'), ); openMenu(ev.clientX, ev.clientY, items); } // ── hover highlight (page-wide) + count tooltip ─────────────────────────── // Hover an entity name or a role label → light up every matching occurrence on the page (CSS // Custom Highlight API), split into "already in MB" vs "newly added this session", and show a // tooltip: how many, and which tracks / the release it appears on. function needleFor(target) { if (!target || !target.closest) return null; // only the actual relationship (track + release credits) UI — not GT's own overlays, and not // stray entity links elsewhere on the page (release title, sidebar, the work-match dialog, …) if (target.closest('.gt-cons-ov, .gt-wm-pop, .gt-pop, .gt-menu, .gt-tip, #mbu-toast')) return null; const phraseTh = target.closest('th.link-phrase'); if (phraseTh && !target.closest('button')) { const l = phraseTh.querySelector('label'); if (l) { let t = (l.textContent || '').trim().replace(/:\s*$/, ''); if (t) return t; } } const link = target.closest('a[href]'); if (link && link.closest('.relationship-item') && /\/(artist|work|label|place|recording|series|release-group|event|instrument|area)\/[a-f0-9-]/.test(link.getAttribute('href') || '')) return (link.textContent || '').trim(); return null; } // newly-added rels get negative MB ids on their remove button; persisted ones are positive function isNewRow(node) { const item = node.parentNode && node.parentNode.closest ? node.parentNode.closest('.relationship-item') : null; if (!item) return false; const rm = item.querySelector('button.remove-item[id^="remove-relationship-"]'); if (!rm) return false; const segs = rm.id.split('-'), last = segs[segs.length - 1]; return (segs[segs.length - 2] === '' && /^\d+$/.test(last)) || /^-\d+$/.test(last); } const trackPosOf = (node, multi) => { const tr = node.parentNode && node.parentNode.closest ? node.parentNode.closest('tr.track') : null; return posLabel(tr, multi); }; // #623 (sweep): where `lower` occurs in `lt` as a whole word — hovering "Bob" used to light up // "Bobby" too. An edge of the needle that isn't a letter or digit needs no boundary there. function wordHits(lt, lower) { const w = c => !!c && /[\p{L}\p{N}]/u.test(c); const needStart = w(lower[0]), needEnd = w(lower[lower.length - 1]), out = []; let i = 0; while ((i = lt.indexOf(lower, i)) !== -1) { if (!(needStart && w(lt[i - 1])) && !(needEnd && w(lt[i + lower.length]))) { out.push(i); i += lower.length; } else i += 1; } return out; } function highlightPage(needle) { if (!needle || !window.CSS?.highlights || typeof Highlight === 'undefined') return { n: 0 }; const lower = needle.toLowerCase(); if (lower.length < 2) return { n: 0 }; const exist = [], neu = [], tracks = new Set(); let release = false, n = 0; // #379 count/highlight only within the relationship editor — not the (possibly hidden) edit log, edit // notes, sidebar, etc., which were inflating the tooltip counts. const root = document.querySelector('div.release-relationship-editor') || document.body; const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, { acceptNode(x) { const p = x.parentNode; if (!p) return NodeFilter.FILTER_REJECT; const t = p.tagName; return (t === 'STYLE' || t === 'SCRIPT' || t === 'NOSCRIPT' || t === 'TEXTAREA' || t === 'INPUT') ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT; } }); const multi = document.querySelectorAll('tr.subh').length > 1; // once, not per hit (#623) let node; while ((node = walker.nextNode())) { const lt = node.nodeValue.toLowerCase(); if (lt.length < lower.length) continue; const at = wordHits(lt, lower); if (!at.length) continue; const isNew = isNewRow(node); for (const i of at) { const r = document.createRange(); r.setStart(node, i); r.setEnd(node, i + lower.length); (isNew ? neu : exist).push(r); n++; } const p = trackPosOf(node, multi); if (p != null) tracks.add(p); else if (node.parentNode && node.parentNode.closest && node.parentNode.closest('.relationship-item')) release = true; } try { window.CSS.highlights.set('gt-hl-existing', new Highlight(...exist)); window.CSS.highlights.set('gt-hl-new', new Highlight(...neu)); } catch (e) {} return { n, tracks, release }; } function clearHighlight() { try { window.CSS.highlights?.delete('gt-hl-existing'); window.CSS.highlights?.delete('gt-hl-new'); } catch (e) {} } // compress [1,2,3,5] → "1–3, 5"; non-numeric positions (vinyl "D4","D5") are natural-sorted + joined function ranges(vals) { const a = [...vals]; if (a.length && a.every(v => /^\d+$/.test(String(v)))) { const nums = a.map(Number).sort((x, y) => x - y), out = []; let s = null, p = null; for (const v of nums) { if (s == null) { s = p = v; } else if (v === p + 1) { p = v; } else { out.push(s === p ? `${s}` : `${s}–${p}`); s = p = v; } } if (s != null) out.push(s === p ? `${s}` : `${s}–${p}`); return out.join(', '); } return a.map(String).sort((x, y) => x.localeCompare(y, undefined, { numeric: true })).join(', '); } let tipEl = null; function showTip(x, y, info, name) { if (!info || (!info.n && !name)) { hideTip(); return; } if (!tipEl) { tipEl = el('div', 'gt-tip'); document.body.appendChild(tipEl); } tipEl.innerHTML = ''; // we hid MB's native title (the entity's real/sort name, shown when Credited As differs) — // so surface it here instead so that info isn't lost. if (name) tipEl.appendChild(el('div', 'gt-tip-name', name)); const parts = []; if (info.n) { parts.push(`${info.n}×`); if (info.tracks && info.tracks.size) parts.push(`track${info.tracks.size > 1 ? 's' : ''} ${ranges(info.tracks)}`); if (info.release) parts.push('release'); } if (parts.length) tipEl.appendChild(el('div', 'gt-tip-stat', parts.join(' · '))); tipEl.style.display = ''; const r = tipEl.getBoundingClientRect(); tipEl.style.left = Math.min(x + 14, window.innerWidth - r.width - 8) + 'px'; tipEl.style.top = Math.min(y + 16, window.innerHeight - r.height - 8) + 'px'; } function hideTip() { if (tipEl) tipEl.style.display = 'none'; } // MB puts the entity's sort name in the link's `title`, so the browser's native tooltip stacks on // top of ours. Temporarily strip it while our count tooltip is up, and restore it on the way out. let _hidTitle = null; function suppressTitle(target) { const te = target.closest && target.closest('[title]'); if (!te || (_hidTitle && te === _hidTitle.el)) return; restoreTitle(); const v = te.getAttribute('title'); if (v == null) return; _hidTitle = { el: te, val: v }; te.removeAttribute('title'); } function restoreTitle() { if (_hidTitle) { try { _hidTitle.el.setAttribute('title', _hidTitle.val); } catch (e) {} _hidTitle = null; } } function onOver(ev) { const nd = needleFor(ev.target); if (!nd) return; suppressTitle(ev.target); const nm = (_hidTitle && _hidTitle.val && _hidTitle.val !== nd) ? _hidTitle.val : null; const info = highlightPage(nd); showTip(ev.clientX, ev.clientY, info, nm); } function onMove(ev) { if (tipEl && tipEl.style.display !== 'none' && needleFor(ev.target)) { tipEl.style.left = Math.min(ev.clientX + 14, window.innerWidth - tipEl.offsetWidth - 8) + 'px'; tipEl.style.top = Math.min(ev.clientY + 16, window.innerHeight - tipEl.offsetHeight - 8) + 'px'; } } function onOut(ev) { if (!needleFor(ev.target)) return; const rt = ev.relatedTarget; if (_hidTitle && rt && _hidTitle.el.contains && _hidTitle.el.contains(rt)) return; clearHighlight(); hideTip(); restoreTitle(); } // ── styles ──────────────────────────────────────────────────────────────── // 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. // <ST-TOKENS> — 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)}'; // </ST-TOKENS> // 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. // <ST-UI> — 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{width:auto;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;width: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(860px,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-hclear,.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min,.mbu-logpop-full{width:auto;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-hclear:hover,.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover,.mbu-logpop-full:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-log-f,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x,#mbu-logpop.min .mbu-logpop-full{display:none}#mbu-logpop.min{max-height:none;width:auto!important;height:auto!important}#mbu-logpop.sized{max-height:none}.mbu-logpop-grip{position:absolute;right:0;bottom:0;width:16px;height:16px;cursor:nwse-resize;opacity:.6;background:linear-gradient(135deg,transparent 55%,var(--mbu-border-strong) 55%,var(--mbu-border-strong) 62%,transparent 62%,transparent 75%,var(--mbu-border-strong) 75%,var(--mbu-border-strong) 82%,transparent 82%)}.mbu-logpop-grip:hover{opacity:1}#mbu-logpop.min .mbu-logpop-grip,#mbu-logpop.full .mbu-logpop-grip{display:none}#mbu-logpop.full{top:12px!important;left:12px!important;right:12px!important;bottom:12px!important;width:auto!important;height:auto!important;max-height:none;transform:none!important}#mbu-logpop.full .mbu-logpop-h{cursor:default}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-logemb{display:flex;flex-direction:column;height:100%;min-height:0;background:var(--mbu-bg);font:13px var(--mbu-font);color:var(--mbu-text)}.mbu-logemb .mbu-logpop-h{cursor:default;padding:7px 10px}.mbu-logemb .mbu-logpop-h>.mbu-log-q{flex-basis:160px;margin-left:0}.mbu-logemb .mbu-logpop-h>.mbu-log-ses{flex-basis:240px}.mbu-logpop-h{flex-wrap:nowrap}.mbu-logpop-h>*{flex-shrink:0;white-space:nowrap}.mbu-logpop-h>.mbu-log-q{flex-shrink:1;min-width:60px}.mbu-logpop-h>.mbu-log-ses{flex-shrink:1;min-width:90px}.mbu-logpop-h>.mbu-logpop-sp{flex:1 1 0;min-width:0}.mbu-logpop .mbu-log-ses{flex:0 1 260px;min-width:120px;max-width:100%;font:12px var(--mbu-font);padding:2px 4px;border:1px solid var(--mbu-border);border-radius:5px}.mbu-logpop-hclear:disabled{opacity:.5;cursor:default}.mbu-log-dbg{display:inline-flex;align-items:center;gap:4px;font-size:12px;color:var(--mbu-text);cursor:pointer;white-space:nowrap}.mbu-log-dbg input{margin:0}#mbu-logpop.min .mbu-log-dbg{display:none}.mbu-log-past .mbu-logpop-clear{display:none}#mbu-logpop.min .mbu-log-ses,#mbu-logpop.min .mbu-logpop-hclear{display:none}.mbu-logpop .mbu-log-q{flex:0 1 200px;min-width:80px;margin-left:6px;font:12px var(--mbu-font);padding:2px 8px;border:1px solid var(--mbu-border);border-radius:5px;cursor:text}#mbu-logpop.min .mbu-log-q{display:none}.mbu-logpop .mbu-log-li.mbu-log-nq{display:none}.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-pre .mbu-log-m{white-space:pre;font:12px/1.35 ui-monospace,Consolas,monospace;word-break:normal}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-c{color:var(--mbu-text-weak);flex:0 0 auto}.mbu-log-list:not(.mbu-log-cats) .mbu-log-c{display:none}.mbu-log-list.mbu-log-cats{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:3px 9px;align-content:start}.mbu-log-cats .mbu-log-li{display:contents}.mbu-log-cats .mbu-log-empty{grid-column:1/-1}.mbu-log-f{display:flex;flex-wrap:wrap;align-items:center;gap:2px 4px;padding:5px 13px;border-bottom:1px solid var(--mbu-border-soft);font-size:11px}.mbu-log-f[hidden]{display:none}.mbu-log-fg{display:contents}.mbu-log-fs{width:1px;height:12px;background:var(--mbu-border);margin:0 6px}.mbu-logpop .mbu-log-fb{width:auto;font:inherit;color:var(--mbu-text-weak);background:none;border:1px solid transparent;border-radius:5px;padding:0 6px;line-height:1.6;cursor:pointer}.mbu-logpop .mbu-log-fb:hover{color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb.on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-border)}.mbu-logpop .mbu-log-fb[data-sev=error]{color:var(--mbu-error)}.mbu-logpop .mbu-log-fb[data-sev=warn]{color:var(--mbu-warn)}.mbu-logpop .mbu-log-fb[data-sev=info]{color:var(--mbu-text-dim)}.mbu-logpop .mbu-log-fb[data-sev].on{background:var(--mbu-bg-hover);border-color:currentColor;font-weight:600}.mbu-logpop .mbu-log-fb[data-cat]{border-color:var(--mbu-border-soft);background:var(--mbu-bg-raised);border-radius:999px;padding:0 8px}.mbu-logpop .mbu-log-fb[data-cat].on{color:var(--mbu-accent-text);background:var(--mbu-accent-soft);border-color:var(--mbu-accent)}.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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,.mbu-logemb,.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 '<a class="mbu-help" href="' + mbuHelpHref(name) + '" target="_blank" rel="noopener"' + ' title="open the README in a new tab">' + (label || '? Help') + '</a>'; } 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) // On MusicBrainz it ends with the interface language, which changes what a script reads // off the page: "· MusicBrainz in de". 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 */ } } try { if (/(^|\.)musicbrainz\.(org|eu)$/i.test(location.hostname)) { // <html lang>, or MB's lang cookie when the page isn't parsed yet (document-start) var lang = (document.documentElement && document.documentElement.lang) || ((document.cookie.match(/(?:^|;\s*)lang=([^;]+)/) || [])[1]) || 'en'; line += ' · MusicBrainz in ' + lang; } } catch (e) { /* no document */ } 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 <html>, 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:'<svg…>' | '<img…>', 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 = '<div class="mbu-cfg-h">'; if (o.icon) html += '<span class="mbu-cfg-ic">' + o.icon + '</span>'; html += '<span class="mbu-cfg-name">' + esc(o.name) + '</span>'; if (o.version) html += '<span class="mbu-cfg-ver" title="installed script version">v' + esc(o.version) + '</span>'; html += '<span class="mbu-cfg-sp"></span>'; if (o.log) { html += '<button type="button" class="mbu-cfg-log' + (o.logClass ? ' ' + esc(o.logClass) : '') + '"' + (o.logId ? ' id="' + esc(o.logId) + '"' : '') + ' title="Open the activity log">Log</button>'; } html += mbuHelpHtml(o.script); return html + '</div>'; } // 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; } } // Shared settings: the few that belong to all the scripts at once, not to one (the corner // launchers' layout). GM storage is private to each script, so these live in the page's // localStorage on MusicBrainz's origin, as JSON under 'mbu.<key>'; every script, bundled or // standalone, reads the same value. One argument reads (undefined when unset or blocked), two // write, and null removes. Per-origin: musicbrainz.org, beta and test each keep their own. // Every key is listed in DEVELOP.md → Shared storage. function mbuShared(key, value) { var k = 'mbu.' + key; try { if (arguments.length < 2) { var v = localStorage.getItem(k); return v == null ? undefined : JSON.parse(v); } if (value == null) localStorage.removeItem(k); else localStorage.setItem(k, JSON.stringify(value)); } catch (e) { /* storage blocked */ } return value; } // 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, all bottom-right: Apollo and Art Station 10 (never on the same page), Scribe 15 // (above Apollo on the edit page), Falcon 20, Fusion 30, Mission Control 40. Scribe is not on the shared block and keeps a copy of this. // The stack is a column; the shared setting cornerFlow = 'row' (mbuShared, set from String // Theory's menu) lines it up along the bottom/top edge instead, the lowest order still nearest the corner. function mbRestackCorner(corner) { var bottom = corner[0] === 'b', right = corner[1] === 'r'; var row = mbuShared('cornerFlow') === 'row'; 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) { var r = el.getBoundingClientRect(); el.style[bottom ? 'bottom' : 'top'] = (row ? 14 : pos) + 'px'; el.style[right ? 'right' : 'left'] = (row ? pos : 14) + 'px'; pos += (row ? r.width : r.height) + 8; }); } // Activity log: the session's log lines plus the viewers that show 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() // LOG.cat('Spotify').info(…) // the same calls, each line tagged with a category // LOG.pre(text) // a block kept as written: line breaks, spacing, monospace (#705) // LOG.mount(el, { tools }) // the viewer inside the script's own panel (#705) // // Categories (#697) are optional. A tagged line shows its category before the // message and in the Markdown ("WARN [Spotify] …"). The viewer grows a row of // filters only when there is a choice: the levels once two of them have lines // (ok counts as info), the categories once there are two. Click one to see only // those lines, click it again to see all; Copy always takes everything. // // The floating window (open) and a mounted viewer (mount) are the same viewer; // mount draws it into an element of the script's, without the title bar's // window buttons, and o.tools (an element) sits in its toolbar before Clear. // Every viewer has a debug switch: off, LOG.debug() lines are not recorded at all // (remembered per script under o.key; LOG.debugOn(), LOG.setDebug(v)). // // 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) // o.history opt-in history of past sessions (#705), below // // 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 // viewer 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. // // History (#705, Falcon's run logs of #512 made shared). Off unless o.history is given: // // o.history = { prefix: 'falcon:session:', keep: () => 20, lines: 400, head: 140, // parse: s => entry, nameOf: entries => name, store } // // A session starts with the page, or with LOG.session(reason) (Falcon: each run). // Only a session the script marks with LOG.keep() is stored, so a page that did // nothing worth reading back leaves nothing behind; LOG.name(text) names it (the // first name stays). It is stored in the page's localStorage under prefix + id, // within 100 ms of each line and at once on LOG.flush(), so a crashed tab keeps // its log; a session longer than `lines` keeps its first `head` lines and its // end, and says how many went between. The newest `keep` sessions are kept. // prefix + 'current' holds the last kept session's id (LOG.last()), so LOG.resume(id) // can carry on a session across a navigation. The viewer gets a session list and Clear history. // LOG.sessionId(); LOG.last(); LOG.isKept(); LOG.refresh(); LOG.sessions(); LOG.load(id); LOG.forget(id); LOG.clearHistory(); LOG.flush() // parse reads a stored line that is a string (an older format); nameOf names a // session stored without a name; store ({ get, set, del, keys }) replaces localStorage. function mbuLog(o) { o = o || {}; var max = o.max || 20000, buf = [], dropped = 0, warn = 0, error = 0, views = [], bound = {}; var LEVELS = ['error', 'warn', 'info', 'debug']; var group = function (sev) { return sev === 'ok' ? 'info' : sev; }; var pad = function (n, w) { return String(n).padStart(w || 2, '0'); }; var ts = function (d) { return d ? 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 '<a href="' + url + '" target="_blank" rel="noopener">' + url + '</a>' + 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 */ } }; // debug lines are recorded only while the viewer's debug switch is on (default off: majkinetor, #705), per script var dbgOn = null; var debugOn = function () { if (dbgOn === null) dbgOn = state().debug === true; return dbgOn; }; var setDebug = function (v) { dbgOn = !!v; remember({ debug: dbgOn }); views.forEach(function (w) { if (w.dbg) w.dbg.checked = dbgOn; }); }; 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.cat ? '[' + e.cat + '] ' : '') + (e.pre ? '\n' : '') + e.msg; }; var countOf = function (list) { var c = { warn: 0, error: 0 }; list.forEach(function (e) { if (e.sev === 'warn') c.warn++; else if (e.sev === 'error') c.error++; }); return c; }; // ── history (#705) ── var H = o.history || null, hp = H && (H.prefix || ((o.key || 'mbu') + ':session:')); var ID_RE = /^\d{14}-\d+$/, sid = '', kept = false, sname = '', savedName = '', seq = 0, pTimer = 0, lastSaved = ''; var store = H && (H.store || { get: function (k) { try { return localStorage.getItem(k); } catch (e) { return null; } }, set: function (k, v) { try { localStorage.setItem(k, v); } catch (e) { /* full or blocked: the session just isn't kept */ } }, del: function (k) { try { localStorage.removeItem(k); } catch (e) { /* nothing to do */ } }, // one call for all the keys: a userscript reaches localStorage through Firefox's // Xray wrapper, where each call costs, and the page may hold hundreds of keys keys: function () { try { var all = Object.keys(localStorage); if (all.length === localStorage.length) return all; } catch (e) { /* the loop below */ } var out = []; try { for (var i = 0; i < localStorage.length; i++) out.push(localStorage.key(i)); } catch (e) { /* none */ } return out; }, }); var keepN = function () { var n = Number(typeof H.keep === 'function' ? H.keep() : H.keep); return n > 0 ? n : 10; }; var newId = function () { return new Date().toISOString().replace(/[^0-9]/g, '').slice(0, 14) + '-' + (++seq); }; // the id is the session's start in UTC: YYYYMMDDHHMMSS-n var idDate = function (id) { var m = /^(\d{4})(\d\d)(\d\d)(\d\d)(\d\d)(\d\d)/.exec(id || ''); return m ? new Date(Date.UTC(+m[1], m[2] - 1, +m[3], +m[4], +m[5], +m[6])) : null; }; function ids() { if (!H) return []; return store.keys().filter(function (k) { return k && k.indexOf(hp) === 0 && ID_RE.test(k.slice(hp.length)); }) .map(function (k) { return k.slice(hp.length); }).sort(); // the id's own timestamp sorts as a string } // a stored line: [time ms | null, sev, cat | 0, msg, pre 1/0]; a string is an older format, read by H.parse function fromStored(x, id) { if (typeof x === 'string') { var p = null; try { p = H.parse ? H.parse(x, idDate(id)) : null; } catch (e) { /* unreadable: kept as text */ } return p ? { t: p.t || null, sev: p.sev || 'info', cat: p.cat || '', msg: String(p.msg == null ? x : p.msg), pre: !!p.pre } : { t: null, sev: 'info', msg: x }; } if (!Array.isArray(x)) return null; var e = { t: x[0] != null ? new Date(x[0]) : null, sev: x[1] || 'info', msg: String(x[3] == null ? '' : x[3]) }; if (x[2]) e.cat = String(x[2]); if (x[4]) e.pre = true; return e; } function loadSession(id) { if (!H || !id) return null; var raw = store.get(hp + id); if (raw == null) return null; try { return (JSON.parse(raw) || []).map(function (x) { return fromStored(x, id); }).filter(Boolean); } catch (e) { return null; } } // a past session's name doesn't change, so it is read (or mined from its lines) once var names = {}; function nameOf(id, entries) { if (id in names) return names[id]; var n = store.get(hp + id + ':name'); if (!n) { try { n = H.nameOf ? H.nameOf(entries || loadSession(id) || []) || '' : ''; } catch (e) { n = ''; } } return (names[id] = n || ''); } // the first `head` lines and the end, and a line saying how many went between function storedWindow() { var cap = H.lines || 400, head = Math.min(H.head || 140, cap - 2); var pack = function (e) { return [e.t ? e.t.getTime() : null, e.sev, e.cat || 0, e.msg, e.pre ? 1 : 0]; }; if (buf.length <= cap) return buf.map(pack); var tail = cap - head - 1, cut = buf.length - head - tail; return buf.slice(0, head).map(pack) .concat([[null, 'info', 0, '——— ' + cut + ' line(s) from the middle of this session were dropped to fit the stored-log budget; its start and end are kept in full ———', 0]]) .concat(buf.slice(-tail).map(pack)); } function flush() { clearTimeout(pTimer); pTimer = 0; if (!H || !sid || !kept) return; try { var payload = JSON.stringify(storedWindow()); if (payload === lastSaved) return; lastSaved = payload; store.set(hp + sid, payload); if (sname && savedName !== sname) { store.set(hp + sid + ':name', sname); savedName = sname; } } catch (e) { /* not stored */ } } var persist = function () { if (H && kept && !pTimer) pTimer = setTimeout(flush, 100); }; function forget(id) { if (H && id) { store.del(hp + id); store.del(hp + id + ':name'); delete names[id]; } } function prune() { var all = ids().filter(function (id) { return id !== sid; }); var excess = all.length + (kept ? 1 : 0) - keepN(); if (excess > 0) all.slice(0, excess).forEach(forget); } // past sessions, newest first, without the current one function sessions() { return ids().filter(function (id) { return id !== sid; }).reverse() .map(function (id) { return { id: id, date: idDate(id), name: nameOf(id) }; }); } var sessionLabel = function (s) { var d = s.date, when = d ? d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) : s.id; return when + (s.name ? ' — ' + s.name : ''); }; function startSession() { flush(); sid = newId(); kept = false; sname = ''; savedName = ''; lastSaved = ''; reset(); } function resume(id) { var entries = loadSession(id); if (!entries) return false; flush(); reset(); sid = id; kept = true; sname = savedName = store.get(hp + id + ':name') || ''; lastSaved = store.get(hp + id) || ''; store.set(hp + 'current', sid); entries.forEach(function (e) { push(e); }); return true; } // ── the live lines ── function reset() { buf = []; dropped = 0; warn = 0; error = 0; views.forEach(function (v) { if (!v.past) v.show(null); v.sessions(); }); } function push(e) { 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; } views = views.filter(function (v) { return v.el.isConnected; }); views.forEach(function (v) { if (!v.past) v.append(e); }); } function add(sev, args, cat, pre) { var msg = Array.prototype.map.call(args, str).join(' '); msg = pre ? msg.replace(/\s+$/, '') : msg.replace(/\s+/g, ' ').trim(); if (!msg) return; var e = { t: new Date(), sev: sev === 'err' ? 'error' : sev, msg: msg }; if (e.sev === 'debug' && !debugOn()) return; if (cat) e.cat = String(cat); if (pre) e.pre = true; push(e); persist(); } function title(bare) { 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 = !bare && o.subtitle && o.subtitle(); if (s) t += ' — ' + s; } catch (e) { /* no subtitle */ } return t; } // past: { id, entries } to copy a stored session instead of this one function markdown(past) { var list = past ? past.entries : buf, c = past ? countOf(list) : { warn: warn, error: error }; var body = list.length ? list.map(line).join('\n') : '(no activity logged)'; if (!past && dropped) body = '(' + dropped + ' earlier line' + (dropped === 1 ? '' : 's') + ' not kept)\n' + body; var n = (c.warn || c.error) ? ' (' + c.warn + ' warning' + (c.warn === 1 ? '' : 's') + ', ' + c.error + ' error' + (c.error === 1 ? '' : 's') + ')' : ''; var what = past ? 'log of ' + sessionLabel({ id: past.id, date: idDate(past.id), name: nameOf(past.id, list) }) : 'session log'; var fence = String.fromCharCode(96, 96, 96); return '<details><summary>' + title(!!past) + ' — ' + what + n + '</summary>\n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n</details>'; } function copy(btn, past) { var md = markdown(past); 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(); } } // ── a viewer: the floating window or one mounted in the script's panel ── var vseq = 0; function view(root, floating, tools) { var vid = String(++vseq); root.classList.add('mbu-logpop'); root.dataset.mbuLv = vid; root.innerHTML = mbuHtml('<div class="mbu-logpop-h">' + (floating ? '<b>' + esc(o.header || 'Activity log') + '</b> ' : '') + '<span class="mbu-log-badge"></span>' + '<input class="mbu-log-q" type="search" placeholder="Filter" title="Show only the lines with this text" autocomplete="off">' + (H ? '<select class="mbu-log-ses" title="The session shown: this one, or a past one kept in this browser"></select>' : '') + '<span class="mbu-logpop-sp"></span>' + (H ? '<button class="mbu-logpop-hclear" type="button" title="Delete every past session (this one stays)">Clear history</button>' : '') + '<label class="mbu-log-dbg" title="Record each step in detail. Leave it on when reporting a problem"><input type="checkbox"> debug</label>' + '<button class="mbu-logpop-clear" type="button" title="Clear the log (the lines so far are gone)">Clear</button>' + '<button class="mbu-logpop-copy" type="button" title="Copy as Markdown (paste into a GitHub issue)">⧉ Copy</button>' + (floating ? '<button class="mbu-logpop-full" type="button" title="Full screen (or double-click the title bar)">⛶</button>' + '<button class="mbu-logpop-min" type="button" title="Minimize">–</button>' + '<button class="mbu-logpop-x" type="button" title="Close">✕</button>' : '') + '</div>' + '<div class="mbu-log-f" hidden></div><style class="mbu-log-fcss"></style>' + '<div class="mbu-log-list"></div>' + (floating ? '<div class="mbu-logpop-grip" title="Resize"></div>' : '')); if (tools) root.querySelector('.mbu-logpop-clear').before(tools); var list = root.querySelector('.mbu-log-list'), badge = root.querySelector('.mbu-log-badge'); var qEl = root.querySelector('.mbu-log-q'), q = '', sel = root.querySelector('.mbu-log-ses'); var fRow = root.querySelector('.mbu-log-f'), fCss = root.querySelector('.mbu-log-fcss'); // what this viewer shows: the live lines, or a past session (read-only) var past = null, src = buf, cats = [], catIx = {}, sevs = {}, fSev = null, fCat = null; var scope = '.mbu-logpop[data-mbu-lv="' + vid + '"] '; var miss = function (e) { return !!q && ((e.cat || '') + ' ' + e.msg).toLowerCase().indexOf(q) < 0; }; // note a line's level and category; true when the filter row must change var note = function (e) { var grew = false; if (e.cat && !(e.cat in catIx)) { catIx[e.cat] = cats.length; cats.push(e.cat); grew = true; } if (!sevs[group(e.sev)]) { sevs[group(e.sev)] = true; grew = true; } return grew; }; var row = function (e) { var d = document.createElement('div'); d._e = e; d.className = 'mbu-log-li mbu-log-' + e.sev + (e.pre ? ' mbu-log-pre' : ''); if (miss(e)) d.classList.add('mbu-log-nq'); d.dataset.s = group(e.sev); if (e.cat) d.dataset.c = catIx[e.cat]; d.innerHTML = mbuHtml('<span class="mbu-log-t">' + ts(e.t) + '</span>' + '<span class="mbu-log-c">' + esc(e.cat || '') + '</span>' + '<span class="mbu-log-m">' + linkify(e.msg) + '</span>'); return d; }; // the filter row, redrawn when a level or category first appears; hiding is one // rule in the viewer's own <style>, not a walk over the rows var filters = function () { var lv = LEVELS.filter(function (l) { return sevs[l]; }); var showL = lv.length > 1, showC = cats.length > 1; if (fSev && !sevs[fSev]) fSev = null; if (fCat != null && !(fCat in catIx)) fCat = null; var b = function (kind, v, on) { return '<button type="button" class="mbu-log-fb' + (on ? ' on' : '') + '" data-' + kind + '="' + esc(v) + '">' + esc(v) + '</button>'; }; fRow.innerHTML = mbuHtml((showL ? '<span class="mbu-log-fg">' + lv.map(function (l) { return b('sev', l, fSev === l); }).join('') + '</span>' : '') + (showL && showC ? '<span class="mbu-log-fs"></span>' : '') + (showC ? '<span class="mbu-log-fg">' + cats.map(function (c) { return b('cat', c, fCat === c); }).join('') + '</span>' : '')); fRow.hidden = !showL && !showC; list.classList.toggle('mbu-log-cats', cats.length > 0); fCss.textContent = (fSev ? scope + '.mbu-log-li:not([data-s="' + fSev + '"]){display:none}' : '') + (fCat != null ? scope + '.mbu-log-li:not([data-c="' + catIx[fCat] + '"]){display:none}' : ''); }; var showBadge = function () { var c = past ? countOf(src) : { warn: warn, error: error }; badge.textContent = '(' + src.length + ')' + (c.warn || c.error ? ' · ' + c.warn + '⚠ ' + c.error + '✖' : ''); }; var empty = function () { return '<div class="mbu-log-empty">' + (past ? 'Nothing was kept of this session.' : 'No activity yet.') + '</div>'; }; var queued = false, follow = true; var paint = function () { queued = false; showBadge(); if (follow) list.scrollTop = list.scrollHeight; }; // all rows at once: on opening, and when the session shown changes var draw = function () { cats = []; catIx = {}; sevs = {}; src.forEach(note); var frag = document.createDocumentFragment(); src.forEach(function (e) { frag.appendChild(row(e)); }); list.innerHTML = mbuHtml(src.length ? '' : empty()); if (src.length) list.appendChild(frag); filters(); showBadge(); follow = true; list.scrollTop = list.scrollHeight; root.classList.toggle('mbu-log-past', !!past); }; list.addEventListener('scroll', function () { follow = list.scrollHeight - list.scrollTop - list.clientHeight < 40; }); var qTimer = 0; var refilter = function () { q = qEl.value.trim().toLowerCase(); for (var d = list.firstElementChild; d; d = d.nextElementSibling) if (d._e) d.classList.toggle('mbu-log-nq', miss(d._e)); follow = true; list.scrollTop = list.scrollHeight; }; qEl.addEventListener('input', function () { clearTimeout(qTimer); qTimer = setTimeout(refilter, 120); }); fRow.addEventListener('click', function (ev) { var t = ev.target.closest('.mbu-log-fb'); if (!t) return; if (t.dataset.sev) fSev = fSev === t.dataset.sev ? null : t.dataset.sev; else fCat = fCat === t.dataset.cat ? null : t.dataset.cat; filters(); follow = true; list.scrollTop = list.scrollHeight; }); var v = { el: root, floating: floating, past: null, q: qEl, append: function (e) { var em = list.querySelector('.mbu-log-empty'); if (em) em.remove(); if (note(e)) filters(); list.appendChild(row(e)); while (list.childElementCount > buf.length) list.firstElementChild.remove(); if (!queued) { queued = true; requestAnimationFrame(paint); } }, // null: the live lines; an id: that past session show: function (id) { var entries = id ? loadSession(id) : null; if (id && !entries) entries = [{ t: null, sev: 'warn', msg: 'This session could not be read (it may have been deleted in another tab).' }]; past = id && entries ? id : null; v.past = past; src = past ? entries : buf; if (sel && sel.value !== (past || '')) sel.value = past || ''; draw(); }, // the session list, refreshed when it is opened and when a session starts sessions: function () { if (!sel) return; var all = sessions(), sig = all.map(function (x) { return x.id + '=' + x.name; }).join('|'); if (sig === v.sig && !(past && !all.some(function (x) { return x.id === past; }))) return; v.sig = sig; if (past && !all.some(function (s) { return s.id === past; })) { past = null; v.past = null; src = buf; draw(); } sel.innerHTML = mbuHtml('<option value="">Current session</option>' + all.map(function (s) { return '<option value="' + esc(s.id) + '">' + esc(sessionLabel(s)) + '</option>'; }).join('')); sel.value = past || ''; var hc = root.querySelector('.mbu-logpop-hclear'); if (hc) hc.disabled = !all.length; }, filters: filters, }; if (sel) { sel.addEventListener('change', function () { v.show(sel.value || null); }); root.querySelector('.mbu-logpop-hclear').onclick = function () { clearHistory(); }; } root.querySelector('.mbu-logpop-clear').onclick = function () { clear(); }; v.dbg = root.querySelector('.mbu-log-dbg input'); v.dbg.checked = debugOn(); v.dbg.onchange = function () { setDebug(v.dbg.checked); }; root.querySelector('.mbu-logpop-copy').onclick = function () { copy(root.querySelector('.mbu-logpop-copy'), past ? { id: past, entries: src } : null); }; v.sessions(); draw(); views.push(v); return v; } var win = null; 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'; document.body.appendChild(pop); var v = view(pop, true); 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 }; // Escape in a filled filter empties it; anywhere else it closes the window. Only the log: // it listens first (capture, on window) and keeps the key, so the script's own window under // it (ISRC Scout's closes on Escape too) stays open var onKey = function (e) { if (e.key !== 'Escape') return; e.stopPropagation(); if (e.target === v.q && v.q.value) { v.q.value = ''; v.q.dispatchEvent(new Event('input')); return; } close(); }; win = { el: pop, v: v, off: function () { window.removeEventListener('keydown', onKey, true); } }; 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); }; var fullBtn = pop.querySelector('.mbu-logpop-full'); var setFull = function (f) { pop.classList.toggle('full', f); fullBtn.textContent = f ? '❐' : '⛶'; fullBtn.title = f ? 'Restore the size (or double-click the title bar)' : 'Full screen (or double-click the title bar)'; }; minBtn.onclick = function () { var m = pop.classList.toggle('min'); if (m && pop.classList.contains('full')) { setFull(false); remember({ full: false }); } // minimising leaves full screen setMin(m); remember({ min: m }); }; fullBtn.onclick = function () { var f = !pop.classList.contains('full'); setFull(f); remember({ full: f }); }; pop.querySelector('.mbu-logpop-h').addEventListener('dblclick', function (e) { if (!e.target.closest('button, input, select') && !pop.classList.contains('min')) fullBtn.click(); }); // a size the user gave it, from the corner grip if (st.w && st.h) { pop.style.width = st.w; pop.style.height = st.h; pop.classList.add('sized'); } if (st.min) { pop.classList.add('min'); setMin(true); } else if (st.full) setFull(true); pop.querySelector('.mbu-logpop-grip').addEventListener('mousedown', function (e) { e.preventDefault(); e.stopPropagation(); var r = pop.getBoundingClientRect(); // pin the top-left corner, so only the right and bottom edges move pop.style.left = r.left + 'px'; pop.style.top = r.top + 'px'; pop.style.right = 'auto'; pop.style.bottom = ''; pop.style.transform = 'none'; pop.classList.add('sized'); var x0 = e.clientX, y0 = e.clientY; var mv = function (ev) { pop.style.width = Math.max(320, Math.min(window.innerWidth - r.left, r.width + ev.clientX - x0)) + 'px'; pop.style.height = Math.max(120, Math.min(window.innerHeight - r.top, r.height + ev.clientY - y0)) + 'px'; }; var up = function () { document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up); restore = { left: pop.style.left, top: pop.style.top, right: 'auto', bottom: '', transform: 'none' }; remember({ left: pop.style.left, top: pop.style.top, w: pop.style.width, h: pop.style.height }); }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up); }); 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, input, select') || pop.classList.contains('full')) 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); }); window.addEventListener('keydown', onKey, true); return pop; } // the viewer inside an element of the script's; a second mount there replaces the first function mount(host, opts) { if (!host) return null; views = views.filter(function (v) { if (v.el.parentNode === host) { v.el.remove(); return false; } return true; }); var el = document.createElement('div'); el.className = 'mbu-logemb'; host.appendChild(el); return view(el, false, opts && opts.tools).el; } // empty the live log: the lines, the counts and the "earlier lines not kept" note function clear() { buf = []; dropped = 0; warn = 0; error = 0; if (H && sid) { store.del(hp + sid); lastSaved = ''; } views.forEach(function (v) { if (!v.past) v.show(null); }); } function clearHistory() { if (!H) return; ids().forEach(function (id) { if (id !== sid) forget(id); }); views.forEach(function (v) { v.sessions(); }); } // 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(); views = views.filter(function (v) { return v !== win.v; }); 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)); }, // the debug switch: are debug lines recorded? debugOn: function () { return debugOn(); }, setDebug: setDebug, pre: function () { add('info', arguments, null, true); }, // a logger whose lines carry this category; the same object for the same name cat: function (name) { if (!name) return api; if (!bound[name]) bound[name] = { info: function () { add('info', arguments, name); }, warn: function () { add('warn', arguments, name); }, err: function () { add('error', arguments, name); }, error: function () { add('error', arguments, name); }, ok: function () { add('ok', arguments, name); }, debug: function () { add('debug', arguments, name); }, add: function (sev) { add(sev, Array.prototype.slice.call(arguments, 1), name); }, pre: function () { add('info', arguments, name, true); }, }; return bound[name]; }, open: open, mount: mount, close: function () { close(); }, reopen: function () { if (state().open) open(); }, isOpen: function () { return !!win; }, markdown: function () { return markdown(); }, copy: function (btn) { copy(btn); }, clear: clear, lines: function () { return buf.map(line); }, entries: function () { return buf.slice(); }, messages: function () { return buf.map(function (e) { return e.msg; }); }, counts: function () { return { warn: warn, error: error }; }, // history (#705); harmless without o.history session: function () { if (H) startSession(); return sid; }, resume: function (id) { return H ? resume(id) : false; }, keep: function () { if (H && sid && !kept) { kept = true; store.set(hp + 'current', sid); flush(); prune(); views.forEach(function (v) { v.sessions(); }); } }, name: function (text) { if (H && text && !sname) { sname = String(text); if (kept) store.set(hp + sid + ':name', sname); } }, sessionId: function () { return sid; }, last: function () { return H ? store.get(hp + 'current') : null; }, isKept: function () { return kept; }, sessions: function () { return H ? sessions() : []; }, load: function (id) { return loadSession(id); }, forget: function (id) { forget(id); views.forEach(function (v) { v.sessions(); }); }, clearHistory: clearHistory, // redraw the session lists (a script showing its mounted viewer again); only a changed list is redrawn refresh: function () { views.forEach(function (v) { v.sessions(); }); }, flush: flush, }; if (H) sid = newId(); 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 <body>: a permanent stray node under <html>, 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 <html>. mbuTheme re-runs whenever // the root or body class changes, Mammoth watches the whole document // for childList changes and reacts by toggling classes on <html>, 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 <body> 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 <head> and <body> 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 */ } // </ST-UI> function injectStyle() { const s = el('style'); s.textContent = MBU_TOKENS + MBU_UI_CSS + ` .gt-menu{position:fixed;z-index:2147483647;min-width:260px;max-width:600px;max-height:74vh;overflow-y:auto;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:7px; box-shadow:0 8px 26px rgba(0,0,0,.18);padding:4px;font:13px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text);user-select:none} .gt-mi .gt-mi-lines{margin:3px 0 1px 4px} .gt-mi .gt-mi-ln{display:flex;gap:6px;font-size:11px;color:var(--mbu-text-dim);line-height:1.4} .gt-mi .gt-mi-pos{flex:none;color:var(--mbu-info);min-width:24px} .gt-mi .gt-mi-tx{flex:1;white-space:normal;word-break:break-word} .gt-mi .gt-mi-more{color:var(--mbu-info);font-style:italic} .gt-menu .gt-sep{height:1px;background:var(--mbu-bg-sunken);margin:4px 2px} .gt-menu .gt-mi-input{padding:3px 9px 6px} .gt-menu .gt-mi-in{width:100%;box-sizing:border-box;font:12px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text); background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:5px;padding:3px 7px} .gt-menu .gt-mi-in:focus{border-color:var(--mbu-info);outline:none} .gt-menu .gt-mi-ininfo{font-size:11px;margin-top:3px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .gt-menu .gt-hdr{padding:5px 9px 4px;font-size:11px;font-weight:700;letter-spacing:.02em;color:var(--mbu-text-dim);text-transform:uppercase} .gt-menu .gt-note{padding:0 9px 6px;font-size:11px;color:var(--mbu-text-weak);white-space:normal;word-break:break-word} .gt-menu .gt-ck-list{margin:2px 0 3px} .gt-menu .gt-ck{display:flex;align-items:flex-start;gap:7px;padding:3px 9px;font-size:11px;color:var(--mbu-text-dim);line-height:1.4;cursor:pointer;user-select:none} .gt-menu .gt-ck:hover{background:var(--mbu-info-bg)} /* #377 per-row [A]/[R] select-by-artist/role buttons, shown on row hover */ .gt-menu .gt-ck-acts{margin-left:auto;display:none;gap:3px;flex:none;align-self:center} .gt-menu .gt-ck:hover .gt-ck-acts{display:inline-flex} .gt-menu .gt-ck-act{font:bold 10px Arial;color:var(--mbu-info);background:var(--mbu-info-bg);border:1px solid var(--mbu-info);border-radius:3px;padding:1px 6px;cursor:pointer;line-height:1.4} .gt-menu .gt-ck-act:hover{background:var(--mbu-info-bg);border-color:var(--mbu-info)} .gt-menu .gt-ck-cb{margin:1px 0 0;flex:none;accent-color:#2e9e5b;cursor:pointer} .gt-menu .gt-ck-pos{flex:none;color:var(--mbu-info)} .gt-menu .gt-ck-tx{flex:1;white-space:normal;word-break:break-word} .gt-mi{display:block;width:100%;box-sizing:border-box;background:none;border:none;text-align:left; padding:6px 9px;border-radius:5px;cursor:pointer;color:inherit;font:inherit} .gt-mi:hover{background:var(--mbu-info-bg)} .gt-mi .gt-mi-top{display:flex;align-items:center;gap:10px} .gt-mi .gt-mi-l{flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .gt-mi .gt-mi-d{font-size:11px;color:var(--mbu-text-weak);margin-top:2px} .gt-mi .gt-mi-s{flex:none;min-width:20px;text-align:center;font-weight:700;font-size:11px;color:var(--mbu-text-dim);background:var(--mbu-info-bg);border-radius:9px;padding:1px 7px} .gt-mi.gt-danger:hover{background:var(--mbu-error-bg)} .gt-mi.gt-danger .gt-mi-s{color:var(--mbu-text-on-accent);background:#c0392b} /* #564 (majkinetor, light theme): "black on blue name highlight". The two highlights shared one blue background and were told apart by their TEXT colour, and --mbu-warn on that blue computes to about 1.1:1 — the amber and the blue have nearly the same luminance, so the name vanished into its own highlight. (Custom-highlight styling can't be read back with getComputedStyle, which is why it survived: it has to be measured off rendered pixels, in both engines — see test/verify-564-highlight-readable.mjs.) The distinction now lives in the BACKGROUND, with white text on both. Literal colours, not tokens: the pair has to stay legible on its own terms and neither background follows the page theme. */ ::highlight(gt-hl-existing){background:#1f6feb;color:#fff} ::highlight(gt-hl-new){background:#b45309;color:#fff} .gt-tip{position:fixed;z-index:2147483647;pointer-events:none;background:#1b2430;color:#eef2f7; font:12px -apple-system,Segoe UI,Arial,sans-serif;padding:4px 9px;border-radius:5px;box-shadow:0 3px 12px rgba(0,0,0,.28);white-space:nowrap} .gt-tip .gt-tip-name{font-weight:600} .gt-tip .gt-tip-stat{color:#a9bed4;font-size:11px;margin-top:1px} /* #564: the pill is always dark, so this is too — --mbu-info is mixed toward the page text and went near-black on it in the light theme */ .gt-clone-btn{margin-left:10px;font:600 12px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-info);background:var(--mbu-info-bg); border:1px solid var(--mbu-info);border-radius:5px;padding:2px 9px;cursor:pointer;vertical-align:middle} .gt-clone-btn:hover{background:var(--mbu-info-bg)} .gt-cfg-btn{float:right;margin-left:8px;font-size:15px;line-height:1.4;color:var(--mbu-text-weak);background:none;border:none;cursor:pointer;padding:2px 7px;border-radius:5px} .gt-cfg-btn:hover{background:var(--mbu-info-bg);color:var(--mbu-text-dim)} /* #372 top toolbar (moved off the "Release relationships" heading to the top of the tab) */ .gt-toolbar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin:6px 0 14px;padding:8px 10px;background:var(--mbu-bg);border:1px solid var(--mbu-info);border-radius:7px} .gt-toolbar .gt-clone-btn{margin-left:0} /* #365 "Vertical:" section — label + two icon-only up/down buttons */ .gt-vert{display:inline-flex;align-items:center;gap:5px} .gt-vert-lbl{font:600 12px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text-dim)} .gt-vert-btn{margin-left:0;font-size:14px;line-height:1;padding:2px 8px} .gt-vert .gt-vert-btn+.gt-vert-btn{margin-left:0} .gt-toolbar .gt-cfg-btn{float:none;margin-left:auto} /* #372 config window (⚙): standard header (icon + name + version + Help) + options body */ .gt-cfg-pop{min-width:270px;padding:0} .gt-cfg-body{padding:9px 12px;display:flex;flex-direction:column;gap:8px} .gt-cfg-opt{display:flex;align-items:center;gap:8px;font-size:13px;color:var(--mbu-text);cursor:pointer}.gt-cfg-opt input{margin:0} .gt-pop{position:fixed;z-index:2147483647;min-width:300px;max-width:460px;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:8px; box-shadow:0 10px 30px rgba(0,0,0,.2);padding:6px;font:13px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text)} .gt-pop .gt-pop-hdr{padding:4px 8px 6px;font-size:11px;font-weight:700;letter-spacing:.02em;color:var(--mbu-text-dim);text-transform:uppercase} .gt-pop .gt-pop-list{max-height:44vh;overflow-y:auto} .gt-pop .gt-pop-note{padding:8px;color:var(--mbu-text-weak);font-size:12px} .gt-pop .gt-pop-rel{display:flex;align-items:center;gap:4px;border-radius:5px} .gt-pop .gt-pop-rel:hover{background:var(--mbu-info-bg)} .gt-pop .gt-pop-rel-info{flex:1;min-width:0;box-sizing:border-box;text-align:left;background:none;border:none;border-radius:5px;padding:6px 9px;cursor:pointer;color:inherit;font:inherit} .gt-pop .gt-pop-rel-t{display:block;white-space:nowrap;overflow:hidden;text-overflow:ellipsis} .gt-pop .gt-pop-rel-m{display:block;font-size:11px;color:var(--mbu-text-weak);margin-top:1px} .gt-pop .gt-pop-rel-open{flex:none;text-decoration:none;color:var(--mbu-text-weak);font-size:14px;line-height:1;padding:6px 9px;border-radius:5px} .gt-pop .gt-pop-rel-open:hover{background:var(--mbu-info-bg);color:var(--mbu-info)} .gt-pop .gt-pop-add{padding:4px 6px 6px} .gt-pop .gt-pop-add-btn{display:block;width:100%;box-sizing:border-box;text-align:left;background:none;border:none;border-radius:5px;padding:6px 9px;cursor:pointer;color:var(--mbu-info);font:inherit} .gt-pop .gt-pop-add-btn:hover{background:var(--mbu-info-bg)} .gt-pop .gt-pop-tf{display:block;width:100%;box-sizing:border-box;min-width:0;padding:6px 8px;border:1px solid var(--mbu-info);border-radius:5px;font:inherit;outline:none} .gt-pop .gt-hidden{display:none} /* subtle discoverability: the controls Group Therapy adds a right-click menu to (recording/work checkboxes → copy/move; the × → group delete; the +/pencil → scoped copy #373) get a green ring on hover */ tr.track input.recording, tr.track input.work { accent-color:#2e9e5b; } tr.track input.recording:hover, tr.track input.work:hover, button.icon.remove-item:hover, tr.track button.add-item.add-another-entity:hover, tr.track button.icon.edit-item:hover { outline:2px solid rgba(46,158,91,.55); outline-offset:1px; border-radius:3px; } /* Consolidate RG (#349) — the release×role matrix modal */ .gt-cons-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} .gt-cons.gt-role-pick{width:520px;max-width:92vw;max-height:min(560px,70vh);display:flex;flex-direction:column} /* #544: .gt-cons is declared later with the same specificity and was overriding every one of these */ .gt-role-search{margin:8px 10px 6px;padding:5px 8px;font-size:13px;font-family:inherit;border:1px solid var(--mbu-border);border-radius:4px} .gt-role-list{overflow:auto;flex:1;padding:0 4px 8px} .gt-role-row{padding:5px 8px;border-radius:4px;cursor:pointer;display:flex;flex-direction:column;gap:1px} .gt-role-row.gt-role-active{background:var(--mbu-bg-raised);box-shadow:inset 2px 0 0 #5f3ec0} /* #544 keyboard cursor */ .gt-role-row:hover{background:var(--mbu-bg-raised)} .gt-role-name{font-weight:600;font-size:13px} .gt-role-recent{font-weight:400;font-size:10px;color:var(--mbu-info);margin-left:6px} .gt-role-desc{font-size:11px;color:var(--mbu-text-dim);line-height:1.35;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} /* #544: was one nowrap line + ellipsis, which cut every description; two wrapped lines instead */ .gt-cons{background:var(--mbu-bg);border-radius:var(--mbu-radius-lg);box-shadow:0 18px 50px rgba(0,0,0,.35);width:min(920px,94vw);max-height:88vh;display:flex;flex-direction:column;font:13px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text)} .gt-cons-hdr{display:flex;align-items:center;gap:8px;padding:11px 14px;border-bottom:1px solid var(--mbu-border)} .gt-cons-title{font-weight:700;font-size:14px;flex:1} .gt-cons-x{background:none;border:none;font-size:16px;color:var(--mbu-text-weak);cursor:pointer;padding:2px 8px;border-radius:5px} .gt-cons-x:hover{background:var(--mbu-info-bg);color:var(--mbu-text-dim)} .gt-cons-body{padding:10px 14px;overflow:auto} .gt-cons-leg{display:flex;flex-wrap:wrap;gap:4px 16px;margin-bottom:10px;font-size:12px;color:var(--mbu-text-dim)} .gt-cons-legi b{display:inline-block;min-width:16px;text-align:center;background:var(--mbu-info-bg);border:1px solid var(--mbu-info);border-radius:4px;color:var(--mbu-info);margin-right:2px} .gt-cons-legi.gt-cur b{background:#2e6da4;color:var(--mbu-text-on-accent)} .gt-cons-legt{color:inherit;text-decoration:none} .gt-cons-legt:hover{text-decoration:underline;color:var(--mbu-info)} .gt-cons-leglabel{font-size:11px;color:var(--mbu-text-weak);text-transform:uppercase;letter-spacing:.02em;margin-bottom:5px} .gt-cons-leg{gap:5px 6px} .gt-cons-selitem{cursor:pointer;border:1px solid transparent;border-radius:var(--mbu-radius);padding:2px 7px;display:inline-flex;align-items:center} .gt-cons-selitem.gt-on{background:var(--mbu-info-bg);border-color:var(--mbu-info)} .gt-cons-selitem:not(.gt-on){opacity:.5} .gt-cons-selitem:not(.gt-on):hover{opacity:.85;background:var(--mbu-bg-raised)} .gt-cons-legopen{text-decoration:none;color:var(--mbu-text-weak);margin-left:5px} .gt-cons-legopen:hover{color:var(--mbu-info)} .gt-cons-legyr{color:var(--mbu-text-weak);margin-left:5px} .gt-cons-paste{display:block;width:100%;box-sizing:border-box;margin:9px 0 2px;padding:5px 8px;border:1px solid var(--mbu-border);border-radius:5px;font-size:12px;font-family:inherit;color:var(--mbu-text);outline:none} .gt-cons-paste:focus{border-color:var(--mbu-info)} .gt-cons-tbl{border-collapse:collapse;width:100%} .gt-cons-tbl th{font-size:11px;color:var(--mbu-text-dim);text-transform:uppercase;letter-spacing:.02em;text-align:left;padding:4px 8px;border-bottom:1px solid var(--mbu-border)} .gt-cons-tbl th.gt-cons-col{text-align:center;width:30px} .gt-cons-tbl th.gt-cons-colsel{cursor:pointer;color:var(--mbu-info)} .gt-cons-tbl th.gt-cons-colsel:hover{background:var(--mbu-info-bg);border-radius:4px} .gt-cons-coll{font-weight:700} .gt-fmt{display:inline-flex;gap:2px;vertical-align:middle;margin:0 4px} .gt-fmt-b{display:inline-block;min-width:13px;box-sizing:border-box;padding:0 3px;border-radius:3px;font:700 9px/14px -apple-system,Segoe UI,Arial,sans-serif;color:var(--mbu-text-on-accent);text-align:center;letter-spacing:.02em;border:1px solid var(--mbu-border)} /* #564: a hairline so the badge SHAPE is visible even when its fill is close to the page — the near-black vinyl one on dark. The inline colour set in fmtBadges wins over the color above. */ .gt-cons-col .gt-fmt{margin:2px 0 0;justify-content:center} .gt-cons-tbl td{padding:4px 8px;border-bottom:1px solid var(--mbu-border);vertical-align:top} .gt-cons-role{color:var(--mbu-text-dim);white-space:nowrap} .gt-cons-cr{color:var(--mbu-text-weak)} .gt-cons-cell{text-align:center;font-weight:700;width:30px;user-select:none} .gt-cons-cell.gt-has{color:var(--mbu-ok)} .gt-cons-cell.gt-prop{color:var(--mbu-info);outline:1px dashed #9cc2e6;outline-offset:-3px;border-radius:4px} .gt-cons-cell.gt-none{color:var(--mbu-info)} .gt-cons-cell.gt-fail{color:var(--mbu-error);cursor:help} .gt-cons-col.gt-fail .gt-cons-coll{color:var(--mbu-error)} .gt-cons-fail{display:flex;align-items:center;gap:8px;margin:4px 0 6px;padding:5px 8px;border:1px solid var(--mbu-error-border);border-radius:5px;background:var(--mbu-error-bg);color:var(--mbu-error);font-size:12px} .gt-cons-retry{margin-left:auto;cursor:pointer} .gt-cons-foot{display:flex;align-items:center;gap:12px;padding:10px 14px;border-top:1px solid var(--mbu-border)} .gt-cons-btn{font-weight:600;font-size:13px;font-family:inherit;padding:5px 14px;border-radius:var(--mbu-radius);border:1px solid var(--mbu-info);background:var(--mbu-info-bg);color:var(--mbu-info);cursor:pointer} .gt-cons-btn:hover{background:var(--mbu-info-bg)} .gt-cons-apply{margin-left:auto;background:#2e9e5b;border-color:var(--mbu-ok);color:var(--mbu-text-on-accent)} .gt-cons-apply:hover{background:#278a4f} .gt-cons-apply:disabled{background:var(--mbu-bg-sunken);border-color:var(--mbu-border);cursor:default} .gt-cons-plan{color:var(--mbu-text-dim);font-size:12px} /* Date picker (#398) */ .gt-dp{width:min(560px,94vw)} .gt-dp-ctrl{padding:10px 14px;border-bottom:1px solid var(--mbu-border);display:flex;flex-direction:column;gap:8px} .gt-dp-line{display:flex;align-items:center;flex-wrap:wrap;gap:6px} .gt-dp-lbl{font-size:11px;font-weight:700;color:var(--mbu-text-dim);text-transform:uppercase;letter-spacing:.02em;min-width:42px} .gt-dp-date{width:112px;box-sizing:border-box;padding:4px 7px;border:1px solid var(--mbu-border);border-radius:5px;font-size:13px;font-family:inherit;outline:none} .gt-dp-date:focus{border-color:var(--mbu-info)} .gt-dp-date.gt-dp-bad{border-color:var(--mbu-error);background:var(--mbu-bg-raised)} .gt-dp-dash{color:var(--mbu-text-weak)} .gt-dp-ended{display:inline-flex;align-items:center;gap:4px;margin-left:6px;color:var(--mbu-text-dim);cursor:pointer} .gt-dp-roles{gap:5px} .gt-dp-hint{color:var(--mbu-text-weak);font-style:italic} .gt-dp-chip{display:inline-flex;align-items:center;gap:4px;background:var(--mbu-info-bg);border:1px solid var(--mbu-info);color:var(--mbu-info);border-radius:12px;padding:2px 8px;font-size:12px;cursor:pointer} .gt-dp-chip:hover{background:var(--mbu-info-bg)} .gt-dp-chipx{color:var(--mbu-info);font-weight:700} .gt-dp-chip:hover .gt-dp-chipx{color:var(--mbu-error)} .gt-dp-body{padding:6px 10px} .gt-dp-trow{padding:4px 0;border-bottom:1px solid var(--mbu-border)} .gt-dp-tlab{display:flex;align-items:center;gap:7px;cursor:pointer;font-weight:600} .gt-dp-tpos{min-width:22px;color:var(--mbu-info);font-weight:700} .gt-dp-ttitle{color:var(--mbu-text);font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .gt-dp-clist{margin:2px 0 4px 26px;display:flex;flex-direction:column;gap:1px} .gt-dp-crow{display:flex;align-items:center;gap:7px;padding:2px 4px;border-radius:5px;cursor:pointer} .gt-dp-crow:hover{background:var(--mbu-bg-raised)} .gt-dp-role{color:var(--mbu-text-dim);white-space:nowrap} .gt-dp-role:hover{color:var(--mbu-info);text-decoration:underline dotted} .gt-dp-cname{color:var(--mbu-text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1} .gt-dp-dated .gt-dp-role,.gt-dp-dated .gt-dp-cname{color:var(--mbu-text-weak)} .gt-dp-has{color:var(--mbu-warn);font-size:11px;white-space:nowrap;background:var(--mbu-warn-bg);border:1px solid var(--mbu-warn);border-radius:4px;padding:0 5px} `; document.head.appendChild(s); } // ── copy / move credits (P2, #338) ──────────────────────────────────────── // Reuse MB's own editing path. We read a source recording's rels straight off the // rendered `.relationship-item` nodes — each carries the full rel object on its React // fiber (linkTypeID, both entities WITH internal ids, credits, and the attributes tree) — // then dispatch copies onto the destination recordings through MB's reducer. No lossy // DOM-text parsing, no nested-state traversal. const REL_TEMPLATE = { _lineage: [], _original: null, _status: 1, attributes: null, begin_date: null, editsPending: false, end_date: null, ended: false, entity0_credit: '', entity1_credit: '', id: null, linkOrder: 0, linkTypeID: null }; const RE = () => (W.MB && W.MB.relationshipEditor) || null; const val = v => (typeof v === 'function' ? v() : v); // walk a DOM node's React fiber to the rel object (or entity) it renders function fiberFind(node, looks) { const key = node && Object.keys(node).find(k => k.startsWith('__reactFiber$') || k.startsWith('__reactInternalInstance$')); if (!key) return null; const seen = new Set(), q = [node[key]]; let steps = 0; while (q.length && steps < 80) { steps++; const f = q.shift(); if (!f || seen.has(f)) continue; seen.add(f); for (const prop of ['memoizedProps', 'pendingProps', 'memoizedState']) { const p = f[prop]; if (p && typeof p === 'object') for (const v of Object.values(p)) { if (looks(v)) return v; if (v && typeof v === 'object' && looks(v.relationship)) return v.relationship; if (v && typeof v === 'object' && looks(v.recording)) return v.recording; if (v && typeof v === 'object' && looks(v.work)) return v.work; } } if (f.child) q.push(f.child); if (f.sibling) q.push(f.sibling); if (f.return) q.push(f.return); } return null; } const looksRel = o => o && typeof o === 'object' && ('linkTypeID' in o) && ('entity0' in o || 'entity1' in o); // gid OR id != null — new (in-session) works/recordings have a NEGATIVE id and no gid yet const looksRec = o => o && typeof o === 'object' && o.entityType === 'recording' && (o.gid || o.id != null); const looksWork = o => o && typeof o === 'object' && o.entityType === 'work' && (o.gid || o.id != null); // same MB entity — MUST compare entityType too: a work id and a recording id can be the SAME number // (e.g. "Phuture Jacks" work #6762137 vs recording #6762137), so an id-only match would wrongly pull // a recording's producer/mix rels into a work. const sameEntity = (e, ref) => !!(e && ref && e.entityType === ref.entityType && ((ref.gid && e.gid === ref.gid) || (ref.id != null && e.id === ref.id))); const relFromNode = node => fiberFind(node, looksRel); // #373 classify a rel item so the +/pencil/× route correctly: a recording credit, a WORK credit (carrying // the work entity from the rel), a recording-of, or null. Fixes work rels showing the recording's menu. function relClass(item) { const rel = relFromNode(item); if (!rel) return null; const t0 = rel.entity0 && rel.entity0.entityType, t1 = rel.entity1 && rel.entity1.entityType; if ((t0 === 'recording' && t1 === 'work') || (t0 === 'work' && t1 === 'recording')) return { kind: 'recof', rel }; if (t0 === 'work' || t1 === 'work') return { kind: 'work', rel, work: t0 === 'work' ? rel.entity0 : rel.entity1 }; if (t0 === 'recording' || t1 === 'recording') return { kind: 'rec', rel }; return null; } const recordingEntity = tr => fiberFind(tr, looksRec); const workEntity = node => fiberFind(node, looksWork); // a recording track-row's rels, normalised to {other, credit, linkTypeID, attributes} // where `other` is the non-recording entity (the artist/work/…) and `credit` its credited-as function recordingRels(tr) { const out = []; tr.querySelectorAll('.relationship-item').forEach(item => { const rel = relFromNode(item); if (!rel || !looksRel(rel)) return; const rec0 = rel.entity0 && rel.entity0.entityType === 'recording'; const rec1 = rel.entity1 && rel.entity1.entityType === 'recording'; if (!rec0 && !rec1) return; const other = rec0 ? rel.entity1 : rel.entity0; const credit = rec0 ? rel.entity1_credit : rel.entity0_credit; // _status: 0 = existing, 1 = added this session, 3 = marked removed (stays in the DOM struck) out.push({ item, other, credit: val(credit) || '', linkTypeID: rel.linkTypeID, attributes: rel.attributes || null, begin_date: rel.begin_date || null, end_date: rel.end_date || null, ended: !!rel.ended, // preserve ℗/© years etc. on copy removed: rel._status === 3 }); }); return out; } // dispatch one rel into MB's editor (ported from Credit Hoarder's editor-state — the shared // editing lib). MB requires entity0 to be the lower entityType; swap + route credit accordingly. function dispatchRelationship(re, sourceEntity, targetEntity, linkTypeID, credit, attributes, dates) { if (credit && credit === (val(targetEntity.name) || '')) credit = ''; const swapped = sourceEntity.entityType > targetEntity.entityType; const e0 = swapped ? targetEntity : sourceEntity; const e1 = swapped ? sourceEntity : targetEntity; re.dispatch({ type: 'update-relationship-state', sourceEntity, batchSelectionCount: null, creditsToChangeForSource: '', creditsToChangeForTarget: '', oldRelationshipState: null, newRelationshipState: { ...REL_TEMPLATE, entity0: e0, entity0_credit: swapped ? (credit || '') : '', entity1: e1, entity1_credit: swapped ? '' : (credit || ''), id: re.getRelationshipStateId(), linkTypeID, attributes: attributes || null, begin_date: (dates && dates.begin_date) || null, end_date: (dates && dates.end_date) || null, ended: !!(dates && dates.ended), }, }); } // copy a set of source rels onto each destination entity (recording or work), preserving credit, // attributes and dates. Each dispatch is guarded so one bad target can't abort the whole batch. function copyCredits(srcRels, destEntities) { const re = RE(); if (!re) return 0; let n = 0, failed = 0; for (const dest of destEntities) { if (!dest || dest.id == null) { failed++; try { console.warn('[Group Therapy] skipping copy to a target with no id (unsaved entity?):', dest && val(dest.name)); } catch (e) {} continue; } for (const s of srcRels) { try { dispatchRelationship(re, dest, s.other, s.linkTypeID, s.credit, s.attributes, s); n++; } catch (e) { failed++; try { console.warn('[Group Therapy] copy failed for one credit:', e); } catch (_) {} } } } if (failed) try { toast(`Copied ${n}, but ${failed} could not be copied (see console)`); } catch (e) {} return n; } // #365 map a link type to the equivalent for a different entity end, by NAME — e.g. artist-recording // "producer" (#141) ↔ artist-release "producer" (#30). Returns null when the role has no equivalent for // that entity type (e.g. "instrument" is recording-only), so we can skip + report it. function ltEquiv(srcLtId, targetType) { const lt = W.MB.linkedEntities && W.MB.linkedEntities.link_type, src = lt && lt[srcLtId]; if (!src) return null; const otherEnd = [src.type0, src.type1].find(t => t !== 'release' && t !== 'recording'); if (!otherEnd) return null; // must be a credit (artist/label) if (src.type0 === targetType || src.type1 === targetType) return srcLtId; // already the right pair const want = [targetType, otherEnd].sort().join('-'); const m = Object.values(lt).find(t => t.name === src.name && [t.type0, t.type1].slice().sort().join('-') === want); return m ? m.id : null; } // like copyCredits, but maps each source credit's link type to the destination's entity type (#365 // release↔recording). Returns { n applied, skipped (no equivalent role for that entity) }. function copyCreditsMapped(srcRels, destEntities) { const re = RE(); if (!re) return { n: 0, skipped: 0 }; let n = 0, skipped = 0; for (const dest of destEntities) { if (!dest || dest.id == null) { skipped += srcRels.length; continue; } for (const s of srcRels) { const lt = ltEquiv(s.linkTypeID, dest.entityType); if (lt == null) { skipped++; continue; } try { dispatchRelationship(re, dest, s.other, lt, s.credit, s.attributes, s); n++; } catch (e) { skipped++; } } } return { n, skipped }; } // #365 read the release's OWN credits — the relationship-items in the Release relationships section // (i.e. NOT inside a track row). Filtered to artist/label credits, like recordingRels. function releaseCreditRels() { const out = []; [...document.querySelectorAll('.relationship-item')].filter(i => !i.closest('tr.track')).forEach(item => { const rel = relFromNode(item); if (!rel || !looksRel(rel)) return; const rel0 = rel.entity0 && rel.entity0.entityType === 'release', rel1 = rel.entity1 && rel.entity1.entityType === 'release'; if (!rel0 && !rel1) return; const other = rel0 ? rel.entity1 : rel.entity0; if (!other || !['artist', 'label'].includes(other.entityType)) return; // only copyable credits const credit = rel0 ? rel.entity1_credit : rel.entity0_credit; out.push({ item, other, credit: val(credit) || '', linkTypeID: rel.linkTypeID, attributes: rel.attributes || null, begin_date: rel.begin_date || null, end_date: rel.end_date || null, ended: !!rel.ended, removed: rel._status === 3 }); }); return out; } const releaseEntity = () => { try { const re = RE(); return re && (re.state.entity || re.state.release) || null; } catch (e) { return null; } }; // #365 (1) copy/move the release's own credits onto its recordings (the ticked ones, or all if none ticked) /* #597 (majkinetor): "Vertical move could also have option to paste tracks or specify them as in Text Pattern scope as selecting checkboxes is slow." One selector, shared by both vertical moves and using the text parser's own matcher, so the syntax, the live preview and the vinyl/ordinal handling cannot drift apart between the three places that offer it. Precedence: a typed spec wins; otherwise the ticked tracks; otherwise all. `state.v` is the live text — the caller reads it back through rowsFor(). */ function gtTrackSpecState(opts) { // blankAll: an empty field means EVERY track, ignoring the tick boxes. That // is what ⬇ has always done, and quietly narrowing its harvest to whatever // happened to be ticked would change an existing tool's behaviour. const blankAll = !!(opts && opts.blankAll); const state = { v: '' }; const tickedRows = rows => rows.filter(x => { const cb = x.tr.querySelector('input.recording'); return cb && cb.checked; }); state.rowsFor = () => { const rows = txpTrackRows(); if (state.v.trim()) return txpMatchTracks(state.v, rows); if (blankAll) return rows; const t = tickedRows(rows); return t.length ? t : rows; }; state.label = () => { const rows = state.rowsFor(); const cap = ns => (ns.length > 8 ? `${ns.slice(0, 8).join(', ')} +${ns.length - 8}` : ns.join(', ')); if (state.v.trim()) return rows.length ? `${rows.length} track${rows.length > 1 ? 's' : ''} (${cap(rows.map(x => x.num))})` : 'nothing matched'; return (!blankAll && tickedRows(txpTrackRows()).length) ? `${rows.length} selected recording${rows.length > 1 ? 's' : ''}` : `all ${rows.length} recordings`; }; state.item = (blank, onChange, onCommit) => ({ input: { value: state.v, onChange: onCommit || null, placeholder: `tracks: 1,3,5-7 · 2:4 · all (blank = ${blank})`, title: 'A track number as shown (3, A1), a range (5-7), a medium-qualified number or range (2:4, 2:4-6), ' + 'a whole medium (2:*), or all.\nA plain number matching no displayed position means the Nth track, ' + `which is how a vinyl's "track 4" finds B1.\nLeave empty to use ${blank}.`, onInput: (v) => { state.v = v; const rows = state.rowsFor(); if (onChange) onChange(); return { ok: rows.length > 0, text: '→ ' + state.label(), title: rows.map(x => `${x.num} ${x.title}`).join('\n') }; }, }, }); return state; } function openRelToRec(anchor) { const srcRels = releaseCreditRels().filter(r => !r.removed); const sel = gtTrackSpecState(); const dests = () => sel.rowsFor().map(x => x.rec).filter(Boolean); const where = () => sel.label(); // #365 cleansing — release-level / packaging roles that don't belong on a recording start UNTICKED // (re-tick to override). Matched as substrings against the role label. const CLEANSE = ['liner note', 'compiler', 'mastering', 'remaster', 'artwork', 'art direction', 'design', 'illustration', 'photograph', 'graphic', 'manufactured', 'pressed by', 'printed by', 'booklet', 'translat', 'lacquer', 'publish', 'copyright', 'booking', '℗', '©']; const entries = srcRels.map(s => { const lbl = (roleLabelOf(s) || '').toLowerCase(); return { rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : ''), checked: !CLEANSE.some(k => lbl.includes(k)) }; }); const chosen = () => entries.filter(e => e.cb ? e.cb.checked : e.checked !== false).map(e => e.rel); const r = anchor.getBoundingClientRect(); if (!srcRels.length) { openMenu(r.left, r.bottom + 4, [{ header: 'No release-level credits to copy' }]); return; } if (!txpTrackRows().length) { openMenu(r.left, r.bottom + 4, [{ header: 'No recordings on this release' }]); return; } // #597: `dests`/`where` are functions now — the destination is whatever the // track field says at the moment Copy/Move is pressed, not what it said // when the menu opened. const guard = (run) => () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } const d = dests(); if (!d.length) { toast('No track matched that selection'); return; } run(c, d, where()); }; const copyItem = { label: 'Copy', sub: String(chosen().length), run: guard((c, d, w) => { const { n, skipped } = copyCreditsMapped(c, d); if (n) markUsed(`Copied ${n} release credit${n > 1 ? 's' : ''} to ${w}`); toast(`Copied ${n} to ${w}${skipped ? ` · ${skipped} had no per-recording role` : ''} — review & save`); }) }; const moveItem = { label: 'Move (remove from release)', danger: true, run: guard((c, d, w) => { const { n } = copyCreditsMapped(c, d); c.forEach(s => { try { const rm = s.item.querySelector('button.remove-item, button.icon.remove-item'); rm && rm.click(); } catch (e) {} }); if (n) markUsed(`Moved ${n} release credit${n > 1 ? 's' : ''} to ${w}`); toast(`Moved ${n} to ${w} — review & save`); }) }; const hdr = { header: `Copy release credits → ${where()}` }; openMenu(r.left, r.bottom + 4, [hdr, sel.item('ticked tracks, or all', () => hdr._set && hdr._set(`Copy release credits → ${where()}`)), { checklist: entries, onToggle: () => copyItem._setSub && copyItem._setSub(String(chosen().length)) }, copyItem, moveItem]); } // #365 (2) collect the recordings' credits onto the release — a UNION across all tracks (dedup by // role+artist+credit), each row showing the track range it covers (* = every track). function openRecToRel(anchor, sel) { const rel = releaseEntity(); const r = anchor.getBoundingClientRect(); if (!rel || rel.id == null) { openMenu(r.left, r.bottom + 4, [{ header: 'Release not ready' }]); return; } // #597: which tracks to harvest FROM. Carried across the rebuild (see the // onCommit below) so the field keeps what was typed into it. sel = sel || gtTrackSpecState({ blankAll: true }); const srcRows = sel.rowsFor(); const total = srcRows.length, byKey = new Map(); srcRows.map(x => x.tr).forEach(tr => { const pos = trackPosOfRow(tr); recordingRels(tr).filter(s => !s.removed && s.other && ['artist', 'label'].includes(s.other.entityType)).forEach(s => { const key = roleKeyOfSpec(s) + '|' + (val(s.other.gid) || '') + '|' + (s.credit || ''); let e = byKey.get(key); if (!e) { e = { rel: s, roleLbl: roleLabelOf(s), other: s.other, credit: s.credit, tracks: new Set(), items: [] }; byKey.set(key, e); } if (pos != null) e.tracks.add(pos); e.items.push(s.item); }); }); const trkLbl = set => (set.size && set.size >= total) ? '*' : ranges(set); const entries = [...byKey.values()].map(e => ({ _e: e, role: e.rel.linkTypeID + '#' + e.roleLbl, pos: e.roleLbl, text: `${trkLbl(e.tracks)} ${val(e.other.name)}${e.credit && e.credit !== val(e.other.name) ? ` (${e.credit})` : ''}` })); const chosen = () => entries.filter(x => x.cb ? x.cb.checked : true); // #597: rebuilding is how the checklist follows the track field — the // entries ARE the harvest, so they cannot be recomputed in place. `sel` is // handed back in so the field keeps its text and the caret lands at the end. const reopen = () => openRecToRel(anchor, sel); const specItem = sel.item('all tracks', null, reopen); // #597: the "nothing to collect" menu keeps the field too. Without it, a // spec matching no credits would strand you in a dead menu with no way to // correct what you typed except closing and starting over. const from = sel.v.trim() ? ` from ${sel.label()}` : ''; if (!entries.length) { openMenu(r.left, r.bottom + 4, [{ header: 'No recording credits to collect' + from }, specItem]); return; } const copyItem = { label: 'Copy', sub: String(chosen().length), run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } const { n, skipped } = copyCreditsMapped(c.map(x => x._e.rel), [rel]); if (n) markUsed(`Collected ${n} credit${n > 1 ? 's' : ''} onto the release${from}`); toast(`Added ${n} to the release${skipped ? ` · ${skipped} had no release role` : ''} — review & save`); } }; const moveItem = { label: 'Move (remove from recordings)', danger: true, run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } const { n } = copyCreditsMapped(c.map(x => x._e.rel), [rel]); c.forEach(x => x._e.items.forEach(it => { try { const rm = it.querySelector('button.remove-item, button.icon.remove-item'); rm && rm.click(); } catch (e) {} })); if (n) markUsed(`Moved ${n} credit${n > 1 ? 's' : ''} from recordings onto the release${from}`); toast(`Moved ${n} onto the release — review & save`); } }; openMenu(r.left, r.bottom + 4, [{ header: 'Collect recording credits → the release (union)' }, specItem, { checklist: entries, onToggle: () => copyItem._setSub && copyItem._setSub(String(chosen().length)) }, copyItem, moveItem]); } // destination recordings = every OTHER track row whose recording checkbox is ticked function checkedDestinations(sourceTr) { const dests = []; document.querySelectorAll('tr.track').forEach(tr => { if (tr === sourceTr) return; const cb = tr.querySelector('input.recording'); if (cb && cb.checked) { const rec = recordingEntity(tr); if (rec) dests.push(rec); } }); return dests; } // for Move: click MB's own × on each source rel (so React removes it like a manual click). // Must go ONE AT A TIME with a re-read between clicks — all the rels are on the same row, and // each removal re-renders it, so pre-collected × buttons go stale after the first click. We // re-query the row each pass and remove the next rel matching (linkType + target gid). // ── #539: choosing recordings by track number ─────────────────────────────── // Every track row carries its DISPLAYED number in td.pos — "1" on a CD, "A1" // on a vinyl — and MB numbers restart per medium, so a plain "3" can name // more than one track. Rows are returned with their medium so the selector // can disambiguate, and the parser shows what it matched before anything is // applied. function txpTrackRows() { const out = []; let medium = 1; const all = [...document.querySelectorAll('#content tr, table tr')]; for (const tr of all) { if (!tr.classList.contains('track')) { // medium headers look like "1▼CD" / "2▼CD"; keep the last number seen const m = (tr.className || '').includes('subh') && (tr.innerText || '').trim().match(/^(\d+)/); if (m) medium = parseInt(m[1], 10); continue; } const rec = recordingEntity(tr); if (!rec) continue; const num = ((tr.querySelector('td.pos') || {}).textContent || '').trim(); out.push({ tr, rec, medium, num, ordinal: out.length + 1, title: val(rec.name) || '' }); } return out; } // Accepts, comma- or space-separated: a literal track number as displayed // (`3`, `A1`), a numeric range (`5-7`), a medium-qualified number or range // (`2:4`, `2:4-6`), a whole medium (`2:*`), or `all`. A colon is used for the // medium rather than a dash so `2-4` can only ever mean a range. // One token of a selector -> the rows it names. Split out of txpMatchTracks // (#597) because two callers now need to know whether a token matched // ANYTHING on its own: the ordinal fallback below, and auto-detect, whose // whole safety rule is "every token must name a real track". function txpMatchTrackToken(tok, rows) { const numOf = r => { const n = parseInt(String(r.num).replace(/^\D+/, ''), 10); return isFinite(n) ? n : null; }; let m; if ((m = tok.match(/^(\d+):(\*|all)$/i))) return rows.filter(r => r.medium === +m[1]); if ((m = tok.match(/^(\d+):(\d+)(?:-(\d+))?$/))) { const [lo, hi] = [+m[2], m[3] ? +m[3] : +m[2]]; return rows.filter(r => r.medium === +m[1] && numOf(r) !== null && numOf(r) >= lo && numOf(r) <= hi); } // #597: a plain number (or numeric range) that names no DISPLAYED position // falls back to the Nth track overall. Liner notes on a vinyl say "(tracks // 1,2,4)" while the positions on the page are A1/B2, so a literal match // finds nothing at all and the ordinal is what the note plainly means. // Strictly a fallback: on a CD "3" matches the position "3" literally and // never gets here, so nothing that already worked moves. if ((m = tok.match(/^(\d+)-(\d+)$/))) { const [lo, hi] = [+m[1], +m[2]]; const hit = rows.filter(r => numOf(r) !== null && numOf(r) >= lo && numOf(r) <= hi); return hit.length ? hit : rows.filter(r => r.ordinal >= lo && r.ordinal <= hi); } const t = tok.toLowerCase(); const hit = rows.filter(r => String(r.num).toLowerCase() === t); if (hit.length || !/^\d+$/.test(t)) return hit; return rows.filter(r => r.ordinal === +t); } function txpMatchTracks(spec, rows) { const s = String(spec || '').trim(); if (!s) return []; if (/^all$/i.test(s)) return rows.slice(); const picked = new Set(); for (const tok of s.split(/[,\s]+/).filter(Boolean)) txpMatchTrackToken(tok, rows).forEach(r => picked.add(r)); return rows.filter(r => picked.has(r)); } /* ── #597: reading the track list out of the credit text itself ───────────── (majkinetor) "Tracks are never mentioned this tidy but we could probably parse them if user selects or gives part that has tracks e.g. 1,2,3,7 or (tracks 1,2,3,7) or even auto recognized as numbers usually do not appear just like that (especially not vinyl)." Until now a clause like "Lead vocals (tracks 2,6,9)" was parsed, resolved and then silently discarded — the credit went on the whole release. Nothing LOOKED wrong either, because MB's role matcher tolerates the suffix: measured, "Lead vocals (tracks 2,6,9)" resolves to `vocal` exactly as the clean text does, and "Djembe (track 4)" to `djembe`. A plausible result crediting the wrong scope is the reason the preview column exists. The one rule that makes auto-detection safe: EVERY token in the clause must name a real track on this release. "(2003)" is not a track list because 2003 is not a track. That needs no blocklist of years, catalogue numbers or durations, and it fails by deciding the clause was never a track clause at all — which costs nothing. */ function txpDetectTracks(text, rows) { const s = String(text || ''); if (!s.trim() || !rows || !rows.length) return null; const cands = []; const covered = i => cands.some(c => i >= c.start && i < c.end); let m; // (tracks 1,2,4,7,8) · [track 3] · (trk 4) · (on tracks 2 and 6) const reWord = /[([]\s*(?:on\s+)?(?:tracks?|trks?)\.?\s*([^)\]]+?)\s*[)\]]/gi; while ((m = reWord.exec(s))) cands.push({ start: m.index, end: m.index + m[0].length, inner: m[1], explicit: true }); // trailing and unbracketed: "…, tracks 2 & 6" · "… on tracks 2, 6 and 9" const reTail = /[,;]?\s*(?:on\s+)?(?:tracks?|trks?)\.?\s+([0-9A-Za-z][0-9A-Za-z,;&\s-]*?)\s*\.?\s*$/i; if ((m = reTail.exec(s)) && !covered(m.index)) cands.push({ start: m.index, end: m.index + m[0].length, inner: m[1], explicit: true }); // bare: "(2,6,9)" · "(A1, B2)" · "(1-3)" const reBare = /[([]\s*([^)\]]+?)\s*[)\]]/g; while ((m = reBare.exec(s))) { if (!covered(m.index)) cands.push({ start: m.index, end: m.index + m[0].length, inner: m[1], explicit: false }); } const norm = t => String(t).replace(/\band\b/gi, ',').replace(/[&;]/g, ',') .replace(/\s*,\s*/g, ',').replace(/,+/g, ',').replace(/^,|[,.]+$/g, '').trim(); const ok = []; for (const c of cands) { const spec = norm(c.inner); const toks = spec ? spec.split(/[,\s]+/).filter(Boolean) : []; if (!toks.length) continue; const hits = toks.map(t => txpMatchTrackToken(t, rows)); if (hits.some(h => !h.length)) continue; // ← the safety rule // A bare single number — "Guitar (1)" — counts. It was refused at first, // on the grounds that it is a footnote marker at least as often as a // track; majkinetor overruled that after testing a real liner-note block // (#597): "also recognize (1) as it is shortest way to specify tracks // without adding any other words. It isn't likely that footnote will come // in that way and if it does, it can be removed by hand." The safety rule // above still applies, so the number has to BE a track on this release. ok.push({ ...c, spec, targets: rows.filter(r => hits.some(h => h.includes(r))) }); } if (!ok.length) return null; // (majkinetor) "We should assume that 1 line contains only 1 track list." // When that assumption is broken — "Mandolin (tracks 1,2), Guitar (tracks // 3,4)" — nothing here can tell which role owns which list, so it stands // down and says so instead of guessing. Splitting the line is the fix, and // saying that is more useful than crediting half of it wrongly. if (ok.length > 1) return { ambiguous: ok.length }; const c = ok[0]; const clean = (s.slice(0, c.start) + ' ' + s.slice(c.end)).replace(/\s+/g, ' ').replace(/[\s,;.]+$/, '').trim(); return { spec: c.spec, targets: c.targets, clean }; } const rowForRecording = gid => [...document.querySelectorAll('tr.track')].find(tr => { const rec = recordingEntity(tr); return rec && rec.gid === gid; }); async function removeSourceRels(srcGid, srcRels) { const want = new Set(srcRels.map(s => s.linkTypeID + '|' + (s.other && s.other.gid))); for (let guard = 0; guard < 300; guard++) { const tr = rowForRecording(srcGid); if (!tr) break; // re-find the row each pass — React replaces it on every removal // skip rels already marked removed (_status 3) — × leaves them struck in the DOM, so // without this the loop would re-click the same one forever const hit = recordingRels(tr).find(r => !r.removed && want.has(r.linkTypeID + '|' + (r.other && r.other.gid))); const b = hit && hit.item && hit.item.querySelector(REMOVE_SEL); if (!b) break; try { b.click(); } catch (e) {} await new Promise(r => setTimeout(r, 70)); // let React re-render before re-finding } } function toast(msg) { return mbuToast(msg); } // #563: the shared toast // build an MB attribute ImmutableTree from a /ws/js rel's attribute array (they carry typeIDs directly) function buildAttrTree(wsAttrs) { if (!wsAttrs || !wsAttrs.length) return null; const MB = W.MB, lat = MB && MB.linkedEntities && MB.linkedEntities.link_attribute_type; if (!lat || !MB.tree) return null; const objs = wsAttrs.map(a => ({ type: lat[a.typeID], typeID: a.typeID, text_value: a.text_value || '', credited_as: a.credited_as || '' })) .filter(o => o.type).sort((a, b) => a.typeID - b.typeID); if (!objs.length) return null; try { return MB.tree.fromDistinctAscArray(objs); } catch (e) { return null; } } // #374 apply recording-of attributes + dates onto an EXISTING recording→work rel — an update (keeps the // rel's id/entities, replaces its attributes/dates), not an add. Verified against MB's reducer. function updateRelAttrs(re, destRel, attrList, dates) { const src = destRel.entity0 && destRel.entity0.entityType === 'recording' ? destRel.entity0 : destRel.entity1; re.dispatch({ type: 'update-relationship-state', sourceEntity: src, batchSelectionCount: null, creditsToChangeForSource: '', creditsToChangeForTarget: '', oldRelationshipState: destRel, // #383 MUST bump _status to EDIT (2) for an EXISTING rel — spreading destRel keeps _status 0 (NOOP), so // MB treats the attribute/date change as nothing and submit says "You haven't made any changes". A // not-yet-saved rel (ADD = 1) stays ADD so it isn't turned into an edit-of-nothing. newRelationshipState: { ...destRel, _status: destRel._status === 1 ? 1 : 2, attributes: buildAttrTree(attrList), begin_date: (dates && dates.begin_date) || null, end_date: (dates && dates.end_date) || null, ended: !!(dates && dates.ended) }, }); } // #385 set the date period (begin/end/ended) on an existing rel, leaving attributes/credit untouched — // powers "Set missing dates". Same _status discipline as updateRelAttrs (#383): bump an existing rel to // EDIT (2) so the change submits; a not-yet-saved rel (ADD = 1) stays ADD. // // ── Findings (so we don't re-investigate — see issue #385) ────────────────────────────────────────── // This can only FILL a blank date. It can NOT overwrite an existing date, nor clear one. Why: // • MB's editor reducer routes 'update-relationship-state' through getUpdatesForAcceptedRelationship → // mergeRelationship, which MERGES the new state into the existing relationship and KEEPS the non-empty // date. So an empty or different date sent via MB.relationshipEditor.dispatch is silently ignored and // the old date stays. Verified empirically: null, {year:null,month:null,day:null}, and empty strings // ALL leave the date intact; setting a different date keeps the first one. // • The ONLY way to overwrite/clear a date is to drive MB's own edit dialog: click the rel's // "edit-relationship-…" button to OPEN the dialog (only then does MB.relationshipEditor // .relationshipDialogDispatch exist — it's undefined otherwise), dispatch // {type:'update-date-period', action:{type:'update-begin-date', action:{type:'set-date', date}}} etc., // then click the dialog's positive (Done) button. loujine's "Copy dates" userscript works this way. // • Or submit the edit directly to MB's edit API (like GT's RG Consolidation), bypassing the editor // reducer entirely — that can set/remove too, but it auto-submits instead of staging for review. // • DECISION (majkinetor): we neither drive the UI nor auto-submit edits, so overwrite/remove are // abandoned; this stays fill-only. // ──────────────────────────────────────────────────────────────────────────────────────────────────── function applyRelDate(re, destRel, dates) { const e0 = destRel.entity0, e1 = destRel.entity1; const src = (e0 && e0.entityType === 'recording') ? e0 : (e1 && e1.entityType === 'recording') ? e1 : e0; re.dispatch({ type: 'update-relationship-state', sourceEntity: src, batchSelectionCount: null, creditsToChangeForSource: '', creditsToChangeForTarget: '', oldRelationshipState: destRel, newRelationshipState: { ...destRel, _status: destRel._status === 1 ? 1 : 2, begin_date: (dates && dates.begin_date) || null, end_date: (dates && dates.end_date) || null, ended: !!(dates && dates.ended) }, }); } function relAttrList(rel) { // a rel's attributes as [{typeID, text_value, credited_as, name}] if (!rel || !rel.attributes || !W.MB || !W.MB.tree) return []; try { return [...W.MB.tree.iterate(rel.attributes)].map(a => ({ typeID: a.typeID, text_value: a.text_value || '', credited_as: a.credited_as || '', name: (a.type && a.type.name) || ((W.MB.linkedEntities.link_attribute_type[a.typeID] || {}).name) || '' })); } catch (e) { return []; } } const fmtRoDate = d => { const one = x => x && x.year ? [x.year, x.month, x.day].filter(Boolean).join('‑') : ''; const b = one(d.begin_date), e = one(d.end_date); if (b && e) return `${b} → ${e}`; if (b) return d.ended ? `${b} → ` : b; if (e) return `→ ${e}`; return d.ended ? 'ended' : ''; }; // #374 copy a "recording of" rel's attributes + dates onto the selected recordings' own recording-of // rels (or all if none selected) — "always set the others exactly like that one". function openRecOfMenu(sourceItem, x, y) { const re = RE(); if (!re) return; const srcRel = relFromNode(sourceItem); if (!srcRel) return; const attrList = relAttrList(srcRel); const dates = { begin_date: srcRel.begin_date || null, end_date: srcRel.end_date || null, ended: !!srcRel.ended }; const selTr = [...document.querySelectorAll('tr.track')].filter(tr => { const cb = tr.querySelector('input.recording'); return cb && cb.checked; }); const trs = selTr.length ? selTr : [...document.querySelectorAll('tr.track')]; const destRels = []; trs.forEach(tr => tr.querySelectorAll('.relationship-item').forEach(it => { if (it === sourceItem) return; const r = relFromNode(it); if (r && r._status !== 3 && r.entity0 && r.entity0.entityType === 'recording' && r.entity1 && r.entity1.entityType === 'work') destRels.push(r); })); const summary = [attrList.map(a => a.name).filter(Boolean).join(', '), fmtRoDate(dates)].filter(Boolean).join(' · ') || '(no attributes or dates set)'; const where = selTr.length ? `${destRels.length} selected` : `all ${destRels.length}`; const items = [{ header: `Copy “recording of” attributes → ${where} recording${destRels.length !== 1 ? 's' : ''}` }, { note: summary }]; if (!destRels.length) items.push({ header: 'No other recording-of rels to copy to' }); else items.push({ label: 'Copy', run: () => { let n = 0; destRels.forEach(dr => { try { updateRelAttrs(re, dr, attrList, dates); n++; } catch (e) {} }); if (n) markUsed(`Copied “recording of” attributes to ${n} recording${n !== 1 ? 's' : ''}`); toast(`Set “recording of” attributes on ${n} — review & save`); } }); openMenu(x, y, items); } // fetch another release's release-level credits (artists + labels) as copy specs (not yet dispatched) async function fetchReleaseRels(sourceGid) { const j = await (await mbnFetch('/ws/js/entity/' + sourceGid + '?inc=rels', { credentials: 'include', headers: { Accept: 'application/json' } })).json(); return (j.relationships || []).filter(r => (r.target_type === 'artist' || r.target_type === 'label') && r.target && r.target.id != null) .map(r => ({ other: { entityType: r.target_type, id: r.target.id, gid: r.target.gid, name: r.target.name }, // artist/label sort before release → carries entity0_credit linkTypeID: r.linkTypeID, credit: r.entity0_credit || '', attributes: buildAttrTree(r.attributes), begin_date: r.begin_date || null, end_date: r.end_date || null, ended: !!r.ended, })); } // this release's single medium format (from tr.subh), or '' when mixed/unknown const releaseFormat = () => { const f = [...new Set([...document.querySelectorAll('tr.subh')].map(s => (s.textContent || '').replace(/^\s*\d+\s*/, '').replace(/^[^A-Za-z0-9]+/, '').trim().toLowerCase()).filter(Boolean))]; return f.length === 1 ? f[0] : ''; }; const GID_RE = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/i; // popover: pick a sibling release from this release group, or paste any release URL/MBID let popEl = null, cloneBtnRef = null; function closePopover() { if (popEl) { popEl.remove(); popEl = null; document.removeEventListener('mousedown', onPopDown, true); document.removeEventListener('keydown', onPopKey, true); } } function onPopKey(e) { if (e.key === 'Escape') { e.stopPropagation(); closePopover(); } } function onPopDown(e) { if (!popEl || popEl.contains(e.target)) return; closePopover(); // swallow the trailing click so dismissing doesn't activate whatever's underneath (#305) const swallow = ev => { ev.stopPropagation(); ev.preventDefault(); document.removeEventListener('click', swallow, true); }; document.addEventListener('click', swallow, true); setTimeout(() => document.removeEventListener('click', swallow, true), 500); } async function doCopyFrom(gid, srcLabel) { if (!gid) return; const here = RE() && RE().state.entity; if (!here) return; if (gid.toLowerCase() === (here.gid || '').toLowerCase()) { toast('That’s this release'); return; } const a = cloneBtnRef && cloneBtnRef.getBoundingClientRect(); const ax = a ? a.left : 120, ay = a ? a.bottom + 4 : 120; closePopover(); toast('Fetching…'); let specs; try { specs = await fetchReleaseRels(gid); } catch (e) { toast('Copy failed: ' + (e && e.message || e)); return; } if (!specs.length) { toast('No artist/label credits on that release'); return; } // show a checklist of what will be copied (like the recording/work menus), with format-aware // cleansing against THIS release's format (cross-format copy is where cleansing matters) const entries = specs.map(s => ({ rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : '') })); const chosen = () => entries.filter(e => !e.cb || e.cb.checked).map(e => e.rel); const fmt = releaseFormat(), exRoles = formatExcludeRolesFor(fmt); let excluded = 0; if (exRoles.length) entries.forEach(e => { const rn = ltName(e.rel.linkTypeID).toLowerCase(); if (exRoles.some(k => rn.includes(k))) { e.checked = false; e.text += ` — off (not typical for ${fmt})`; excluded++; } }); const copyItem = { label: 'Copy', sub: String(chosen().length), run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, [here])) markUsed(`Copied ${c.length} release credit${c.length > 1 ? 's' : ''} from ${srcLabel ? `“${srcLabel}” (${gid})` : gid}`); toast(`Copied ${c.length} release credit${c.length > 1 ? 's' : ''} — review & save`); } }; const items = [{ header: 'Copy release credits' }]; if (excluded) items.push({ note: `${excluded} pre-unticked for format “${fmt}”` }); items.push({ checklist: entries, onToggle: () => { copyItem._setSub && copyItem._setSub(String(chosen().length)); } }, copyItem); openMenu(ax, ay, items); } async function loadRgReleases(list) { try { const here = RE().state.entity.gid; const rg = await (await mbnFetch('/ws/2/release/' + here + '?inc=release-groups&fmt=json', { headers: { Accept: 'application/json' } })).json(); const rgid = rg['release-group'] && rg['release-group'].id; if (!rgid) { list.textContent = ''; list.appendChild(el('div', 'gt-pop-note', 'No release group')); return; } const sib = await (await mbnFetch('/ws/2/release?release-group=' + rgid + '&inc=media&limit=100&fmt=json', { headers: { Accept: 'application/json' } })).json(); const rels = (sib.releases || []).filter(r => r.id !== here); list.textContent = ''; if (!rels.length) { list.appendChild(el('div', 'gt-pop-note', 'No other releases in this group')); return; } for (const r of rels) { const b = el('div', 'gt-pop-rel'); const info = el('button', 'gt-pop-rel-info'); info.appendChild(el('span', 'gt-pop-rel-t', r.title + (r.disambiguation ? ` (${r.disambiguation})` : ''))); const fmt = [...new Set((r.media || []).map(m => m.format).filter(Boolean))].join(' + '); const tracks = (r.media || []).reduce((s, m) => s + (m['track-count'] || 0), 0); const meta = [r.date, r.country, fmt, tracks ? tracks + ' tracks' : ''].filter(Boolean).join(' · '); if (meta) info.appendChild(el('span', 'gt-pop-rel-m', meta)); info.title = 'Copy this release’s credits onto this one'; info.onclick = () => doCopyFrom(r.id, r.title + (r.disambiguation ? ` (${r.disambiguation})` : '')); const open = el('a', 'gt-pop-rel-open', '↗'); // ↗ open the release in a new tab to inspect first open.href = '/release/' + r.id; open.target = '_blank'; open.rel = 'noopener'; open.title = 'Open this release in a new tab'; open.addEventListener('click', ev => ev.stopPropagation()); b.appendChild(info); b.appendChild(open); list.appendChild(b); } } catch (e) { list.textContent = ''; list.appendChild(el('div', 'gt-pop-note', 'Could not load release group')); } } function openCopyFromPopover(anchor) { closePopover(); popEl = el('div', 'gt-pop'); popEl.appendChild(el('div', 'gt-pop-hdr', 'Copy release credits from…')); const list = el('div', 'gt-pop-list'); list.appendChild(el('div', 'gt-pop-note', 'Loading release group…')); popEl.appendChild(list); popEl.appendChild(el('div', 'gt-sep')); // paste-to-copy: a (+) that unrolls into a field and acts immediately on paste — no Copy button // (same idiom as Apollo's link/artist add + ISRC Scout). Enter is a fallback for typed input. const add = el('div', 'gt-pop-add'); const plus = el('button', 'gt-pop-add-btn'); plus.type = 'button'; plus.textContent = '+ from a release URL / MBID'; const inp = el('input', 'gt-pop-tf gt-hidden'); inp.type = 'text'; inp.placeholder = 'paste a release URL / MBID…'; const fromInput = () => { const m = (inp.value || '').match(GID_RE); if (m) doCopyFrom(m[0]); }; plus.onclick = () => { plus.classList.add('gt-hidden'); inp.classList.remove('gt-hidden'); try { inp.focus(); } catch (e) {} }; inp.addEventListener('paste', () => setTimeout(fromInput, 0)); // wait for the pasted text to land in .value inp.addEventListener('keydown', e => { if (e.key === 'Enter') { e.preventDefault(); fromInput(); } }); add.appendChild(plus); add.appendChild(inp); popEl.appendChild(add); document.body.appendChild(popEl); const a = anchor.getBoundingClientRect(), r = popEl.getBoundingClientRect(); popEl.style.left = Math.max(8, Math.min(a.left, window.innerWidth - r.width - 8)) + 'px'; popEl.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0); loadRgReleases(list); } // #372 standard config window — title bar (icon + name + version + Help) then a body of options, // matching the other userscripts' settings dialogs. function openAboutPopover(anchor) { closePopover(); popEl = el('div', 'gt-pop gt-cfg-pop'); // #563: the shared config title bar. Built as markup rather than assembled by // hand, so this header cannot drift from the other six again. const hd = el('div'); hd.innerHTML = mbuCfgHeader({ script: 'group_therapy', name: 'Group Therapy', version: VERSION, icon: '<img src="https://raw.githubusercontent.com/majkinetor/musicbrainz-userscripts/main/userscripts/group_therapy/icon.svg" alt="">' }); // a broken icon URL should leave a tidy header, not a broken-image glyph const hdIc = hd.querySelector('.mbu-cfg-ic img'); if (hdIc) hdIc.onerror = () => hdIc.remove(); popEl.appendChild(hd.firstElementChild); const body = el('div', 'gt-cfg-body'); const opt = (label, hint, get, set) => { const l = el('label', 'gt-cfg-opt'); l.title = hint; const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = get(); cb.onchange = () => set(cb.checked); l.append(cb, el('span', null, label)); return l; }; body.appendChild(opt('Hide help text', 'Hide the two MusicBrainz help paragraphs at the top of the edit-relationships page', () => gtHideHelp, v => { gtHideHelp = v; try { GM_setValue('gt-hide-help', v); } catch (e) {} gtApplyHelp(); })); body.appendChild(opt('Hide native batch tools', "Hide MusicBrainz's own batch-tools table (#batch-tools) on the edit-relationships page", () => gtHideBatch, v => { gtHideBatch = v; try { GM_setValue('gt-hide-batch', v); } catch (e) {} gtApplyBatch(); })); body.appendChild(opt('Auto-match on start', 'Open the work matcher and run matching automatically when the page loads', () => gtAutoMatch, v => { gtAutoMatch = v; try { GM_setValue('gt-auto-match', v); } catch (e) {} })); body.appendChild(opt('Auto-match on open', 'When you open the work matcher, run matching automatically — off (default): the popup opens with everything unresolved and you click ⚡ Match yourself', () => wmAutoOnOpen, v => { wmAutoOnOpen = v; try { GM_setValue('gt-wm-auto-open', v); } catch (e) {} })); body.appendChild(opt('Uncollapse media on start', 'On load, click MusicBrainz’s “Expand all mediums” so every medium’s tracks are reachable during the fill phase (MB collapses mediums past the first few). Off by default — expanding a large release takes a moment.', () => gtUncollapse, v => { gtUncollapse = v; try { GM_setValue('gt-uncollapse', v); } catch (e) {} if (v) gtApplyUncollapse(); })); popEl.appendChild(body); document.body.appendChild(popEl); const a = anchor.getBoundingClientRect(), r = popEl.getBoundingClientRect(); popEl.style.left = Math.max(8, Math.min(a.right - r.width, window.innerWidth - r.width - 8)) + 'px'; popEl.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0); } function injectCloneButton() { const content = document.getElementById('content'); if (!content) return false; if (content.querySelector('.gt-toolbar')) { gtApplyHelp(); gtApplyBatch(); gtApplyUncollapse(); return true; } // wait until the relationship editor has rendered: its release section (#722 by id, as MB // translates the "Release relationships" heading) if (!document.getElementById('release-rels')) return false; const bar = el('div', 'gt-toolbar'); const b = el('button', 'gt-clone-btn', '⧉ Copy from release…'); b.title = 'Copy release-level credits (artists, labels) from another release onto this one'; b.type = 'button'; b.onclick = () => openCopyFromPopover(b); bar.appendChild(b); cloneBtnRef = b; const cons = el('button', 'gt-clone-btn', '▦ Consolidate RG…'); cons.title = 'Spread release-level credits across every release in this group (union minus format-specific)'; cons.type = 'button'; cons.onclick = () => openConsolidate(); bar.appendChild(cons); const wm = el('button', 'gt-clone-btn', '◎ Match works…'); wm.title = 'Match each recording to an existing MusicBrainz work (via ISRC + title/artist siblings) and stage recording→work “performance” relationships'; wm.type = 'button'; wm.onclick = () => openWorkMatch(); bar.appendChild(wm); const tp = el('button', 'gt-clone-btn', '✎ Text parser…'); tp.title = 'Parse pasted or annotation credit text (e.g. "Role: Artist") into release relationships'; tp.type = 'button'; tp.onclick = () => openTextParser(); bar.appendChild(tp); // #365 one "Vertical:" section — ⬆ pushes the release's credits DOWN to its recordings, ⬇ pulls the // recordings' credits UP to the release (icons per majkinetor: up = release→recordings, down = →release) const vwrap = el('span', 'gt-vert'); vwrap.appendChild(el('span', 'gt-vert-lbl', 'Vertical:')); const r2r = el('button', 'gt-clone-btn gt-vert-btn', '⬆'); r2r.title = 'Release → recordings: copy (or move) the release-level credits onto its recordings — the ticked ones, or all if none ticked'; r2r.type = 'button'; r2r.onclick = () => openRelToRec(r2r); const c2r = el('button', 'gt-clone-btn gt-vert-btn', '⬇'); c2r.title = 'Recordings → release: collect the recordings’ credits onto the release — a union across all tracks (shows the track range each covers)'; c2r.type = 'button'; c2r.onclick = () => openRecToRel(c2r); vwrap.append(r2r, c2r); bar.appendChild(vwrap); const cfg = el('button', 'gt-cfg-btn', MBU_CFG_ICON); cfg.type = 'button'; cfg.title = 'Group Therapy — options, about / help'; cfg.onclick = () => openAboutPopover(cfg); bar.appendChild(cfg); // #372 the toolbar goes at the top of the tab (right after the entity tabs), not on the heading const tabs = content.querySelector(':scope > .tabs'); content.insertBefore(bar, tabs ? tabs.nextSibling : content.firstChild); gtApplyHelp(); gtApplyBatch(); gtApplyUncollapse(); // #372 auto-open + match — but skip it when every recording already has a work (nothing to do) if (gtAutoMatch) setTimeout(() => { try { if (wmRecordings().some(r => !r.hasWorkOnPage)) openWorkMatch(true); } catch (e) {} }, 500); // "Auto-match on start" opens AND runs, regardless of the per-open toggle return true; } const ltName = id => (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type[id] || {}).name || String(id); // #491: does this link type even support relationship-level dates? "recorded at" is defined for BOTH // recording-place (has_dates true — the Place entity carries no date of its own) AND recording-event // (has_dates false — the Event entity already has one), so name-based matching alone can't tell them // apart. has_dates is missing from the cache when the type hasn't loaded yet — default true (unknown) // rather than silently hiding a genuinely datable row. const ltHasDates = id => { const lt = W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type[id]; return !lt || lt.has_dates !== false; }; if (mbuTestHooks()) window.__gtTest = Object.assign(window.__gtTest || {}, { ltHasDates }); // test hook only (#491) — no behaviour change // grouping key for a copy spec: link type + its attribute typeIDs (so drums ≠ shakers ≠ vocals) const roleKeyOfSpec = s => { let a = ''; try { if (s.attributes) a = [...W.MB.tree.iterate(s.attributes)].map(x => x.typeID).sort((p, q) => p - q).join(','); } catch (e) {} return s.linkTypeID + '#' + a; }; // display label for a copy spec — MB's own rendered role label when the rel is on the page // ("drums (drum set)", "background vocals"); else the link type + resolved attribute names. function roleLabelOf(s) { if (s.item) { const l = pickRoleLabel(s.item.closest && s.item.closest('tr')); if (l && l !== 'role') return l; } const base = ltName(s.linkTypeID), parts = []; try { if (s.attributes) { const lat = W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type; for (const a of W.MB.tree.iterate(s.attributes)) { const nm = (a.type && a.type.name) || (lat && lat[a.typeID] && lat[a.typeID].name); if (nm) parts.push(a.text_value ? `${nm}: ${a.text_value}` : nm); } } } catch (e) {} return parts.length ? `${base} (${parts.join(', ')})` : base; } const trackPosOfRow = tr => posLabel(tr); // ── format-aware cleansing (#338, #349) ─────────────────────────────────── // When copying/consolidating credits onto a destination of a given FORMAT, credits whose ROLE is // format-inappropriate start UNTICKED (re-tick to override). Two layers: // FORMAT_EXCLUDE — format-substring → role-substrings dropped FOR that format (physical-only roles off // a digital edition). Override via GM value 'gt-format-exclude' (JSON object). // FORMAT_ONLY — role-substring → the ONLY format families it belongs to; dropped from every OTHER // format. Lacquer cutting is vinyl-only; glass mastering is optical-disc-only (CD/DVD/SACD/Blu-ray). // Override via GM value 'gt-format-only' (JSON object). const FORMAT_EXCLUDE_DEFAULT = { digital: ['vinyl', 'pressed', 'printed', 'manufactured'] }; const FORMAT_ONLY_DEFAULT = { lacquer: ['vinyl'], glass: ['cd', 'dvd', 'sacd', 'blu-ray'] }; const _gmJson = (key, def) => { try { const raw = (typeof GM_getValue === 'function') && GM_getValue(key, ''); if (raw) return JSON.parse(raw); } catch (e) {} return def; }; function formatExcludeMap() { return _gmJson('gt-format-exclude', FORMAT_EXCLUDE_DEFAULT); } function formatOnlyMap() { return _gmJson('gt-format-only', FORMAT_ONLY_DEFAULT); } function formatExcludeRolesFor(fmt) { fmt = (fmt || '').toLowerCase(); if (!fmt) return []; const out = [], excl = formatExcludeMap(), only = formatOnlyMap(); for (const k in excl) if (fmt.includes(String(k).toLowerCase())) out.push(...(excl[k] || [])); for (const role in only) if (!(only[role] || []).some(f => fmt.includes(String(f).toLowerCase()))) out.push(role); return [...new Set(out.map(s => String(s).toLowerCase()))]; } // ── format-family markers (#350): collapse any MB format to Digital / Vinyl / CD / Cassette, drawn as a // compact colored badge (full format in the tooltip). Optical (DVD/SACD/Blu-ray) folds into CD. /* #564 follow-up (majkinetor, RG Consolidation, light theme): "still has format button issues … D and CD". Not the label this time — badgeInk already picks a readable ink — but the FILL against a white page: Digital #4a90d9 3.34:1 CD #7d8894 3.61:1 Vinyl #3a3f47 10.60:1 which is why LP looked like a badge and D and CD looked washed out beside it. Darkened so every family sits at 5:1 or better on white, which also puts all four on white ink — the LP treatment, applied consistently. badgeInk still chooses; it just now chooses the same thing for all of them. */ const FMT_FAMILY = { Digital: { label: 'D', color: '#2f6fb0' }, Vinyl: { label: 'LP', color: '#3a3f47' }, CD: { label: 'CD', color: '#556270' }, Cassette: { label: 'MC', color: '#8a5f37' } }; function formatFamily(f) { f = (f || '').toLowerCase(); if (/cassette|tape/.test(f)) return 'Cassette'; if (/vinyl|shellac|flexi/.test(f)) return 'Vinyl'; if (/cd|sacd|dvd|blu.?ray|hd.?dvd|minidisc|umd|disc/.test(f)) return 'CD'; if (/digital|file|download|stream|web/.test(f)) return 'Digital'; return ''; } const formatFamilies = fmt => [...new Set((fmt || '').split('+').map(formatFamily).filter(Boolean))]; /* #564 (majkinetor): "Format icons are not visible clearly in both themes", with a shot of each. The badge background is the format's identity colour — vinyl near-black, CD mid-grey — while the label was always var(--mbu-text-on-accent), i.e. white. A fixed foreground over a fixed palette has to fail somewhere: white on the CD grey is ~2.6:1 on either theme, and the near-black vinyl badge disappears into a dark page entirely. So the label picks itself, from the background it actually sits on: whichever of black or white has more contrast there. That holds for every format and every theme without a per-theme table, and it improves the light theme too, which was never the complaint but was just as wrong. */ function badgeInk(hex) { const h = String(hex).replace('#', ''); const n = h.length === 3 ? h.split('').map(c => c + c).join('') : h; const ch = i => parseInt(n.slice(i * 2, i * 2 + 2), 16) / 255; const lin = v => (v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)); const L = 0.2126 * lin(ch(0)) + 0.7152 * lin(ch(1)) + 0.0722 * lin(ch(2)); const onWhite = 1.05 / (L + 0.05); // contrast of #fff against it const onBlack = (L + 0.05) / 0.05; // contrast of #000 against it return onBlack >= onWhite ? '#000' : '#fff'; } function fmtBadges(fmt) { const wrap = el('span', 'gt-fmt'); for (const fam of formatFamilies(fmt)) { const b = el('span', 'gt-fmt-b', FMT_FAMILY[fam].label); b.style.background = FMT_FAMILY[fam].color; b.style.color = badgeInk(FMT_FAMILY[fam].color); b.title = fam + (fmt && fmt !== fam ? ` (${fmt})` : ''); wrap.appendChild(b); } return wrap; } // ── Consolidate RG (#349) ────────────────────────────────────────────────── // Build a (role, entity) × release matrix of every release's release-level credits — artist/label/place // entity credits. URLs are EXCLUDED (edition-specific: each release has its own discogs / streaming / // purchase link, so spreading them is wrong); recording/work are excluded too (shared already). Propose // the union minus format-specific roles, and let the user // toggle cells, whole columns (click a header letter), or the whole matrix (Auto select). Apply POSTs // the additions as edit_type:90 relationship-creates to /ws/js/edit/create — the internal endpoint ISRC // Scout uses (session-cookie auth, no CSRF). We read via /ws/js for the NUMERIC linkTypeID + entity // credits the edit API needs; formats come from the RG enumeration. const sleep = ms => new Promise(r => setTimeout(r, ms)); // fetch one release's release-level rels, retrying on rate-limit (429/503) and transient errors with backoff async function consFetchRels(gid, tries = 4) { for (let i = 0; i < tries; i++) { try { const res = await mbnFetch('/ws/js/entity/' + gid + '?inc=rels', { credentials: 'include', headers: { Accept: 'application/json' } }); if ((res.status === 429 || res.status === 503) && i < tries - 1) { await sleep(700 * (i + 1)); continue; } if (!res.ok) throw new Error('HTTP ' + res.status); const j = await res.json(); return (j.relationships || []).filter(r => r.target && r.target.gid && !['recording', 'work', 'url'].includes(r.target_type)); } catch (e) { if (i === tries - 1) throw e; await sleep(600 * (i + 1)); } } return []; } // #624 (majkinetor): "RG Consolidation reports no RG due to error/rate limit" — on a release that // plainly had 3 versions. The WS2 reads here never looked at the response: a throttled 503 is JSON // too ({"error": …}), so it parsed as "no release group" / "no releases". Every WS2 read in the // dialog now goes through the /ws/2 gate (the shared one since #633), retries 429/503 after its hold (Retry-After is a // floor, never a replacement — MB sends "Retry-After: 0"), and THROWS when it gives up, so a // failure is reported as a failure, never as an empty result. async function consWs2(url, tries = 5) { let last = ''; for (let i = 0; i < tries; i++) { await mbnSlot({ label: url, log: gtGateLog }); // the shared gate (#633): its pace, and any hold MusicBrainz asked for let r; try { r = await fetch(url, { headers: { Accept: 'application/json' } }); } catch (e) { last = (e && e.message) || 'network error'; console.debug('[Group Therapy] consolidate:', url, 'failed:', last); await sleep(700 * (i + 1)); continue; } if ((await mbnAnswer(r.status, n => r.headers.get(n), { log: gtGateLog })).throttled) { last = 'MusicBrainz is rate-limiting (HTTP ' + r.status + ')'; console.debug(`[Group Therapy] consolidate: ${url} → ${r.status}` + (i < tries - 1 ? `, retry ${i + 1}/${tries - 1} after the hold` : ', giving up')); if (i < tries - 1) continue; // the next slot waits out the shared hold break; } if (!r.ok) throw new Error('HTTP ' + r.status); return await r.json(); } throw new Error(last || 'request failed'); } // run worker over items with a bounded number of concurrent tasks (parallel, but throttled) async function throttledMap(items, worker, concurrency = 4) { const out = new Array(items.length); let idx = 0; const run = async () => { while (idx < items.length) { const i = idx++; try { out[i] = await worker(items[i], i); } catch (e) { out[i] = null; } } }; await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, run)); return out; } const consKey = r => [r.linkTypeID, r.target.gid, (r.attributes || []).map(a => a.typeID).sort((p, q) => p - q).join(','), r.entity0_credit || '', r.entity1_credit || ''].join('|'); // #371 the link type alone is generic ("instrument"); the specific instrument (guitar, tuba, drums …) // lives in the attributes — resolve their names so every instrument row is distinguishable, matching // roleLabelOf. Consolidation rels come from /ws/js (plain array, typeID only), so resolve via linkedEntities. const consRole = r => { const base = ltName(r.linkTypeID), lat = W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type; const parts = (r.attributes || []).map(a => { const nm = (a.type && a.type.name) || (lat && lat[a.typeID] && lat[a.typeID].name); return nm ? (a.text_value ? `${nm}: ${a.text_value}` : nm) : null; }).filter(Boolean); return parts.length ? `${base} (${parts.join(', ')})` : base; }; const consLabel = r => { const ent = r.target_type === 'url' ? (r.target.name || '').replace(/^https?:\/\/(www\.)?/, '').replace(/\/+$/, '') : (r.target.name || '?'); const credit = (r.entity0_credit && r.entity0_credit !== r.target.name && r.entity0_credit) || (r.entity1_credit && r.entity1_credit !== r.target.name && r.entity1_credit) || ''; return { role: consRole(r), ent, credit }; }; const consExcluded = (row, rel) => formatExcludeRolesFor(rel.fmt).some(k => row.label.role.toLowerCase().includes(k)); // ── edit_type:90 relationship-create payload for adding `r` onto release `relGid` ── function consAttrs(r) { return (r.attributes || []).map(a => { const gid = (a.type && a.type.gid) || ((W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type || {})[a.typeID] || {}).gid; if (!gid) return null; const o = { type: { gid } }; if (a.credited_as) o.credited_as = a.credited_as; if (a.text_value) o.text_value = a.text_value; return o; }).filter(Boolean); } function consEdit(r, relGid) { const lt = ((W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {})[r.linkTypeID] || {}; const relEnd = { entityType: 'release', gid: relGid }, targetEnd = { entityType: r.target_type, gid: r.target.gid }; // entities must match the link type's type0/type1 order; release→url has release as entity0 const relIsE0 = lt.type0 === 'release' || r.target_type === 'url'; const e = { edit_type: 90, linkTypeID: r.linkTypeID, entities: relIsE0 ? [relEnd, targetEnd] : [targetEnd, relEnd], attributes: consAttrs(r) }; if (r.entity0_credit) e.entity0_credit = r.entity0_credit; if (r.entity1_credit) e.entity1_credit = r.entity1_credit; if (r.begin_date) e.begin_date = r.begin_date; if (r.end_date) e.end_date = r.end_date; if (r.ended) e.ended = true; return e; } let consEl = null; function onConsKey(e) { if (e.key === 'Escape') { e.stopPropagation(); closeConsolidate(); } } function closeConsolidate() { if (consEl) { consEl.remove(); consEl = null; document.removeEventListener('keydown', onConsKey, true); } } // ══ Date picker (#398) ═════════════════════════════════════════════════════ // Redesign of "Set missing dates". Right-clicking a dated rel opens a popup listing every credit on the // SELECTED tracks (ticked recordings, or all when none are ticked) as a track→credits tree. The header // carries an EDITABLE date — seeded from the invoking rel, so the tool no longer depends on that rel's // own date — plus the list of ROLES that drive the DEFAULT tick (persisted across sessions: right-click a // credit's role to remember it, click a header chip to drop it). Any credit can also be ticked by hand. // Apply stamps the header date on every ticked, still-UNDATED rel (fill-only — MB's reducer keeps an // existing date, see the note on applyRelDate; already-dated rows are shown with their date, unchanged). const DATE_ROLES_DEFAULT = ['instrument', 'recording', 'recorded at', 'vocals', 'performer']; let gtDateRoles = (() => { try { const raw = GM_getValue('gt-date-roles', ''); if (raw) { const a = typeof raw === 'string' ? JSON.parse(raw) : raw; if (Array.isArray(a)) return a.map(String); } } catch (e) {} return DATE_ROLES_DEFAULT.slice(); })(); const saveDateRoles = () => { try { GM_setValue('gt-date-roles', JSON.stringify(gtDateRoles)); } catch (e) {} }; // null = empty (clear), undefined = unparsable (keep prior); accepts YYYY, YYYY-MM, YYYY-MM-DD (/ or . too) const dpParseDate = s => { s = (s || '').trim(); if (!s) return null; const m = s.match(/^(\d{1,4})(?:[-/.](\d{1,2}))?(?:[-/.](\d{1,2}))?$/); if (!m) return undefined; const d = { year: +m[1] || null, month: m[2] ? +m[2] : null, day: m[3] ? +m[3] : null }; return (d.year || d.month || d.day) ? d : null; }; const dpDateStr = d => { if (!d) return ''; const p = (n, w) => n == null ? '' : String(n).padStart(w, '0'); return [p(d.year, 4), d.month != null ? p(d.month, 2) : '', d.day != null ? p(d.day, 2) : ''].filter(Boolean).join('-'); }; const dpDated = r => !!((r.begin_date && r.begin_date.year) || (r.end_date && r.end_date.year) || r.ended); // a credit is a default match when any remembered role token matches its base link-type name OR its full // rendered label (so "instrument" catches "drums (drum set)" via the base, "vocals" catches "background // vocals" via the label; x.includes(base) makes the stored token tolerant of plurals like vocal↔vocals) const dpRoleMatches = r => { const base = ltName(r.linkTypeID).toLowerCase(); const full = (roleLabelOf(r) || '').toLowerCase(); return gtDateRoles.some(x => { x = String(x).toLowerCase(); return !!x && (base === x || full === x || full.includes(x) || x.includes(base)); }); }; const dpRows = () => { const all = [...document.querySelectorAll('tr.track')]; const t = all.filter(tr => { const cb = tr.querySelector('input.recording'); return cb && cb.checked; }); return t.length ? t : all; }; let dpEl = null; function onDpKey(e) { if (e.key === 'Escape') { e.stopPropagation(); closeDatePicker(); } } function closeDatePicker() { if (dpEl) { dpEl.remove(); dpEl = null; document.removeEventListener('keydown', onDpKey, true); } } function openDatePicker(clickedRel) { closeDatePicker(); closeConsolidate(); closePopover(); const state = { begin_date: clickedRel && clickedRel.begin_date ? { ...clickedRel.begin_date } : null, end_date: clickedRel && clickedRel.end_date ? { ...clickedRel.end_date } : null, ended: !!(clickedRel && clickedRel.ended), }; const skipItem = clickedRel && clickedRel.item; const manual = new Map(); // credit id → bool: user override of the role-driven default tick let uid = 0; const tracks = dpRows().map(tr => { const credits = recordingRels(tr) // #491: exclude relationship types that don't support dates at all (e.g. recording-event // "recorded at" — the Event carries its own date) so this tool can't offer, pre-select, or // stamp a date onto something MB has no date field for in the first place. .filter(r => !r.removed && r.other && r.other.entityType !== 'url' && r.item !== skipItem && ltHasDates(r.linkTypeID)) .map(r => ({ id: ++uid, rel: r, tr, dated: dpDated(r), name: val(r.other.name) || '?', credit: r.credit && r.credit !== val(r.other.name) ? r.credit : '' })); return { tr, pos: posLabel(tr) || '?', title: val((recordingEntity(tr) || {}).name) || '', credits }; }).filter(t => t.credits.length); const allCredits = tracks.flatMap(t => t.credits); const isChecked = c => manual.has(c.id) ? manual.get(c.id) : (dpRoleMatches(c.rel) && !c.dated); const chosen = () => allCredits.filter(isChecked); dpEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons gt-dp'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Set relationship dates')); const xb = el('button', 'gt-cons-x', '✕'); xb.type = 'button'; xb.onclick = closeDatePicker; hdr.appendChild(xb); const ctrl = el('div', 'gt-dp-ctrl'), dateLine = el('div', 'gt-dp-line'); dateLine.appendChild(el('span', 'gt-dp-lbl', 'Date')); const bIn = el('input', 'gt-dp-date'); bIn.type = 'text'; bIn.placeholder = 'YYYY-MM-DD'; bIn.value = dpDateStr(state.begin_date); const eIn = el('input', 'gt-dp-date'); eIn.type = 'text'; eIn.placeholder = 'YYYY-MM-DD'; eIn.value = dpDateStr(state.end_date); const endedL = el('label', 'gt-dp-ended'); const endedCb = el('input'); endedCb.type = 'checkbox'; endedCb.checked = state.ended; endedL.append(endedCb, el('span', null, 'ended')); const commitDates = () => { const b = dpParseDate(bIn.value), e2 = dpParseDate(eIn.value); bIn.classList.toggle('gt-dp-bad', b === undefined); eIn.classList.toggle('gt-dp-bad', e2 === undefined); if (b !== undefined) state.begin_date = b; if (e2 !== undefined) state.end_date = e2; state.ended = endedCb.checked; }; bIn.oninput = eIn.oninput = commitDates; endedCb.onchange = commitDates; dateLine.append(bIn, el('span', 'gt-dp-dash', '—'), eIn, endedL); const rolesLine = el('div', 'gt-dp-line gt-dp-roles'); ctrl.append(dateLine, rolesLine); const body = el('div', 'gt-cons-body gt-dp-body'), foot = el('div', 'gt-cons-foot'); const plan = el('div', 'gt-cons-plan'), applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; foot.append(plan, applyBtn); const refreshCounts = () => { const n = chosen().length; plan.textContent = n ? `${n} relationship${n > 1 ? 's' : ''} selected` : 'nothing selected'; applyBtn.disabled = !n; }; const setTrackState = (t, tcb) => { const on = t.credits.filter(isChecked).length; tcb.checked = on === t.credits.length && on > 0; tcb.indeterminate = on > 0 && on < t.credits.length; }; let renderBody; const renderRoles = () => { rolesLine.textContent = ''; rolesLine.appendChild(el('span', 'gt-dp-lbl', 'Roles')); if (!gtDateRoles.length) rolesLine.appendChild(el('span', 'gt-dp-hint', 'none — right-click a role below to add')); gtDateRoles.forEach(r => { const chip = el('span', 'gt-dp-chip', r); chip.title = 'click to remove'; chip.appendChild(el('span', 'gt-dp-chipx', '×')); chip.onclick = () => { gtDateRoles = gtDateRoles.filter(v => v !== r); saveDateRoles(); renderRoles(); renderBody(); }; rolesLine.appendChild(chip); }); }; const addRole = r => { r = String(r).toLowerCase().trim(); if (!r || gtDateRoles.includes(r)) return; gtDateRoles.push(r); saveDateRoles(); renderRoles(); renderBody(); }; renderBody = () => { body.textContent = ''; if (!tracks.length) { body.appendChild(el('div', 'gt-pop-note', 'No datable credits on the selected tracks')); refreshCounts(); return; } tracks.forEach(t => { const trow = el('div', 'gt-dp-trow'), tlab = el('label', 'gt-dp-tlab'), tcb = el('input'); tcb.type = 'checkbox'; tlab.append(tcb, el('span', 'gt-dp-tpos', t.pos), el('span', 'gt-dp-ttitle', t.title)); trow.appendChild(tlab); setTrackState(t, tcb); tcb.onchange = () => { const want = tcb.checked; t.credits.forEach(c => manual.set(c.id, want)); renderBody(); }; const clist = el('div', 'gt-dp-clist'); t.credits.forEach(c => { const row = el('label', 'gt-dp-crow' + (c.dated ? ' gt-dp-dated' : '')), cb = el('input'); cb.type = 'checkbox'; cb.checked = isChecked(c); cb.onchange = () => { manual.set(c.id, cb.checked); setTrackState(t, tcb); refreshCounts(); }; const role = el('span', 'gt-dp-role', roleLabelOf(c.rel) || ltName(c.rel.linkTypeID)); role.title = 'right-click: remember this role for the default selection'; role.addEventListener('contextmenu', ev => { ev.preventDefault(); ev.stopPropagation(); addRole(ltName(c.rel.linkTypeID)); }); row.append(cb, role, el('span', 'gt-dp-cname', c.name + (c.credit ? ` (${c.credit})` : ''))); if (c.dated) row.append(el('span', 'gt-dp-has', fmtRoDate(c.rel))); clist.appendChild(row); }); trow.appendChild(clist); body.appendChild(trow); }); refreshCounts(); }; applyBtn.onclick = () => { commitDates(); const re = RE(); if (!re) { toast('Editor not ready'); return; } if (!(state.begin_date || state.end_date || state.ended)) { toast('Enter a date first'); return; } const picked = chosen(), undated = picked.filter(c => !c.dated); if (!undated.length) { toast(picked.length ? 'All selected already have dates (unchanged)' : 'Nothing selected'); return; } // c.rel is recordingRels' STRIPPED view (item/linkTypeID/dates but NO entity0/entity1) — applyRelDate // needs the raw fiber rel to match MB's reducer, so re-resolve it from the live .relationship-item node. // (#398 fix: apply was a silent no-op because the stripped rel had no entities to route the update.) let n = 0, miss = 0; undated.forEach(c => { try { const real = c.rel.item && relFromNode(c.rel.item); if (real) { applyRelDate(re, real, state); n++; } else miss++; } catch (e) { miss++; console.warn('[Group Therapy] set-date failed:', e && e.message); } }); const dl = fmtRoDate(state) || 'date', skipped = picked.length - undated.length; console.debug(`[Group Therapy] Date picker: set ${dl} on ${n} rel(s); ${skipped} already dated, ${miss} unresolved`); if (n) markUsed(`Set date ${dl} on ${n} relationship${n > 1 ? 's' : ''}`); toast(`Set ${dl} on ${n} relationship${n > 1 ? 's' : ''}${skipped ? ` (${skipped} already dated)` : ''} — review & save`); closeDatePicker(); }; panel.append(hdr, ctrl, body, foot); dpEl.appendChild(panel); document.body.appendChild(dpEl); dpEl.addEventListener('mousedown', e => { if (e.target === dpEl) closeDatePicker(); }); document.addEventListener('keydown', onDpKey, true); renderRoles(); renderBody(); } async function applyConsolidation(releases, rows, refresh) { const byRel = new Map(); for (const row of rows) for (const rel of releases) if (row.propose.has(rel.gid) && !row.present.has(rel.gid)) { if (!byRel.has(rel.gid)) byRel.set(rel.gid, []); byRel.get(rel.gid).push(row); } const total = [...byRel.values()].reduce((s, a) => s + a.length, 0); if (!total) { toast('Nothing selected to add'); return; } toast(`Applying ${total} edit${total > 1 ? 's' : ''} across ${byRel.size} release${byRel.size > 1 ? 's' : ''}…`); const sig = editNoteSig(); let okRel = 0, okEdits = 0; const failed = []; for (const [gid, rowsFor] of byRel) { const rel = releases.find(r => r.gid === gid); const edits = rowsFor.map(row => consEdit(row.sample, gid)); // #737: no list of the credits: MB copies the note onto each relationship's edit, which shows its own const note = `Consolidated ${edits.length} release-level credit${edits.length > 1 ? 's' : ''} across the release group onto this release\n\n${sig}`; try { const res = await mbnFetch('/ws/js/edit/create', { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ edits, editNote: note, makeVotable: 0 }) }); const txt = await res.text().catch(() => ''); let j = null; try { j = JSON.parse(txt); } catch (e) {} if (!res.ok || (j && j.error)) throw new Error((((j && (j.error.message || j.error)) || ('HTTP ' + res.status)) + '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 160)); okRel++; okEdits += edits.length; // reflect success: drop the proposal and cache the new rel on this release so the render's rebuild shows it present rowsFor.forEach(row => { row.propose.delete(gid); if (rel && rel._rels) rel._rels.push(row.sample); }); } catch (e) { failed.push(`${rel ? rel.letter : gid}: ${(e && e.message) || e}`); } refresh && refresh(); await sleep(1200); // throttle between releases } if (failed.length) toast(`Added ${okEdits} across ${okRel} release(s); ${failed.length} failed — ${failed[0]}`); else toast(`✓ Added ${okEdits} credit${okEdits > 1 ? 's' : ''} across ${okRel} release${okRel > 1 ? 's' : ''} — check your edits`); return okEdits; } // The legend doubles as the release selector: columns follow which releases are ticked. Rels are fetched // lazily (only for selected releases) and cached; rows are rebuilt from the selected set on every render, // while each row's `propose` set persists in `rowsByKey`. function renderConsMatrix(ctx) { const { body, foot, releases, rowsByKey, recompute } = ctx; body.textContent = ''; body.appendChild(el('div', 'gt-cons-leglabel', 'Releases in this group — click to add/remove a column')); const leg = el('div', 'gt-cons-leg'); releases.forEach(r => { const s = el('span', 'gt-cons-legi gt-cons-selitem' + (r.selected ? ' gt-on' : '') + (r.current ? ' gt-cur' : '')); s.appendChild(el('b', null, r.letter)); s.appendChild(fmtBadges(r.fmt)); s.appendChild(document.createTextNode(' ' + r.title)); if (r.year) s.appendChild(el('span', 'gt-cons-legyr', ' · ' + r.year)); const open = el('a', 'gt-cons-legopen', '↗'); open.href = '/release/' + r.gid; open.target = '_blank'; open.rel = 'noopener'; open.title = 'Open this release in a new tab'; open.onclick = ev => ev.stopPropagation(); s.appendChild(open); s.title = (r.selected ? 'In the matrix — click to remove its column' : 'Click to add its column') + (r.current ? ' · the release you’re editing' : ''); s.onclick = () => { r.selected = !r.selected; recompute(); }; leg.appendChild(s); }); body.appendChild(leg); const paste = el('input', 'gt-cons-paste'); paste.type = 'text'; paste.placeholder = 'paste release URLs / MBIDs to add them to the matrix…'; paste.addEventListener('paste', () => setTimeout(() => { const gids = (paste.value.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/gi) || []); let n = 0; gids.forEach(g => { const rel = releases.find(r => r.gid.toLowerCase() === g.toLowerCase()); if (rel && !rel.selected) { rel.selected = true; n++; } }); paste.value = ''; if (n) recompute(); else toast('No matching releases in this group'); }, 0)); body.appendChild(paste); // #624: releases whose credits could not be read — said so, with a Retry; never shown as "no credits" const unread = releases.filter(r => r.selected && r._relsErr); if (unread.length) { const f = el('div', 'gt-cons-fail', `⚠ Could not read ${unread.map(r => r.letter).join(', ')} — ${unread[0]._relsErr}. Shown as “?” and left out of Auto select / Apply.`); const again = el('button', 'gt-cons-btn gt-cons-retry', 'Retry'); again.type = 'button'; again.onclick = () => recompute(); f.appendChild(again); body.appendChild(f); } const cols = releases.filter(r => r.selected); // rebuild rows from the selected releases: present is recomputed, propose persists on the row objects for (const row of rowsByKey.values()) row.present = new Set(); for (const rel of cols) for (const r of (rel._rels || [])) { const k = consKey(r); let row = rowsByKey.get(k); if (!row) { row = { key: k, sample: r, label: consLabel(r), present: new Set(), propose: new Set() }; rowsByKey.set(k, row); } row.present.add(rel.gid); } const rows = [...rowsByKey.values()].filter(row => row.present.size > 0).sort((a, b) => (a.label.role + a.label.ent).localeCompare(b.label.role + b.label.ent)); foot.textContent = ''; if (!cols.length) { body.appendChild(el('div', 'gt-pop-note', 'Select one or more releases above to build the matrix.')); return; } if (!rows.length) { body.appendChild(el('div', 'gt-pop-note', unread.length && unread.length === cols.length ? 'None of the selected releases could be read — see above.' : 'No release-level credits on the selected release(s).')); return; } const tbl = el('table', 'gt-cons-tbl'), head = el('tr'); head.append(el('th', 'gt-cons-role', 'Role'), el('th', 'gt-cons-ent', 'Entity')); const addableFor = rel => rel._relsErr ? [] : rows.filter(row => !row.present.has(rel.gid) && !consExcluded(row, rel)); // not present + not format-specific; nothing on an unread release (#624) const planLbl = el('span', 'gt-cons-plan'); const applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; const updatePlan = () => { let e = 0; const rs = new Set(); rows.forEach(row => cols.forEach(rel => { if (row.propose.has(rel.gid) && !row.present.has(rel.gid)) { e++; rs.add(rel.gid); } })); planLbl.textContent = e ? `${e} addition${e > 1 ? 's' : ''} across ${rs.size} release${rs.size > 1 ? 's' : ''}` : 'nothing selected'; applyBtn.disabled = !e; }; const draw = () => { [...tbl.querySelectorAll('tr.gt-cons-row')].forEach(n => n.remove()); for (const row of rows) { const tr = el('tr', 'gt-cons-row'); tr.appendChild(el('td', 'gt-cons-role', row.label.role)); const ent = el('td', 'gt-cons-ent'); ent.appendChild(document.createTextNode(row.label.ent)); if (row.label.credit) ent.appendChild(el('span', 'gt-cons-cr', ' “' + row.label.credit + '”')); tr.appendChild(ent); for (const rel of cols) { if (rel._relsErr) { // #624 unread → unknown, not "missing" const tf = el('td', 'gt-cons-cell gt-fail', '?'); tf.title = `Couldn’t read this release’s credits (${rel._relsErr}) — left out; use Retry above`; tr.appendChild(tf); continue; } const td = el('td', 'gt-cons-cell'), has = row.present.has(rel.gid), prop = row.propose.has(rel.gid); td.classList.add(has ? 'gt-has' : prop ? 'gt-prop' : 'gt-none'); td.textContent = has || prop ? rel.letter : '·'; if (has) td.title = 'already present'; else { td.style.cursor = 'pointer'; td.title = prop ? 'will be added — click to skip' : `skipped (format-specific for ${rel.fmt || '?'}) — click to add`; td.onclick = () => { row.propose.has(rel.gid) ? row.propose.delete(rel.gid) : row.propose.add(rel.gid); draw(); updatePlan(); }; } tr.appendChild(td); } tbl.appendChild(tr); } }; cols.forEach(r => { const th = el('th', 'gt-cons-col gt-cons-colsel' + (r._relsErr ? ' gt-fail' : '')); th.appendChild(el('div', 'gt-cons-coll', r.letter)); th.appendChild(fmtBadges(r.fmt)); th.title = r._relsErr ? `${r.title} — couldn’t read its credits (${r._relsErr}); use Retry above` : `${r.title} — click to select / clear every addable credit for this release (skips format-specific)`; th.onclick = () => { const p = addableFor(r); const all = p.length && p.every(row => row.propose.has(r.gid)); p.forEach(row => all ? row.propose.delete(r.gid) : row.propose.add(r.gid)); draw(); updatePlan(); }; head.appendChild(th); }); tbl.appendChild(head); body.appendChild(tbl); const autoBtn = el('button', 'gt-cons-btn', 'Auto select'); autoBtn.type = 'button'; autoBtn.title = 'Select every addable credit across the selected releases, except format-specific roles'; const clearBtn = el('button', 'gt-cons-btn', 'Clear'); clearBtn.type = 'button'; clearBtn.title = 'Deselect every proposed credit'; autoBtn.onclick = () => { cols.forEach(rel => addableFor(rel).forEach(row => row.propose.add(rel.gid))); draw(); updatePlan(); }; clearBtn.onclick = () => { rows.forEach(row => row.propose.clear()); draw(); updatePlan(); }; applyBtn.onclick = async () => { const n = await applyConsolidation(cols, rows, () => renderConsMatrix(ctx)); if (n) closeConsolidate(); }; // close on success (no auto-scroll — leave the page where it was) foot.append(autoBtn, clearBtn, planLbl, applyBtn); draw(); updatePlan(); } async function openConsolidate() { closeConsolidate(); closePopover(); consEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Consolidate release-level credits across the group')); const x = el('button', 'gt-cons-x', '✕'); x.type = 'button'; x.onclick = closeConsolidate; hdr.appendChild(x); const body = el('div', 'gt-cons-body'), foot = el('div', 'gt-cons-foot'); body.appendChild(el('div', 'gt-pop-note', 'Loading release group…')); panel.append(hdr, body, foot); consEl.appendChild(panel); document.body.appendChild(consEl); document.addEventListener('keydown', onConsKey, true); consEl.addEventListener('mousedown', e => { if (e.target === consEl) closeConsolidate(); }); const note = m => { const n = body.querySelector('.gt-pop-note'); if (n) n.textContent = m; }; // #624: a failure says so and offers another go — it must never read as "there is nothing" const noteFail = m => { const n = body.querySelector('.gt-pop-note'); if (!n) return; n.textContent = m + ' '; const again = el('button', 'gt-cons-btn gt-cons-retry', 'Try again'); again.type = 'button'; again.onclick = () => openConsolidate(); n.appendChild(again); }; let releases; try { const ent = (RE() && RE().state && RE().state.entity) || {}; const here = ent.gid; // #624: the release group is already on the page — the relationship editor's state carries it, // and the header links it ("see all versions of this release"). No request, so no rate limit // can lose it. The WS2 lookup is only the last resort. let rgid = (ent.releaseGroup && ent.releaseGroup.gid) || null, rgFrom = rgid ? 'editor state' : ''; if (!rgid) { rgid = [...document.querySelectorAll('a[href^="/release-group/"]')].map(a => ((a.getAttribute('href') || '').match(/^\/release-group\/([0-9a-f-]{36})/i) || [])[1]).find(Boolean) || null; if (rgid) rgFrom = 'page link'; } if (!rgid && here) { const rg = await consWs2('/ws/2/release/' + here + '?inc=release-groups&fmt=json'); rgid = (rg['release-group'] && rg['release-group'].id) || null; if (rgid) rgFrom = 'WS2'; } console.debug('[Group Therapy] consolidate: release', here, '→ release group', rgid, rgFrom ? '(from ' + rgFrom + ')' : ''); if (!rgid) return note('This release has no release group'); // enumerate every release (WS2 caps at 100/page — paginate). A page that still fails after the // retries aborts the whole list: a partial group would look complete and propose the wrong things. const all = []; let offset = 0, total = Infinity; while (offset < total) { note('Loading the releases in this group…' + (Number.isFinite(total) ? ` ${all.length}/${total}` : '')); const sib = await consWs2(`/ws/2/release?release-group=${rgid}&inc=media&limit=100&offset=${offset}&fmt=json`); total = sib['release-count'] != null ? sib['release-count'] : (all.length + (sib.releases || []).length); all.push(...(sib.releases || [])); if (!(sib.releases || []).length) break; offset += 100; } console.debug('[Group Therapy] consolidate:', all.length, 'release(s) in the group'); releases = all.sort((a, b) => (a.date || '~').localeCompare(b.date || '~')).map((r, i) => ({ gid: r.id, title: r.title + (r.disambiguation ? ` (${r.disambiguation})` : ''), letter: (i < 26 ? '' : String.fromCharCode(64 + Math.floor(i / 26))) + String.fromCharCode(65 + (i % 26)), fmt: [...new Set((r.media || []).map(m => m.format).filter(Boolean))].join('+') || '', year: (r.date || '').slice(0, 4), current: r.id === here, selected: false, _rels: null, })); } catch (e) { console.warn('[Group Therapy] consolidate: loading the release group failed —', e && e.message); return noteFail(`Could not load the release group — ${(e && e.message) || 'request failed'}.`); } if (!releases || releases.length < 2) return note('Need at least 2 releases in the group to consolidate'); // auto-include all when the group is small; otherwise start with just the release we're editing (the // user picks the rest) — a 100+-release group would be an unusable wall of columns otherwise. const AUTO_MAX = 10; releases.forEach(r => r.selected = releases.length <= AUTO_MAX || r.current); const rowsByKey = new Map(); const ctx = { body, foot, releases, rowsByKey, recompute: null }; ctx.recompute = async () => { const need = releases.filter(r => r.selected && !r._rels); // #624: a release whose credits could not be read stays UNREAD (_rels null + _relsErr), shown as // "?" and left out of Auto select / Apply — not stored as "no credits", which made every credit // look missing on it and proposed adding all of them. Each recompute retries the unread ones. if (need.length) { let d = 0; note(`Reading releases… 0/${need.length}`); await throttledMap(need, async r => { try { r._rels = await consFetchRels(r.gid); r._relsErr = null; } catch (e) { r._rels = null; r._relsErr = (e && e.message) || 'request failed'; console.warn('[Group Therapy] consolidate: could not read release', r.gid, '—', r._relsErr); } note(`Reading releases… ${++d}/${need.length}`); }); } renderConsMatrix(ctx); }; await ctx.recompute(); } // ══ Work matching (#363) ═══════════════════════════════════════════════════ // Match each recording to an EXISTING MB work and stage a recording→work "performance" rel. // Disambiguation is the whole problem — a bare standard title ("St. Louis Blues") matches many works. // Two signals: (1) works of ISRC-sharing recordings — an /isrc LOOKUP returns work-rels (a /recording // SEARCH does not), the strongest when ISRCs exist; and (2) a WORK title search ranked by MB's own // score — the canonical work (bare title) scores ~100 while arrangements trail and are disambiguated, // so the top score + the gap to the runner-up say whether to auto-tick or leave it for a manual pick. const PERF_GID = 'a3005666-a872-32c3-ad06-98af558e99b0'; // recording→work "performance" link type // colours mirror Apollo's recording matcher (CONF_COLOR) const WM_LEVEL = { exact:{ c:'#2f6fd6', t:'ISRC-confirmed' }, tolerance:{ c:'#86c686', t:'the only work with this title' }, near:{ c:'#fff176', t:'dominant — most-recorded work' }, low:{ c:'#e5534b', t:'ambiguous — often wrong, check it' }, none:{ c:'#9aa0a6', t:'no work found' } }; const WM_RANK = { exact:0, tolerance:1, near:2, low:3, none:4 }; const WM_LVL_BY_RANK = ['exact', 'tolerance', 'near', 'low']; // how far ⚡ Match / the initial pre-tick reaches down the confidence ladder (persisted) let wmCutoff = (() => { try { const v = GM_getValue('gt-wm-cutoff', WM_RANK.near); return typeof v === 'number' ? v : WM_RANK.near; } catch (e) { return WM_RANK.near; } })(); // #363 whether opening the matcher auto-runs the (API-heavy) match. Default OFF — you click ⚡ Match yourself. let wmAutoOnOpen = (() => { try { return !!GM_getValue('gt-wm-auto-open', false); } catch (e) { return false; } })(); // #718 a medley track ("Land of Hope and Dreams / People Get Ready") gets a row per work. Only " / " with // spaces splits — MB's title guideline for medleys — so "AC/DC" stays whole. On by default, toggled in // the matcher's toolbar (persisted). let wmSplitMedley = (() => { try { return GM_getValue('gt-wm-split', true) !== false; } catch (e) { return true; } })(); const WM_MEDLEY_RE = /\s+\/\s+/; const wmMedleyParts = t => { const p = (t || '').split(WM_MEDLEY_RE).map(x => x.trim()).filter(Boolean); return p.length > 1 ? p : null; }; // #372 page options (persisted): hide MB's edit-relationships help text (on by default), and auto-open + // run the work matcher on page load (off by default). let gtHideHelp = (() => { try { return GM_getValue('gt-hide-help', true) !== false; } catch (e) { return true; } })(); let gtAutoMatch = (() => { try { return GM_getValue('gt-auto-match', false) === true; } catch (e) { return false; } })(); let gtHideBatch = (() => { try { return GM_getValue('gt-hide-batch', false) === true; } catch (e) { return false; } })(); // hide MB's own batch-tools table (off by default) let gtUncollapse = (() => { try { return GM_getValue('gt-uncollapse', false) === true; } catch (e) { return false; } })(); // #390 auto-expand all mediums on load (off by default) // the two help paragraphs are the only direct-child <p> of #content (the batch-tools hint + the guidelines // link) — a stable selector even after we insert our toolbar, since that's a <div>. const gtApplyHelp = () => { document.querySelectorAll('#content > p').forEach(p => { p.style.display = gtHideHelp ? 'none' : ''; }); }; const gtApplyBatch = () => { const t = document.getElementById('batch-tools'); if (t) t.style.display = gtHideBatch ? 'none' : ''; }; // #390 MB collapses mediums past the first few, so their tracks aren't reachable during the fill phase. // Click MB's own "Expand all mediums" once on load (it then loads each collapsed medium). Guarded so a // re-inject doesn't click it repeatedly. let _gtUncollapsed = false; const gtApplyUncollapse = () => { if (!gtUncollapse || _gtUncollapsed) return; const b = document.getElementById('expand-all-mediums'); if (b) { _gtUncollapsed = true; try { b.click(); } catch (e) {} gtLoadingBanner(); } }; // #390 a bottom banner while the expanded mediums load (MB renders one .loading-message per medium). // Only shown once loading is actually happening — a release whose mediums are all already loaded // (nothing to lazy-load) must not flash the banner, so it's created lazily on the first .loading-message. function gtLoadingBanner() { let banner = null, ticks = 0, seen = false; const show = n => { if (!banner) { banner = document.createElement('div'); banner.style.cssText = 'position:fixed;left:50%;bottom:14px;transform:translateX(-50%);z-index:2147483646;background:#2c3a33;color:#fff;padding:7px 14px;border-radius:8px;font:13px -apple-system,Segoe UI,Arial,sans-serif;box-shadow:0 4px 16px rgba(0,0,0,.3)'; document.body.appendChild(banner); } banner.textContent = 'Loading media… (' + n + ' left)'; }; const poll = () => { ticks++; const n = document.querySelectorAll('.loading-message').length; if (n > 0) { seen = true; show(n); setTimeout(poll, 400); return; } if (!seen && ticks < 12) { setTimeout(poll, 400); return; } // grace: wait up to ~5s for loading to begin — banner not shown yet if (banner) banner.remove(); // nothing ever loaded, or it finished → no/removed banner }; setTimeout(poll, 300); } // writer/composer relationship types — used to pull authors from a pasted work MBID (the autocomplete // already carries authors inline for searched works) const WM_WRITER_RE = /composer|writer|lyricist|librettist|translat|revis|arrang|orchestrat/i; // create a synthetic NEW work (negative id, no gid) — MB's submit creates it like a natively-added work // (verified: the reducer accepts it and renders a pending new-work rel). Same-title new works within a // session share one entity, so two unmatched same-title tracks don't spawn duplicate works. let wmNewSeq = -1000000; const wmNewWorks = new Map(); // #363 optional params applied to every new work created this session — Type + lyrics language(s), like // MB's own "Batch-add new works" dialog. Both catalogues live on the page (MB.linkedEntities), so there's // no pagination/fetch: cache them once for the searchable combos. Choices are NOT persisted (per maintainer). let _wmTypesCache = null, _wmLangsCache = null; const wmWorkTypes = () => (_wmTypesCache || (_wmTypesCache = Object.values((W.MB.linkedEntities && W.MB.linkedEntities.work_type) || {}).slice().sort((a, b) => a.name.localeCompare(b.name)))); const wmLanguages = () => (_wmLangsCache || (_wmLangsCache = Object.values((W.MB.linkedEntities && W.MB.linkedEntities.language) || {}).filter(l => l.frequency > 0 || l.name).sort((a, b) => (b.frequency - a.frequency) || a.name.localeCompare(b.name)))); let wmNewType = null; // work-type id (number) or null // #363 default the work type to "Song" once the work_type catalogue is available (it isn't at script-init). // Runs once; a later user pick sticks (the flag stays set so we never override it). let _wmTypeInit = false; const wmDefaultType = () => { if (_wmTypeInit) return; const s = wmWorkTypes().find(t => /^song$/i.test(t.name || '')); if (s) { wmNewType = s.id; _wmTypeInit = true; } }; let wmNewLangs = []; // array of MB language objects const wmLangRels = () => wmNewLangs.map(l => ({ language: l, last_updated: null })); // MB's work.languages shape // #363 attributes + dates that go on the recording→work "recording of" RELATIONSHIP (not the work) — // acappella/cover/demo/instrumental/karaoke/live/medley/partial + begin/end date + ended. Eligible // attributes are read from the performance link type itself, so we track whatever MB currently allows. const wmRelAttrs = new Set(); // selected attribute typeIDs let wmBegin = { year: null, month: null, day: null }, wmEnd = { year: null, month: null, day: null }, wmEnded = false; let _wmPerfAttrsCache = null; const wmPerfAttrs = () => (_wmPerfAttrsCache || (_wmPerfAttrsCache = (() => { const le = W.MB.linkedEntities, lat = le && le.link_attribute_type, perf = le && Object.values(le.link_type || {}).find(t => t.gid === PERF_GID); if (!perf || !perf.attributes || !lat) return []; return Object.keys(perf.attributes).map(k => { const id = perf.attributes[k].type_id || +k; return { typeID: id, name: lat[id] && lat[id].name }; }).filter(a => a.name).sort((a, b) => a.name.localeCompare(b.name)); })())); const wmHasDates = () => !!(wmBegin.year || wmBegin.month || wmBegin.day || wmEnd.year || wmEnd.month || wmEnd.day || wmEnded); // the attribute tree + dates object to hand dispatchRelationship for a recording→work rel const wmRelExtras = () => ({ attrs: wmRelAttrs.size ? buildAttrTree([...wmRelAttrs].map(id => ({ typeID: id }))) : null, dates: wmHasDates() ? { begin_date: wmBegin, end_date: wmEnd, ended: wmEnded } : null, }); // push the current Type/language choice onto every new work already staged (params can change after some // were created — MB reads these off the entity on submit) function wmApplyNewParams() { wmNewWorks.forEach(w => { w.typeID = wmNewType; w.languages = wmLangRels(); }); } function wmMakeNewWork(title) { const key = (title || '').normalize('NFC').toLowerCase().replace(/\s+/g, ' ').trim(); if (key && wmNewWorks.has(key)) return wmNewWorks.get(key); // Mirror EXACTLY the entity MB's own "Batch-add new works" produces (captured from the reducer): a // negative id + empty-string gid, every field the editor reads present with an empty default, and the // `_fromBatchCreateWorksDialog` flag. That flag is what marks it as a to-be-created work — without it MB // rejected the target ("must select … target entity") and threw loading its relationships ("e is null"). // typeID + languages carry the optional #363 params. MB creates the work for real on submit. (#363) const w = { entityType: 'work', id: wmNewSeq--, gid: '', name: title || '[untitled]', comment: '', typeID: wmNewType, languages: wmLangRels(), iswcs: [], attributes: [], artists: [], other_artists: [], authors: [], editsPending: false, last_updated: null, _fromBatchCreateWorksDialog: true, _gtNew: true, }; if (key) wmNewWorks.set(key, w); return w; } // #363 the "New work options" controls in the matcher toolbar — a Type <select> and a searchable, // multi-select Lyrics-language combo (common languages first). Both catalogues are already on the page. function wmNewParamsUi() { wmDefaultType(); // #363 default to "Song" (once the catalogue exists) const wrap = el('div', 'gt-wm-nwp'); const typeSel = el('select', 'gt-wm-nwp-type'); typeSel.title = 'Work type applied to every new work'; typeSel.appendChild(new Option('— type —', '')); wmWorkTypes().forEach(t => { const o = new Option(t.name, String(t.id)); if (t.id === wmNewType) o.selected = true; typeSel.appendChild(o); }); typeSel.onchange = () => { wmNewType = typeSel.value ? +typeSel.value : null; wmApplyNewParams(); }; wrap.appendChild(typeSel); const lc = el('div', 'gt-wm-nwp-lang'); lc.title = 'Lyrics language(s) applied to every new work'; const chips = el('span', 'gt-wm-nwp-chips'), inp = el('input', 'gt-wm-nwp-inp'); inp.placeholder = 'lyrics language…'; inp.spellcheck = false; let drop = null; const onDown = e => { if (!lc.contains(e.target)) closeDrop(); }; function closeDrop() { if (drop) { drop.remove(); drop = null; document.removeEventListener('mousedown', onDown, true); } } function renderChips() { chips.textContent = ''; wmNewLangs.forEach(l => { const c = el('span', 'gt-wm-nwp-chip', l.name); const x = el('span', 'gt-wm-nwp-x', '×'); x.title = 'remove'; x.onclick = () => { wmNewLangs = wmNewLangs.filter(o => o !== l); wmApplyNewParams(); renderChips(); }; c.appendChild(x); chips.appendChild(c); }); } function showDrop() { closeDrop(); const q = inp.value.trim().toLowerCase(), picked = new Set(wmNewLangs.map(l => l.id)); const list = wmLanguages().filter(l => !picked.has(l.id) && (!q || l.name.toLowerCase().includes(q))); if (!list.length) return; drop = el('div', 'gt-wm-nwp-drop'); list.slice(0, 50).forEach(l => { const it = el('div', 'gt-wm-nwp-opt', l.name); it.onmousedown = e => { e.preventDefault(); wmNewLangs.push(l); wmApplyNewParams(); renderChips(); inp.value = ''; showDrop(); inp.focus(); }; drop.appendChild(it); }); lc.appendChild(drop); document.addEventListener('mousedown', onDown, true); } inp.oninput = showDrop; inp.onfocus = showDrop; inp.onkeydown = e => { if (e.key === 'Escape' && drop) { closeDrop(); e.stopPropagation(); } }; lc.append(chips, inp); wrap.appendChild(lc); renderChips(); const more = el('button', 'gt-wm-nwp-more', '⋯'); more.type = 'button'; more.title = 'recording-of relationship options — attributes (live, cover…) + dates'; more.onclick = () => wmRelOptsPopover(more); wrap.appendChild(more); // #432 (vzell): the ⋯ popover selections were invisible once it closed — surface them // as live chips right next to the button (attributes and "ended" removable via ×, the // date chip re-opens the popover), and highlight ⋯ while anything is set. const roSum = el('span', 'gt-wm-ro-sum'); const fmtD = d => [d.year, d.month, d.day].some(v => v != null) ? [d.year != null ? String(d.year) : '????', d.month != null ? String(d.month).padStart(2, '0') : (d.day != null ? '??' : null), d.day != null ? String(d.day).padStart(2, '0') : null].filter(Boolean).join('‑') : null; wmRoRefresh = () => { roSum.textContent = ''; const names = new Map(wmPerfAttrs().map(a => [a.typeID, a.name])); wmRelAttrs.forEach(id => { const c = el('span', 'gt-wm-nwp-chip', names.get(id) || ('#' + id)); const x = el('span', 'gt-wm-nwp-x', '×'); x.title = 'remove attribute'; x.onclick = () => { wmRelAttrs.delete(id); closeRoPop(); wmRoRefresh(); }; c.appendChild(x); roSum.appendChild(c); }); const b = fmtD(wmBegin), e2 = fmtD(wmEnd); if (b || e2) { const c = el('span', 'gt-wm-nwp-chip', (b || '…') + ' → ' + (e2 || '…')); c.title = 'recording-of dates — click to edit'; c.style.cursor = 'pointer'; c.onclick = () => { if (!wmRoPop) wmRelOptsPopover(more); }; roSum.appendChild(c); } if (wmEnded) { const c = el('span', 'gt-wm-nwp-chip', 'ended'); const x = el('span', 'gt-wm-nwp-x', '×'); x.title = 'remove'; x.onclick = () => { wmEnded = false; closeRoPop(); wmRoRefresh(); }; c.appendChild(x); roSum.appendChild(c); } more.classList.toggle('on', !!(wmRelAttrs.size || b || e2 || wmEnded)); }; wrap.appendChild(roSum); wmRoRefresh(); return wrap; } let wmRoRefresh = null; // #432 — repaints the rel-options chips; set by wmNewParamsUi // #363 the recording-of relationship options popover (opened by the ⋯ button): the performance // attributes as checkboxes + begin/end date + ended. These go on the recording→work rel, not the work. let wmRoPop = null; function closeRoPop() { if (wmRoPop) { wmRoPop.remove(); wmRoPop = null; document.removeEventListener('mousedown', wmRoDown, true); document.removeEventListener('keydown', wmRoKey, true); } } function wmRoDown(e) { if (wmRoPop && !wmRoPop.contains(e.target) && !e.target.classList.contains('gt-wm-nwp-more')) closeRoPop(); } function wmRoKey(e) { if (e.key === 'Escape' && wmRoPop) { e.stopPropagation(); closeRoPop(); } } function wmRelOptsPopover(anchor) { if (wmRoPop) { closeRoPop(); return; } const pop = el('div', 'gt-wm-relopts'); wmRoPop = pop; pop.appendChild(el('div', 'gt-wm-ro-hd', 'recording of')); wmPerfAttrs().forEach(a => { const lb = el('label', 'gt-wm-ro-cb'); const cb = document.createElement('input'); cb.type = 'checkbox'; cb.checked = wmRelAttrs.has(a.typeID); cb.onchange = () => { if (cb.checked) wmRelAttrs.add(a.typeID); else wmRelAttrs.delete(a.typeID); if (wmRoRefresh) wmRoRefresh(); }; // #432 live chips lb.append(cb, el('span', null, a.name)); pop.appendChild(lb); }); const mkDate = (label, obj) => { const row = el('div', 'gt-wm-ro-date'); row.appendChild(el('span', 'gt-wm-ro-dl', label)); const mk = (ph, key, cls) => { const i = el('input', cls); i.type = 'text'; i.placeholder = ph; i.value = obj[key] || ''; i.oninput = () => { const v = parseInt(i.value, 10); obj[key] = Number.isFinite(v) ? v : null; if (wmRoRefresh) wmRoRefresh(); }; return i; }; // #432 live chips row.append(mk('YYYY', 'year', 'gt-wm-ro-y'), el('span', 'gt-wm-ro-sep', '‑'), mk('MM', 'month', 'gt-wm-ro-m'), el('span', 'gt-wm-ro-sep', '‑'), mk('DD', 'day', 'gt-wm-ro-d')); return row; }; pop.appendChild(mkDate('Begin date', wmBegin)); pop.appendChild(mkDate('End date', wmEnd)); const endedL = el('label', 'gt-wm-ro-cb'); const endedCb = document.createElement('input'); endedCb.type = 'checkbox'; endedCb.checked = wmEnded; endedCb.onchange = () => { wmEnded = endedCb.checked; if (wmRoRefresh) wmRoRefresh(); }; endedL.append(endedCb, el('span', null, 'This relationship has ended.')); pop.appendChild(endedL); // #432 live chips document.body.appendChild(pop); const r = anchor.getBoundingClientRect(); pop.style.left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8) + 'px'; pop.style.top = (r.bottom + 4) + 'px'; setTimeout(() => { document.addEventListener('mousedown', wmRoDown, true); document.addEventListener('keydown', wmRoKey, true); }, 0); } // /ws/2 is paced by the shared MusicBrainz request gate (#633, the ST-NET block at the top), one budget for every script and tab // on the origin. (It used to be spaced 380ms apart here alone, and a long tracklist still burst past // the limit and dropped its tail to 503s, which surfaced as false "no match".) const gtGateLog = (lv, m) => console[lv === 'warn' ? 'warn' : 'debug']('[Group Therapy] ' + m); let wmAbort = null, wmRunning = false; // #372 cancel aborts in-flight matching fetches; wmRunning drives the "matching…" row state let wmPrefetchP = null; // #363 the in-flight prefetch (fired, not awaited up front) — wmMatchOne folds it in per row let _wmPrefetchCache = null; // #363 { gid, at, data } — cached 1h so re-opening the matcher doesn't re-fetch the release async function wmJson(url) { const sig = wmAbort && wmAbort.signal; for (let i = 0; i < 5; i++) { if (sig && sig.aborted) return null; // cancelled — stop retrying / sleeping try { const gated = url.startsWith('/ws/2/'); // only the public API is rate-limited; /ws/js is the editor's own if (gated && !(await mbnSlot({ label: url, log: gtGateLog, cancelled: () => !!(sig && sig.aborted) })).ok) return null; const r = await fetch(url, { credentials: 'include', headers: { Accept: 'application/json' }, signal: sig }); if (gated && (await mbnAnswer(r.status, n => r.headers.get(n), { log: gtGateLog })).throttled && i < 4) continue; // the next slot waits out the shared hold if ((r.status === 429 || r.status === 503) && i < 4) { await sleep(900 * (i + 1)); continue; } if (!r.ok) return null; return await r.json(); } catch (e) { if ((sig && sig.aborted) || i === 4) return null; await sleep(500 * (i + 1)); } } return null; } // MB's internal work autocomplete — returns authors, artist-popularity (hits), type and disambiguation // inline, ranked by relevance, and it's the editor's own endpoint so it isn't on the /ws/2 rate limit. async function wmWorkSearch(term) { const j = await wmJson('/ws/js/work?q=' + encodeURIComponent(term) + '&direct=false&limit=10'); const arr = Array.isArray(j) ? j : (j && j.results) || []; // authors (writers) + artist popularity live under related_artists.{authors,artists} — the top-level // w.authors / w.artists are empty return arr.filter(w => w && w.gid).map(w => { const ra = w.related_artists || {}; return { gid: w.gid, id: w.id, title: w.name, disambiguation: w.comment || '', type: w.typeName || '', authors: (ra.authors && ra.authors.results) || [], artists: (ra.artists && ra.artists.results) || [], pop: (ra.artists && ra.artists.hits) || 0 }; }); } const wmNorm = s => (s || '').normalize('NFC').toLowerCase().replace(/[’‘']/g, "'").replace(/[‐‑‒–—―]/g, '-').replace(/…\s*/g, '…').replace(/\s+/g, ' ').trim(); function performanceLtId() { const lt = W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type; if (!lt) return null; for (const k in lt) if (lt[k] && lt[k].gid === PERF_GID) return lt[k].id != null ? lt[k].id : +k; return null; } // the release's recordings, from the rendered track rows (deduped); flag ones already work-linked on the page function wmRecordings() { const out = [], seen = new Set(); document.querySelectorAll('tr.track').forEach(tr => { const rec = recordingEntity(tr); if (!rec) return; const key = (rec.gid || '') + '|' + rec.id; if (seen.has(key)) return; seen.add(key); const hasWork = recordingRels(tr).some(r => !r.removed && r.other && r.other.entityType === 'work'); const title = val(rec.name) || '', pos = posLabel(tr) || ''; const parts = wmSplitMedley ? wmMedleyParts(title) : null; // #718 one row per medley part — same recording, the part as its title; Apply links each part's work if (parts) parts.forEach((t, i) => out.push({ tr, rec, pos, title: t, fullTitle: title, part: i + 1, parts: parts.length, hasWorkOnPage: hasWork })); else out.push({ tr, rec, pos, title, hasWorkOnPage: hasWork }); }); return out; } // The recording entities on the page are lean (no artist credit), so the performer can't come from the // fiber. One release lookup fills every row's artist up front — independent of the per-row matching — so // the left column is populated immediately instead of trickling in as each match lands. async function wmPrefetchArtists(rows, draw) { const re = RE(); if (!re || !re.state || !re.state.entity) return; const gid = re.state.entity.gid; // #363 ONE release lookup fills every row's artist + ISRCs + already-linked status, so wmMatchOne can // skip the per-recording /ws/2/recording detail call. Cached for 1h so re-opening the matcher (or a // quick close/reopen) doesn't re-fetch. It's fired in PARALLEL with matching now (not awaited up front): // artist is only a ranking hint, so the title search starts immediately and folds this in when ready. let byGid = (_wmPrefetchCache && _wmPrefetchCache.gid === gid && (Date.now() - _wmPrefetchCache.at) < 3600000) ? _wmPrefetchCache.data : null; if (!byGid) { const j = await wmJson('/ws/2/release/' + gid + '?inc=recordings+artist-credits+isrcs+recording-level-rels+work-rels&fmt=json'); if (!j) return; byGid = new Map(); (j.media || []).forEach(m => (m.tracks || []).forEach(t => { const r = t.recording; if (r && r.id) byGid.set(r.id, { artist: (r['artist-credit'] || []).map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join('').trim(), isrcs: r.isrcs || [], linked: (r.relations || []).some(rel => rel.work), }); })); _wmPrefetchCache = { gid, at: Date.now(), data: byGid }; } let any = false; rows.forEach(row => { const d = byGid.get(row.rec.gid); if (!d) return; if (!row.artist && d.artist) { row.artist = d.artist; any = true; } row._isrcs = d.isrcs; row._linked = d.linked; row._prefetched = true; }); if (any) draw(); } // gather candidate works for one recording: ISRC-sibling works + the internal work autocomplete async function wmMatchOne(row) { const rec = row.rec; // compare exactness against both the full title and the title minus a trailing parenthetical, so // "Take My Breath Away (love theme…)" counts as an exact match of the work "Take My Breath Away" const bare = row.title.replace(/\s*\([^)]*\)\s*$/, '').trim(); const tnorm = wmNorm(row.title), bnorm = wmNorm(bare); const isExact = t => { const n = wmNorm(t); return n === tnorm || (!!bnorm && n === bnorm); }; // #363 kick off the title work-search NOW — it hits the editor's UNGATED /ws/js and only needs the track // title (already known), so it isn't blocked on the prefetch (artist/ISRCs), which runs in parallel. A // DESCRIPTIVE trailing parenthetical isn't part of the work title → retry stripped when the full title // finds nothing (titles where it IS part of the work match on the full title and skip the fallback). const searchP = row.title ? (async () => { let ws = await wmWorkSearch(row.title); if (!ws.length && bare && bare !== row.title) ws = await wmWorkSearch(bare); return ws; })() : Promise.resolve([]); // fold in the prefetch (artist + ISRCs + already-linked): awaited PER ROW (a shared one-time cost), not // an up-front block. If there was no prefetch at all, fall back to a per-recording detail call. if (wmPrefetchP) { try { await wmPrefetchP; } catch (e) {} } let isrcs; if (row._prefetched) { if (row._linked) { row.linked = true; return; } isrcs = row._isrcs || []; } else { const self = await wmJson('/ws/2/recording/' + rec.gid + '?inc=artist-credits+isrcs+work-rels&fmt=json'); if (self && self['artist-credit']) row.artist = self['artist-credit'].map(c => (c.name || (c.artist && c.artist.name) || '') + (c.joinphrase || '')).join('').trim(); if (self && (self.relations || []).some(r => r.work)) { row.linked = true; return; } // already linked isrcs = (self && self.isrcs) || []; } // does the work's own writer/artist match the track's performer? A strong ranking signal — split the // performer credit into names so a duet matches regardless of order/join. (artist comes from the // prefetch, so it's only ready here — hence a ranking hint, never a gate on finding candidates.) const perfNames = wmNorm(row.artist || '').split(/\s*(?:&|,|;|\/|\bfeat\.?\b|\bfeaturing\b|\bwith\b|\bvs\.?\b|\band\b|\bx\b)\s*/).map(x => x.trim()).filter(x => x.length > 2); const nameHit = names => !!perfNames.length && (names || []).some(n => { const nn = wmNorm(n); return perfNames.some(p => nn.includes(p)); }); const cands = new Map(); // workGid → { gid, id, title, disambiguation, type, authors, artists, pop, isrc, exact, artistMatch } const add = (w, isIsrc) => { if (!w || !w.gid) return; let e = cands.get(w.gid); if (!e) { e = { gid: w.gid, id: w.id != null ? w.id : null, title: w.title, disambiguation: w.disambiguation || '', type: w.type || '', authors: w.authors || [], artists: w.artists || [], pop: w.pop || 0, isrc: 0, exact: isExact(w.title) }; e.artistMatch = nameHit(e.authors) || nameHit(e.artists); cands.set(w.gid, e); } if (isIsrc) e.isrc++; if (!e.authors.length && w.authors && w.authors.length) e.authors = w.authors; if (!e.artists.length && w.artists && w.artists.length) e.artists = w.artists; if (!e.artistMatch) e.artistMatch = nameHit(e.authors) || nameHit(e.artists); }; // ISRC-sharing recordings (the /isrc lookup returns work-rels, unlike a /recording search) for (const code of isrcs.slice(0, 3)) { const j = await wmJson('/ws/2/isrc/' + encodeURIComponent(code) + '?inc=work-rels&fmt=json'); // #718 an ISRC sibling of a medley carries every part's work — it confirms only the one titled like this part (j && j.recordings || []).forEach(r => (r.relations || []).forEach(rel => { if (rel.work) add({ gid: rel.work.id, id: null, title: rel.work.title, disambiguation: rel.work.disambiguation || '' }, !row.parts || isExact(rel.work.title)); })); } // fold in the title search that's been running since the top of this call (await searchP).forEach(w => add(w, false)); // rank: ISRC-confirmed → performer is on the work → exact-title → most-recorded (popularity) const list = [...cands.values()].sort((a, b) => (b.isrc - a.isrc) || ((b.artistMatch ? 1 : 0) - (a.artistMatch ? 1 : 0)) || (b.exact - a.exact) || (b.pop - a.pop)); row.cands = list; if (!list.length) { row.level = 'none'; return; } const best = list[0], second = list[1]; row.best = best; row.writers = best.authors || []; row.workArtists = best.artists || []; row.artistMatched = !!best.artistMatch; const exacts = list.filter(c => c.exact).length; if (best.isrc > 0) row.level = 'exact'; // ISRC-confirmed else if (best.exact && (best.artistMatch || exacts === 1)) row.level = 'tolerance'; // exact title + (performer is on the work | only one such work) else if (best.artistMatch || (best.exact && (!second || !second.exact || best.pop >= (second.pop || 0) * 2))) row.level = 'near'; // performer on the work, or exact + clearly most-used else row.level = 'low'; // several plausible works — the user picks if (WM_RANK[row.level] <= wmCutoff) row.chosen = best; } function wmStyle() { if (document.getElementById('gt-wm-style')) return; const s = el('style'); s.id = 'gt-wm-style'; s.textContent = // toolbar (clone of Apollo's .tc-rec-tb) // #372 sticky toolbar; #376 flush to the header + bled to the panel edges so nothing shows behind the gap '.gt-wm .gt-cons-body{padding-top:0}' // #381 drag-to-resize from the lower-right so a big screen can widen the table (less Artist wrap) + '.gt-cons.gt-wm{resize:both;overflow:hidden;max-width:98vw;max-height:94vh;min-width:620px;min-height:320px}' // #381 maximize toggle → near-fullscreen + '.gt-cons.gt-wm.gt-wm-max{position:fixed;left:10px;right:10px;top:10px;bottom:10px;width:auto;height:auto;max-width:none;max-height:none;margin:0}' + '.gt-wm-tb{display:flex;align-items:center;gap:8px;padding:9px 14px;flex-wrap:wrap;position:sticky;top:0;z-index:6;background:var(--mbu-bg);border-bottom:1px solid var(--mbu-border);margin:0 -14px 8px}' + '.gt-wm-tb .gt-wm-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}' + '.gt-wm-tbl2{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--mbu-text-dim)}.gt-wm-tbl2 b{color:var(--mbu-accent-deep-text)}' + '.gt-wm-warn{color:var(--mbu-error);font-weight:600;font-size:12px}.gt-wm-warn.click{cursor:pointer}.gt-wm-warn.click:hover{text-decoration:underline}' + '.gt-wm-cancel{font:12px Arial;color:var(--mbu-error);background:var(--mbu-bg);border:1px solid var(--mbu-error);border-radius:12px;padding:1px 9px;cursor:pointer;flex:none}.gt-wm-cancel:hover{background:var(--mbu-error-bg)}' + '.gt-wm-tbsep{width:1px;height:18px;background:var(--mbu-bg-sunken);flex:none;margin:0 2px}' + '.gt-wm-btn{padding:4px 11px;border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial;color:var(--mbu-text)}.gt-wm-btn:hover{background:linear-gradient(var(--mbu-bg),var(--mbu-bg-sunken));border-color:var(--mbu-border)}' + '.gt-wm-btn:disabled,.gt-wm-caret:disabled{opacity:.45;cursor:default;pointer-events:none}' + '.gt-wm-btn.primary{color:var(--mbu-accent-text);font-weight:bold}.gt-wm-btn.primary:hover{background:linear-gradient(#7a52df,#5f3ec0);color:#fff;border-color:var(--mbu-accent)}' + '.gt-wm-caret{padding:4px 7px;color:var(--mbu-accent-text);border:1px solid transparent;border-radius:3px;background:transparent;cursor:pointer;font:13px Arial}.gt-wm-caret:hover{background:var(--mbu-bg-hover)}' // cutoff chip (clone of .tc-cutoff) + '.gt-wm-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}.gt-wm-cutoff:hover{border-color:var(--mbu-border)}' + '.gt-wm-cutoff-dot,.gt-wm-menu .dot{width:12px;height:12px;border-radius:50%;display:inline-block;border:1px solid rgba(0,0,0,.18);flex:none}.gt-wm-cutoff-caret{color:var(--mbu-text-weak);font-size:10px}' + '.gt-wm-menu{position:fixed;z-index:2147483647;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}' + '.gt-wm-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)}.gt-wm-menu .mi:hover,.gt-wm-menu .mi.sel{background:var(--mbu-bg-hover)}' // table (clone of .tc-rectbl) + '.gt-wm-tbl{border-collapse:collapse;width:100%;background:var(--mbu-bg);table-layout:fixed}' + '.gt-wm-tbl 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}' + '.gt-wm-tbl td{padding:4px 7px;vertical-align:top;font-size:13px}' + '.gt-wm-tbl .c-n{color:var(--mbu-text-weak);text-align:right;width:38px;white-space:nowrap}' + '.gt-wm-tbl .c-sep{width:20px;text-align:center;border-left:1px solid var(--mbu-accent);border-right:1px solid var(--mbu-accent)}' + '.gt-wm-tbl .tc-grp-l{background:var(--mbu-bg-raised);color:var(--mbu-info)}.gt-wm-tbl .tc-grp-r{background:var(--mbu-bg-hover);color:var(--mbu-accent-text)}' + '.gt-wm-dot{display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid rgba(0,0,0,.15)}' + '.gt-wm-tkt{font-weight:600}.gt-wm-tka{color:var(--mbu-text-dim)}' // #718 medley: the Split toggle, the track's tag, each part's label + '.gt-wm-split{display:inline-flex;align-items:center;gap:5px;font-size:12px;color:var(--mbu-text);border:1px solid var(--mbu-border);border-radius:14px;padding:2px 9px 2px 6px;background:var(--mbu-bg-hover);cursor:pointer;white-space:nowrap}.gt-wm-split input{margin:0}.gt-wm-split code{font-weight:700;color:var(--mbu-accent-text)}' + '.gt-wm-medtag{display:inline-block;margin-top:3px;font:11px Arial;font-weight:400;color:var(--mbu-text-on-accent);background:var(--mbu-accent);border-radius:8px;padding:0 7px}' + '.gt-wm-pt{display:block;color:var(--mbu-text-dim);font-size:11px;margin-top:1px}' + '.gt-wm-wk{position:relative}.gt-wm-wa{color:var(--mbu-info);font-weight:600;text-decoration:none;cursor:pointer}.gt-wm-wa:hover{text-decoration:underline}' + '.gt-wm-none{color:var(--mbu-error);cursor:pointer}.gt-wm-none:hover{text-decoration:underline}.gt-wm-newtag{color:var(--mbu-ok);cursor:pointer}' + '.gt-wm-disamb{color:var(--mbu-text-weak);font-weight:400}.gt-wm-authors{color:var(--mbu-text-dim);font-size:12px}.gt-wm-dim{color:var(--mbu-text-weak);font-style:italic}.gt-wm-linked{color:var(--mbu-ok)}' + '.gt-wm-wwr{color:var(--mbu-text-dim)}.gt-wm-wart{display:block;color:var(--mbu-text-weak);font-size:11px;margin-top:1px}' + '.gt-wm-acts{position:absolute;right:2px;top:2px;display:none;gap:2px}.gt-wm-row:hover .gt-wm-acts{display:inline-flex}' + '.gt-wm-act{border:none;background:var(--mbu-bg);cursor:pointer;color:var(--mbu-accent-text);font-size:13px;line-height:1;padding:1px 5px;border-radius:3px}.gt-wm-act:hover{background:var(--mbu-bg-hover)}' // picker (kept) + '.gt-wm-pop{position:fixed;z-index:2147483647;background:var(--mbu-bg);color:var(--mbu-text);border:1px solid var(--mbu-border);border-radius:8px;box-shadow:0 8px 30px rgba(0,0,0,.25);padding:8px;min-width:360px;max-width:480px;font:13px -apple-system,Segoe UI,Arial,sans-serif}' + '.gt-wm-qrow{display:flex;align-items:stretch;gap:6px;margin:4px 0 6px}' + '.gt-wm-q{flex:1 1 auto;min-width:0;box-sizing:border-box;padding:4px 6px}.gt-wm-results{max-height:300px;overflow:auto}' + '.gt-wm-newplus{flex:none;width:40px;font-size:17px;line-height:1;color:var(--mbu-ok);background:var(--mbu-ok-bg);border:1px solid var(--mbu-border);border-radius:5px;cursor:pointer}.gt-wm-newplus:hover{background:var(--mbu-ok-bg)}' + '.gt-wm-res{padding:4px 6px;border-radius:5px;cursor:pointer}.gt-wm-res:hover{background:var(--mbu-info-bg)}.gt-wm-rt{font-size:13px}' + '.gt-wm-rw{color:var(--mbu-text-dim);font-size:12px;margin-left:4px}.gt-wm-sub{color:var(--mbu-text-dim);font-size:11px;margin:-2px 0 5px 2px}' + '.gt-wm-open{margin-left:6px;color:var(--mbu-info);text-decoration:none;font-size:12px}.gt-wm-open:hover{text-decoration:underline}' + '.gt-wm-cur{margin:2px 0 6px;padding:4px 7px;background:var(--mbu-bg-raised);border-radius:5px;font-size:12px}.gt-wm-cur-l{color:var(--mbu-text-dim)}' + '.gt-wm-new{display:block;width:100%;box-sizing:border-box;margin-top:6px;padding:5px;border:1px dashed rgba(127,127,127,.5);border-radius:5px;background:transparent;color:inherit;cursor:pointer}.gt-wm-new:hover{background:rgba(127,127,127,.15)}' // #363 New-work params (Type + searchable lyrics-language combo) in the footer + '.gt-wm-nwp{display:inline-flex;align-items:center;gap:6px;font-size:12px;color:var(--mbu-text-dim)}' + '.gt-wm-ro-sum{display:inline-flex;align-items:center;gap:3px;flex-wrap:wrap;max-width:340px}' + '.gt-wm-nwp-more.on{border-color:var(--mbu-accent);background:var(--mbu-bg-hover);font-weight:700}' + '.gt-wm-nwp-more{padding:2px 6px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);color:var(--mbu-accent-text);cursor:pointer;font:13px Arial;line-height:1}.gt-wm-nwp-more:hover{background:var(--mbu-bg-hover)}' + '.gt-wm-nwp-type{font:12px Arial;padding:2px 4px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);max-width:130px}' + '.gt-wm-nwp-lang{position:relative;display:inline-flex;align-items:center;flex-wrap:wrap;gap:3px;min-width:120px;max-width:240px;border:1px solid var(--mbu-border);border-radius:4px;background:var(--mbu-bg);padding:2px 4px}' + '.gt-wm-nwp-chip{display:inline-flex;align-items:center;gap:3px;background:var(--mbu-bg-hover);color:var(--mbu-accent-deep-text);border-radius:9px;padding:1px 4px 1px 7px;font-size:11px;white-space:nowrap}' + '.gt-wm-nwp-x{cursor:pointer;color:var(--mbu-accent-text);font-weight:700;line-height:1}.gt-wm-nwp-x:hover{color:var(--mbu-error)}' + '.gt-wm-nwp-inp{border:none;outline:none;background:transparent;font:12px Arial;min-width:60px;flex:1 1 60px}' + '.gt-wm-nwp-drop{position:absolute;left:0;top:100%;z-index:5;margin-top:2px;max-height:220px;overflow:auto;min-width:160px;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:5px;box-shadow:0 4px 14px rgba(0,0,0,.15)}' + '.gt-wm-nwp-opt{padding:4px 9px;cursor:pointer;font-size:12px;white-space:nowrap}.gt-wm-nwp-opt:hover{background:var(--mbu-bg-hover)}' // #363 recording-of relationship options popover + '.gt-wm-relopts{position:fixed;z-index:2147483647;background:var(--mbu-bg);border:1px solid var(--mbu-accent);border-radius:6px;box-shadow:0 6px 20px rgba(40,20,80,.22);padding:8px 12px;font-size:13px;color:var(--mbu-text);min-width:190px}' + '.gt-wm-ro-hd{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--mbu-text-weak);margin-bottom:5px}' + '.gt-wm-ro-cb{display:flex;align-items:center;gap:6px;padding:2px 0;cursor:pointer}.gt-wm-ro-cb input{margin:0}' + '.gt-wm-ro-date{display:flex;align-items:center;gap:3px;margin-top:5px}' + '.gt-wm-ro-dl{width:66px;color:var(--mbu-text-dim);font-size:12px;text-align:right;margin-right:4px}' + '.gt-wm-ro-y{width:44px}.gt-wm-ro-m,.gt-wm-ro-d{width:30px}.gt-wm-relopts input[type=text]{border:1px solid var(--mbu-border);border-radius:3px;padding:2px 3px;font:12px Arial;text-align:center}' + '.gt-wm-ro-sep{color:var(--mbu-text-weak)}'; document.head.appendChild(s); } let wmEl = null; function onWmKey(e) { if (e.key === 'Escape') { if (popEl) return; e.stopPropagation(); closeWorkMatch(); } } // let an open picker take Escape first function closeWorkMatch() { closeRoPop(); if (wmAbort) { try { wmAbort.abort(); } catch (e) {} } wmRunning = false; if (wmEl) { wmEl.remove(); wmEl = null; document.removeEventListener('keydown', onWmKey, true); } } async function openWorkMatch(forceMatch) { closeWorkMatch(); closePopover(); wmStyle(); const re = RE(); if (!re) { toast('Open the relationship editor first'); return; } if (performanceLtId() == null) { toast('Could not resolve the “performance” link type'); return; } wmEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons gt-wm'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Match recordings to works')); // #381 maximize/restore — toggles a near-fullscreen class. Clears any drag-resized inline size on the way // in (inline would beat the class) and restores it on the way out. const max = el('button', 'gt-cons-x', '⛶'); max.type = 'button'; max.title = 'Maximize / restore'; max.onclick = () => { if (panel.classList.toggle('gt-wm-max')) { panel._savedW = panel.style.width; panel._savedH = panel.style.height; panel.style.width = ''; panel.style.height = ''; max.textContent = '❐'; max.title = 'Restore'; } else { panel.style.width = panel._savedW || ''; panel.style.height = panel._savedH || ''; max.textContent = '⛶'; max.title = 'Maximize'; } }; hdr.appendChild(max); const x = el('button', 'gt-cons-x', '✕'); x.type = 'button'; x.onclick = closeWorkMatch; hdr.appendChild(x); const body = el('div', 'gt-cons-body'), foot = el('div', 'gt-cons-foot'); body.appendChild(el('div', 'gt-pop-note', 'Reading recordings…')); panel.append(hdr, body, foot); wmEl.appendChild(panel); document.body.appendChild(wmEl); document.addEventListener('keydown', onWmKey, true); wmEl.addEventListener('mousedown', e => { if (e.target === wmEl) closeWorkMatch(); }); const note = m => { const n = body.querySelector('.gt-pop-note'); if (n) n.textContent = m; }; const rows = wmRecordings(); if (!rows.length) { note('No recordings found on this release.'); return; } const api = renderWorkMatch(body, foot, rows); // show the whole matrix at once — rows start "matching…" wmPrefetchP = wmPrefetchArtists(rows, api.draw); // #363 fire it in PARALLEL (don't block the popup/matching); wmMatchOne awaits it per row and the title search runs regardless // #363 auto-run the match only if opted in (settings) or forced by "Auto-match on start"; otherwise the // popup opens with every row unresolved and you click ⚡ Match yourself. if (forceMatch || wmAutoOnOpen) api.runMatch(); // #372 the initial pass (and ⚡ Match afterwards) go through the same re-runnable, cancellable path } // floating menu near an anchor (cutoff options / caret actions), Apollo-style function wmFloatMenu(anchor, items) { const m = el('div', 'gt-wm-menu'); const close = () => { m.remove(); document.removeEventListener('mousedown', onDown, true); }; const onDown = e => { if (!m.contains(e.target)) close(); }; items.forEach(it => { const mi = el('div', 'mi' + (it.sel ? ' sel' : '')); if (it.dot) { const d = el('span', 'dot'); d.style.background = it.dot; mi.appendChild(d); } mi.appendChild(document.createTextNode(it.label)); mi.onclick = () => { close(); it.run(); }; m.appendChild(mi); }); document.body.appendChild(m); const a = anchor.getBoundingClientRect(), r = m.getBoundingClientRect(); m.style.left = Math.max(6, Math.min(a.left, window.innerWidth - r.width - 6)) + 'px'; m.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 6) + 'px'; setTimeout(() => document.addEventListener('mousedown', onDown, true), 0); } // interface mirrors Apollo's Recordings matcher: no checkboxes — a row is resolved (has a work) or not; // Apply stages every resolved row; the toolbar carries the cutoff chip, an unresolved count, and ⚡ Match. function renderWorkMatch(body, foot, rows) { body.textContent = ''; foot.textContent = ''; const mkAct = (glyph, title, run) => { const b = el('button', 'gt-wm-act', glyph); b.type = 'button'; b.title = title; b.onclick = e => { e.stopPropagation(); run(); }; return b; }; // ── toolbar ── const tb = el('div', 'gt-wm-tb'); const amstatus = el('span', 'gt-wm-amstatus'); const cutWrap = el('label', 'gt-wm-tbl2'); cutWrap.appendChild(el('b', null, 'Cutoff')); const chip = el('span', 'gt-wm-cutoff'); chip.tabIndex = 0; chip.title = 'lowest confidence that ⚡ Match still resolves'; const chipDot = el('span', 'gt-wm-cutoff-dot'), chipLbl = el('span', 'gt-wm-cutoff-lbl'); chip.append(chipDot, chipLbl, el('span', 'gt-wm-cutoff-caret', '▾')); cutWrap.appendChild(chip); const paintChip = () => { const lvl = WM_LVL_BY_RANK[wmCutoff] || 'near'; chipDot.style.background = WM_LEVEL[lvl].c; chipLbl.textContent = lvl; }; paintChip(); const warn = el('span', 'gt-wm-warn'); let cancelled = false; // #372 cancel an ongoing match without closing the matcher const cancelBtn = el('button', 'gt-wm-cancel', '✕ cancel'); cancelBtn.type = 'button'; cancelBtn.title = 'stop matching (keeps what has matched so far)'; cancelBtn.style.display = 'none'; cancelBtn.onclick = () => { cancelled = true; if (wmAbort) { try { wmAbort.abort(); } catch (e) {} } cancelBtn.style.display = 'none'; }; // abort in-flight fetches so it stops immediately const matchBtn = el('button', 'gt-wm-btn primary', '⚡ Match'); matchBtn.type = 'button'; matchBtn.title = 'resolve every unresolved track whose best match is at/above the cutoff'; const matchCaret = el('button', 'gt-wm-caret', '▾'); matchCaret.type = 'button'; matchCaret.title = 'more actions'; matchCaret.onclick = () => wmFloatMenu(matchCaret, [{ label: 'Clear all', run: () => { rows.forEach(r => { r.chosen = null; }); draw(); updatePlan(); } }]); // #363 new-work options on the left; matched status + cutoff + Match (with a caret menu for Clear) on the right tb.append(wmNewParamsUi()); // #718 shown only when a track title has " / "; toggling rebuilds the rows (and re-matches if a match ran) if (rows.some(r => r.parts || wmMedleyParts(r.title))) { const split = el('label', 'gt-wm-split'); split.title = 'Split track titles on “ / ” (a medley) and match each part to its own work'; const scb = document.createElement('input'); scb.type = 'checkbox'; scb.checked = wmSplitMedley; scb.onchange = () => { wmSplitMedley = scb.checked; try { GM_setValue('gt-wm-split', wmSplitMedley); } catch (e) {} openWorkMatch(rows.some(r => r._matched)); }; split.append(scb, document.createTextNode('Split on '), el('code', null, '/')); tb.appendChild(split); } tb.append(amstatus, cancelBtn, cutWrap, warn, el('span', 'gt-wm-tbsep'), matchBtn, matchCaret); body.appendChild(tb); const setProgress = (d, n) => { wmRunning = !!n; amstatus.textContent = n ? `matching ${d}/${n}…` : (d ? `matched ${d} track${d > 1 ? 's' : ''}` : ''); matchBtn.disabled = !!n; matchCaret.disabled = !!n; cancelBtn.style.display = n ? '' : 'none'; }; // disable ⚡ Match + offer cancel while matching runs // ── table ── const tbl = el('table', 'gt-wm-tbl'); const cg = document.createElement('colgroup'); ['4%', '27%', '19%', '3%', '28%', '19%'].forEach(w => { const c = document.createElement('col'); c.style.width = w; cg.appendChild(c); }); tbl.appendChild(cg); // fixed widths — the # column was ballooning to an equal 1/6 (blank left column) const grp = el('tr'); const gl = el('th', 'tc-grp-l', 'Track'); gl.colSpan = 3; grp.appendChild(gl); grp.appendChild(el('th', 'c-sep', '')); const gr = el('th', 'tc-grp-r', 'Work'); gr.colSpan = 2; grp.appendChild(gr); tbl.appendChild(grp); const head = el('tr'); head.append(el('th', 'c-n', '#'), el('th', null, 'Title'), el('th', null, 'Artist'), el('th', 'c-sep', ''), el('th', null, 'Work'), el('th', null, 'Writers')); tbl.appendChild(head); const applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; const plan = el('span', 'gt-cons-plan'); let unresCursor = 0; // #363 cycle through the unresolved on each ⚠ click, not always the first const updatePlan = () => { const n = rows.filter(r => r.chosen && !r.hasWorkOnPage && !r.linked).length; plan.textContent = n ? `${n} work${n > 1 ? 's' : ''} to add` : 'nothing resolved'; applyBtn.disabled = !n; const uns = rows.filter(r => (r._matched || !wmRunning) && !r.chosen && !r.hasWorkOnPage && !r.linked); // #363 once stopped, cancelled (un-matched) rows count as unresolved too warn.textContent = uns.length ? `⚠ ${uns.length} unresolved` : ''; warn.className = 'gt-wm-warn' + (uns.length ? ' click' : ''); warn.onclick = uns.length ? () => { const r0 = uns[unresCursor % uns.length]; unresCursor++; if (r0 && r0._wk) { try { r0._wk.scrollIntoView({ block: 'center' }); } catch (e) {} wmPicker(r0, r0._wk, draw, updatePlan); } } : null; }; const draw = () => rows.forEach(row => { drawRow(row); if (row.parts && row._wk && !(row.hasWorkOnPage || row.linked)) row._wk.appendChild(el('span', 'gt-wm-pt', `part ${row.part} · “${row.title}”`)); // #718 }); const drawRow = row => { const wkd = row._wk, dot = row._dot, wad = row._wa; if (!wkd) return; wkd.textContent = ''; if (wad) wad.textContent = ''; if (row._artEl) row._artEl.textContent = row.artist || ''; if (row.hasWorkOnPage || row.linked) { if (dot) dot.style.visibility = 'hidden'; wkd.appendChild(el('span', 'gt-wm-linked', 'already linked ✓')); return; } // #363 not matched yet: while the run is going it's "matching…"; once it stops (finished/cancelled) // treat it like an unresolved row — a clickable "— none —" (pick a work) + a + new-work action — so a // cancelled row isn't a dead "—". We keep _matched=false so ⚡ Match still resumes it. if (!row._matched && wmRunning) { if (dot) dot.style.visibility = 'hidden'; wkd.appendChild(el('span', 'gt-wm-dim', 'matching…')); return; } // whatever's chosen (matched, picked, or a +/new-work on a cancelled row) — NOT gated on _matched, or a // chosen set after a cancel (un-matched row) would wrongly render as "— none —" (#363: + / New-work no-op) const w = row.chosen; if (!w) { if (dot) dot.style.visibility = 'hidden'; const none = el('span', 'gt-wm-none', '— none —'); none.title = 'pick a work'; none.onclick = () => wmPicker(row, wkd, draw, updatePlan); wkd.appendChild(none); const acts = el('span', 'gt-wm-acts'); acts.appendChild(mkAct('+', 'set to a new work', () => { row.chosen = wmMakeNewWork(row.title); draw(); updatePlan(); })); wkd.appendChild(acts); return; } if (dot) { dot.style.visibility = 'visible'; if (w._gtNew) { dot.style.background = '#2c7a51'; dot.title = 'new work'; } else { const L = WM_LEVEL[row.level] || WM_LEVEL.near; dot.style.background = L.c; dot.title = L.t; } } if (w._gtNew) { const nw = el('span', 'gt-wm-newtag', '+ new work: ' + (w.name || w.title || '')); nw.title = 'change / pick a work'; nw.onclick = () => wmPicker(row, wkd, draw, updatePlan); wkd.appendChild(nw); } else { const a = el('a', 'gt-wm-wa', w.title); a.href = '/work/' + w.gid; a.target = '_blank'; a.rel = 'noopener'; a.title = 'change / pick a work (middle-click to open the work)'; a.onclick = e => { e.preventDefault(); e.stopPropagation(); wmPicker(row, wkd, draw, updatePlan); }; wkd.appendChild(a); if (w.disambiguation) wkd.appendChild(el('span', 'gt-wm-disamb', ` (${w.disambiguation})`)); } if (wad && !w._gtNew) { // #363 a NEW work has no writers yet — don't leave the auto-match candidate's writers showing wad.textContent = ''; if (row.writers && row.writers.length) wad.appendChild(el('span', 'gt-wm-wwr', row.writers.slice(0, 4).join(', '))); // when the performer is one of the work's recording artists (why it matched), show them too — the // native work dropdown's "Artists:" line, e.g. Phil Collins on "You Can't Hurry Love" if (row.artistMatched && row.workArtists && row.workArtists.length) { const ar = el('span', 'gt-wm-wart', '♫ ' + row.workArtists.slice(0, 3).join(', ')); ar.title = 'recording artists of this work — the performer is among them'; wad.appendChild(ar); } } const acts = el('span', 'gt-wm-acts'); acts.appendChild(mkAct('↺', 'clear this match', () => { row.chosen = null; draw(); updatePlan(); })); if (!w._gtNew) acts.appendChild(mkAct('+', 'set to a new work', () => { row.chosen = wmMakeNewWork(row.title); draw(); updatePlan(); })); wkd.appendChild(acts); }; rows.forEach((row, i) => { const tr = el('tr', 'gt-wm-row' + (row.parts ? ' gt-wm-med' : '')); // #718 a medley's parts share one track: its #, full title and artist span the part rows if (!row.parts || row.part === 1) { const span = c => { if (row.parts) c.rowSpan = row.parts; return c; }; tr.appendChild(span(el('td', 'c-n', row.pos ? String(row.pos) : String(i + 1)))); const tkt = span(el('td', 'gt-wm-tkt', row.parts ? row.fullTitle : (row.title || '(untitled)'))); if (row.parts) { tkt.appendChild(document.createElement('br')); tkt.appendChild(el('span', 'gt-wm-medtag', `medley · ${row.parts} works`)); } tr.appendChild(tkt); const tka = span(el('td', 'gt-wm-tka')); row._artEl = tka; tr.appendChild(tka); } const sepd = el('td', 'c-sep'); const dot = el('span', 'gt-wm-dot'); dot.style.visibility = 'hidden'; sepd.appendChild(dot); row._dot = dot; tr.appendChild(sepd); const wkd = el('td', 'gt-wm-wk'); row._wk = wkd; tr.appendChild(wkd); const wad = el('td', 'gt-wm-authors'); row._wa = wad; tr.appendChild(wad); tbl.appendChild(tr); }); body.appendChild(tbl); // resolve every matched row whose best is at/above the cutoff (⚡ Match + cutoff change) const applyCutoff = () => { rows.forEach(r => { if (!(r.hasWorkOnPage || r.linked) && r._matched) r.chosen = (r.best && WM_RANK[r.level] <= wmCutoff) ? r.best : null; }); draw(); updatePlan(); }; // #372 (re-)run matching for any not-yet-matched rows, then apply the cutoff. Re-runnable: this is // both the initial pass and what ⚡ Match does — so "Match" after a cancel resumes the leftover rows. const runMatch = async () => { if (wmRunning) return; cancelled = false; if (wmAbort) { try { wmAbort.abort(); } catch (e) {} } wmAbort = new AbortController(); const total = rows.length; let done = rows.filter(r => r._matched || r.hasWorkOnPage || r.linked).length; setProgress(done, total); draw(); // #363 match recordings CONCURRENTLY (not one-at-a-time). Each recording's work-title search hits the // editor's /ws/js endpoint, which ISN'T on the /ws/2 rate gate — so running several rows at once lets // those ungated searches overlap and fills the idle time while the gated ISRC lookups are spaced out, // instead of the old serial loop where every row waited on the one before it. wmMatchOne rows are // independent (shared state is only the global gate + abort), so this is safe. const todo = rows.filter(r => !(r._matched || r.hasWorkOnPage)); await throttledMap(todo, async row => { if (cancelled || !wmEl) return; try { await wmMatchOne(row); } catch (e) {} if (cancelled || !wmEl) return; row._matched = true; done++; setProgress(done, total); draw(); updatePlan(); }, 6); if (!wmEl) { wmRunning = false; return; } // dialog closed mid-run setProgress(done, 0); // clears wmRunning → leftover rows show "—", ⚡ Match re-enabled // #363 the run is over (finished or cancelled) — drop the AbortController. A cancel leaves it in the // aborted state, and wmJson bails on an aborted signal, so keeping it would make the ✎ picker's OWN // searches return nothing after a cancel (they don't after a full run, which never aborts it). if (wmAbort && wmAbort.signal.aborted) wmAbort = null; if (!cancelled) applyCutoff(); // auto-select strong matches after a full pass (not after a cancel) draw(); updatePlan(); }; matchBtn.onclick = runMatch; chip.onclick = () => wmFloatMenu(chip, WM_LVL_BY_RANK.map(lvl => ({ label: lvl, dot: WM_LEVEL[lvl].c, sel: WM_RANK[lvl] === wmCutoff, run: () => { wmCutoff = WM_RANK[lvl]; try { GM_setValue('gt-wm-cutoff', wmCutoff); } catch (e) {} paintChip(); applyCutoff(); } }))); const newAllBtn = el('button', 'gt-cons-btn', '+ New work for unresolved'); newAllBtn.type = 'button'; newAllBtn.title = 'Create a new work (named after the track) for every recording still unresolved — same-title tracks share one'; newAllBtn.onclick = () => { rows.forEach(r => { if (!(r.hasWorkOnPage || r.linked) && (r._matched || !wmRunning) && !r.chosen) r.chosen = wmMakeNewWork(r.title); }); draw(); updatePlan(); }; // #363 include cancelled rows once matching has stopped applyBtn.onclick = async () => { const n = await wmApply(rows, null); if (n > 0) closeWorkMatch(); }; // close the popup once staged (#363 follow-up) foot.append(newAllBtn, plan, applyBtn); // #363 Clear moved to the Match caret menu; new-work options moved to the toolbar draw(); updatePlan(); return { draw, updatePlan, setProgress, runMatch }; } function wmResRow(work, row, draw, updatePlan) { const r = el('div', 'gt-wm-res'); r.appendChild(el('span', 'gt-wm-rt', work.title + (work.disambiguation ? ` (${work.disambiguation})` : ''))); if (work.type && work.type !== 'Song') r.appendChild(el('span', 'gt-wm-rw', ' · ' + work.type)); if (work.authors && work.authors.length) r.appendChild(el('span', 'gt-wm-rw', ' — ' + work.authors.slice(0, 4).join(', '))); if (work.artists && work.artists.length) r.appendChild(el('span', 'gt-wm-rw', ' · ♫ ' + work.artists.slice(0, 3).join(', '))); if (work.gid) { const open = el('a', 'gt-wm-open', '↗'); open.href = '/work/' + work.gid; open.target = '_blank'; open.rel = 'noopener'; open.title = 'open this work in a new tab'; open.onclick = e => e.stopPropagation(); r.appendChild(open); } r.onclick = () => { row.chosen = work; row.best = work; if (!row.level || row.level === 'none') row.level = 'near'; row.writers = work.authors || []; draw && draw(); updatePlan && updatePlan(); closePopover(); }; return r; } function wmPicker(row, anchor, draw, updatePlan) { closePopover(); popEl = el('div', 'gt-wm-pop'); popEl.appendChild(el('div', 'gt-pop-hdr', 'Pick a work for “' + trunc(row.title, 54) + '”')); if (row.parts) popEl.appendChild(el('div', 'gt-wm-sub', `part ${row.part} of “${trunc(row.fullTitle, 60)}”`)); // #718 if (row.artist) popEl.appendChild(el('div', 'gt-wm-sub', 'by ' + trunc(row.artist, 60))); // current match (mirrors Apollo's picker header) — the work as a link you can open in a new tab, its // writers, and a clear button const cur = el('div', 'gt-wm-cur'); const paintCur = () => { cur.textContent = ''; cur.appendChild(el('span', 'gt-wm-cur-l', 'Current: ')); const w = row.chosen; if (!w) { cur.appendChild(el('span', 'gt-wm-none', '— none —')); return; } if (w._gtNew) { cur.appendChild(el('span', 'gt-wm-newtag', '+ new work: ' + (w.name || w.title || ''))); return; } const a = el('a', 'gt-wm-wa', w.title + (w.disambiguation ? ` (${w.disambiguation})` : '')); a.href = '/work/' + w.gid; a.target = '_blank'; a.rel = 'noopener'; a.title = 'open this work in a new tab'; cur.appendChild(a); if (row.writers && row.writers.length) cur.appendChild(el('span', 'gt-wm-rw', ' — ' + row.writers.slice(0, 4).join(', '))); const clr = el('button', 'gt-wm-act', '↺'); clr.type = 'button'; clr.title = 'clear this match'; clr.onclick = () => { row.chosen = null; paintCur(); draw && draw(); updatePlan && updatePlan(); }; cur.appendChild(clr); }; paintCur(); popEl.appendChild(cur); const qrow = el('div', 'gt-wm-qrow'); const q = el('input', 'gt-wm-q'); q.type = 'text'; q.placeholder = 'search works, or paste a work MBID / URL…'; qrow.appendChild(q); // #363 create-new-work as a + button right of the search (like Apollo's recordings picker), not a footer button const newBtn = el('button', 'gt-wm-newplus', '+'); newBtn.type = 'button'; newBtn.title = 'Create a new work “' + trunc(row.title, 40) + '”'; newBtn.onclick = () => { const w = wmMakeNewWork(row.title); row.chosen = w; row.best = w; row.level = (!row.level || row.level === 'none') ? 'near' : row.level; row.writers = []; draw && draw(); updatePlan && updatePlan(); closePopover(); }; qrow.appendChild(newBtn); popEl.appendChild(qrow); const list = el('div', 'gt-wm-results'); popEl.appendChild(list); const showCands = () => { list.textContent = ''; (row.cands || []).forEach(c => list.appendChild(wmResRow(c, row, draw, updatePlan))); if (!(row.cands || []).length) list.appendChild(el('div', 'gt-pop-note', 'No candidates yet — search or paste a work.')); }; showCands(); // #363 if this row was never matched (e.g. cancelled before its turn), run the same match ⚡ Auto-match // uses right now, so candidates appear automatically instead of a blank list until you type. if (!(row.cands || []).length && !row._matched && !row.linked) { list.textContent = ''; list.appendChild(el('div', 'gt-pop-note', 'Searching…')); const myPop = popEl; wmMatchOne(row).then(() => { row._matched = true; if (popEl === myPop) { showCands(); paintCur(); } draw && draw(); updatePlan && updatePlan(); }) .catch(() => { if (popEl === myPop) showCands(); }); } let t = null; const run = async () => { const term = (q.value || '').trim(); if (!term) return showCands(); const gid = (term.match(GID_RE) || [])[0]; list.textContent = ''; if (gid) { const j = await wmJson('/ws/2/work/' + gid + '?inc=artist-rels&fmt=json'); if (j && j.id) list.appendChild(wmResRow({ gid: j.id, title: j.title, disambiguation: j.disambiguation || '', authors: (j.relations || []).filter(r => r.artist && WM_WRITER_RE.test(r.type || '')).map(r => r.artist.name) }, row, draw, updatePlan)); else list.appendChild(el('div', 'gt-pop-note', 'No work with that MBID.')); return; } const works = await wmWorkSearch(term); if (!works.length) { list.appendChild(el('div', 'gt-pop-note', 'No matches.')); return; } works.forEach(w => list.appendChild(wmResRow(w, row, draw, updatePlan))); }; q.addEventListener('input', () => { clearTimeout(t); t = setTimeout(run, 300); }); q.addEventListener('paste', () => setTimeout(run, 0)); document.body.appendChild(popEl); const a = anchor.getBoundingClientRect(), r = popEl.getBoundingClientRect(); popEl.style.left = Math.max(8, Math.min(a.left, window.innerWidth - r.width - 8)) + 'px'; popEl.style.top = Math.min(a.bottom + 4, window.innerHeight - r.height - 8) + 'px'; setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); q.focus(); }, 0); } async function wmApply(rows, refresh) { const re = RE(); const ltId = performanceLtId(); if (!re || ltId == null) { toast('Cannot apply — editor not ready'); return; } const todo = rows.filter(r => r.chosen && !r.hasWorkOnPage && !r.linked); if (!todo.length) { toast('Nothing resolved to apply'); return; } toast(`Linking ${todo.length} work${todo.length > 1 ? 's' : ''}…`); let ok = 0, fail = 0; const okRecs = new Set(); // #718 a medley links one recording to several works const extras = wmRelExtras(); // #363 recording-of attributes + dates, applied to every staged rel for (const row of todo) { try { // existing work: fetch its internal id (the editor needs it). New work: dispatch the synthetic // entity as-is — MB creates it on submit, exactly like a natively-added new work. const workEnt = row.chosen._gtNew ? row.chosen : await wmJson('/ws/js/entity/' + row.chosen.gid); if (!workEnt || workEnt.id == null) { fail++; continue; } dispatchRelationship(re, row.rec, workEnt, ltId, '', extras.attrs, extras.dates); row.linked = true; row.chosen = null; ok++; okRecs.add(row.rec); } catch (e) { fail++; } } const nRec = okRecs.size; if (ok) markUsed(`Matched ${nRec} recording${nRec > 1 ? 's' : ''} to works`); toast(fail ? `Linked ${ok}, ${fail} failed — see console` : `✓ Linked ${ok} work${ok > 1 ? 's' : ''} — review & save`); refresh && refresh(); return ok; } // recording checkbox → copy every recording rel except work/url/recording-samples // (so artists, ℗/© labels, recorded-at places, …) onto the ticked recordings // preselect (optional): a predicate rel→bool for which credits START ticked (#373 — the + / pencil // right-clicks scope the copy to one role group / one credit; others start unticked but stay selectable). function openCopyMenu(sourceTr, x, y, preselect, extraItems) { const srcRels = recordingRels(sourceTr).filter(r => !r.removed && r.other && !['work', 'url', 'recording'].includes(r.other.entityType)); const entries = srcRels.map(s => ({ rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : ''), checked: preselect ? !!preselect(s) : true })); // before the checkboxes render, respect the preselect (e.checked) so the Copy count reflects the ticked subset (#373) const chosen = () => entries.filter(e => e.cb ? e.cb.checked : e.checked !== false).map(e => e.rel); const nR = srcRels.length, srcPos = posLabel(sourceTr); // #377 destinations are LIVE (recomputed when you Copy/Move) so the [A]/[R] row buttons can grow the // selection; with nothing ticked it means ALL other tracks. const tickedRows = () => [...document.querySelectorAll('tr.track')].filter(tr => { if (tr === sourceTr) return false; const cb = tr.querySelector('input.recording'); return cb && cb.checked; }); const destRows = () => { const t = tickedRows(); return t.length ? t : [...document.querySelectorAll('tr.track')].filter(tr => tr !== sourceTr); }; const getDests = () => destRows().map(recordingEntity).filter(Boolean); const whereText = () => { const t = tickedRows(); if (!t.length) return `all ${destRows().length} tracks`; const pos = new Set(t.map(trackPosOfRow).filter(p => p != null)); return pos.size ? `track${pos.size > 1 ? 's' : ''} ${ranges(pos)}` : `${t.length} recording${t.length > 1 ? 's' : ''}`; }; // #377 tick destination tracks by a credit: [A] = any track crediting this artist (any role); [R] = same role const selectByCredit = (gid, roleKey) => { let n = 0; document.querySelectorAll('tr.track').forEach(tr => { if (tr === sourceTr) return; const has = recordingRels(tr).some(r => !r.removed && r.other && r.other.gid === gid && (roleKey == null || roleKeyOfSpec(r) === roleKey)); if (has) { const cb = tr.querySelector('input.recording'); if (cb && !cb.checked) { cb.click(); n++; } } }); return n; }; // #385 "Set missing dates": the pencil-clicked rel carries a date period (e.g. a "recorded at" place // with a date). Stamp that date onto every UNDATED relationship (all roles + recording-of) on the target // tracks. It only FILLS blanks — MB's editor reducer merges an update into the existing relationship and // keeps a non-empty date, so an existing date can't be overwritten or cleared from here (that would need // driving MB's own edit dialog, which we don't do). url rels can't carry a date period, so they're skipped. const clickedRel = (preselect && srcRels.find(s => preselect(s))) || null; const hasDate = !!(clickedRel && (clickedRel.begin_date || clickedRel.end_date || clickedRel.ended)); const items = []; if (!nR) { items.push({ header: 'No credits here' }); } else { const hdr = { header: `Copy to ${whereText()}` }; const refreshHdr = () => { hdr._set && hdr._set(`Copy to ${whereText()}`); }; const copyItem = { label: 'Copy', sub: String(chosen().length), run: () => { const c = chosen(), dests = getDests(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, dests)) markUsed(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} from track ${srcPos || '?'} to ${whereText()}`); toast(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} to ${dests.length} recording${dests.length > 1 ? 's' : ''} — review & save`); } }; const moveItem = { label: 'Move (remove here)', danger: true, run: () => { const c = chosen(), dests = getDests(); if (!c.length) { toast('No credits selected'); return; } const srcGid = (recordingEntity(sourceTr) || {}).gid; if (copyCredits(c, dests)) markUsed(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} from track ${srcPos || '?'} to ${whereText()}`); removeSourceRels(srcGid, c); toast(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} to ${dests.length} recording${dests.length > 1 ? 's' : ''} — review & save`); } }; items.push(hdr, { checklist: entries, onToggle: () => { const n = chosen().length; copyItem._setSub && copyItem._setSub(String(n)); }, rowBtns: [ { label: 'A', title: 'select every track crediting this artist — any role', run: e => { const n = selectByCredit(e.rel.other.gid, null); refreshHdr(); toast(n ? `+${n} track${n > 1 ? 's' : ''} with this artist` : 'No other tracks with this artist'); } }, { label: 'R', title: 'select every track crediting this artist in the same role', run: e => { const n = selectByCredit(e.rel.other.gid, e.role); refreshHdr(); toast(n ? `+${n} track${n > 1 ? 's' : ''} with this role` : 'No other tracks with this role'); } }, ] }, copyItem, moveItem); // #398 "Set dates…" — only when the clicked rel carries a date to seed with. Opens the date picker // (editable date + per-credit selection across the selected tracks) instead of blindly filling every // undated rel; the clicked rel merely SEEDS the initial date, the picker owns the rest. if (hasDate) { const dl = fmtRoDate(clickedRel) || 'date'; items.push({ label: `Set dates from ${dl}…`, run: () => openDatePicker(clickedRel) }); } } if (extraItems && extraItems.length) items.push(...extraItems); // #470 "Replace role…" from the pencil openMenu(x, y, items); } /* ── #522 Text parser: pattern-match unstructured credit text into (role, entity) rows ──── * majkinetor: "In a new branch, create a Text parser tool. It should work sorta like Pattern * parser of Apollo but for credits." Same DSL shape as Apollo's tpTokenize/tpCompile * (apollo_editor.user.js) — a one-line PATTERN compiles to a regex, run over every pasted * line — but with credit fields (R role, E entity — artist OR label, see #525 below) instead * of track fields, plus a new [,] split modifier so one matched line can expand into several * (role, entity) rows (e.g. "Cameron Allen - Flute, Tenor Saxophone" → two rows, one entity, * two roles). Deliberately * duplicated rather than shared — same one-file, dependency-free philosophy this script * already states in its own README, and the same "keep in sync" convention Apollo uses * between its own live copy and test/pattern-engine.test.mjs; this file gets its own mirror * at test/pattern-engine.test.mjs. Per majkinetor's own scoping: "let's parse comments on * single line only" — there is no multi-line grammar; a line that doesn't match anything * (a section header, a blank line) is simply unmatched, not a special case. */ // #525 follow-up (majkinetor): "change A to E (as entity, and everywhere // where Artist appears)" — the field can resolve to either an artist OR a // label (see the entity-type generalization below), so the DSL letter and // the field's own name are now the generic "entity", not "artist". const TXP_FIELDS = { 'R': 'role', 'E': 'entity' }; const TXP_SEPS = ['-', '‐', '–', '—', '/', ':']; // #525 follow-up (majkinetor): "Add R[,] - E[,] and E[,] - R[,] instead // variants on single side. It is more general." — splitting BOTH sides // strictly subsumes a single-side split (a field with no comma in it is // just a no-op split), so these replace the old entity-only-split dash // preset ("E - R[,]") rather than sitting alongside it. // #525: MB wants a sort name on a new artist, and typing it by hand for every // created credit is exactly the tedium this parser exists to remove. Ported // from credit_hoarder's guessSortName (mappers.js) — duplicated rather than // shared, the same way the pattern engine already is, since Group Therapy // ships as one file. Assumes type=Person, which is what we seed alongside it. function txpGuessSortName(name) { if (!name || !name.trim()) return name || ''; name = name.trim(); const words = name.split(/\s+/); if (words.length === 1) return name; // "The Doors" -> "Doors, The" const art = name.match(/^(the|a|an)\s+(.+)$/i); if (art) return `${art[2]}, ${art[1].charAt(0).toUpperCase() + art[1].slice(1).toLowerCase()}`; // keep a trailing suffix attached to the given names: "Sammy Davis Jr." -> // "Davis, Sammy Jr." let suffix = '', base = name; const sm = name.match(/^(.*?),?\s+(jr\.?|sr\.?|ii|iii|iv|v|esq\.?)$/i); if (sm) { base = sm[1].trim(); suffix = ' ' + sm[2]; } const bw = base.split(/\s+/); if (bw.length === 1) return name; return `${bw[bw.length - 1]}, ${bw.slice(0, -1).join(' ')}${suffix}`; } const TXP_PRESETS = ['R: E', 'R: E[,]', 'R[,] - E[,]', 'E[,] - R[,]', 'R by E[&]']; let _txpPattern = 'R: E'; // remembered across opens this session const txpEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const txpUsable = out => !!out && Object.values(out).some(v => v && String(v).trim()); function txpTokenize(pattern, seps) { const segs = [], sepSet = new Set(seps); const isFieldLetter = c => c === 'R' || c === 'E'; 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; // #522 split modifier: [,] splits this field's captured text on commas, expanding // one matched line into several output rows (see txpExpand). [, and] also splits on // the literal word "and" (space-separated extra alternatives after the leading comma) // — covers wiki shapes like "Flute Alto, Tenor, Baritone and Soprano Saxophones". // #525 (majkinetor): "R: E[&]" didn't work for "Ricardo H Fernandes & // Yacine Blaeich" — [&] splits the SAME way, on " & " (with required // surrounding whitespace, so it doesn't fire mid-word for things like // "AT&T"), for lines joined by "&" instead of a comma. if (inner[0] === ',' || inner[0] === '&') { const primary = inner[0]; const rest = inner.slice(1).trim(); return { slice: { split: [primary, ...(rest ? rest.split(/\s+/) : [])] }, next: close + 1 }; } // 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), same as Apollo's. 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 } : { fromDelim: left, fromLast: hadTilde, toDelim, toLast }; 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: TXP_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]) || letterish(pattern[i + 1]))) { const sl = readSlice(pattern, i + 1); segs.push({ kind: 'field', field: TXP_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 txpCompile(pattern, opts = {}) { const seps = opts.separators || TXP_SEPS; const segs = txpTokenize(pattern, seps); // dangling leading/trailing ws/sep tokens compile to a hard boundary requirement a // trimmed input line can never satisfy — same fix as Apollo's #522, re-ported here since // this is a fresh, independent copy of the engine. 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)); // split-flagged fields (R[,]) can't be positionally sliced — they need an ordinary // capture (still compiled below) plus post-match splitting in txpExpand, so they're // excluded from slice-mode eligibility. const sliced = fieldSegs.filter(s => s.slice && !s.slice.split); const splitSpecs = new Map(fieldSegs.filter(s => s.slice && s.slice.split).map(s => [s.field, s.slice.split])); const sepClass = '(?:' + seps.map(txpEsc).join('|') + ')'; if (fieldSegs.length > 0 && sliced.length === fieldSegs.length) { // slice-mode (all fields positional) return { fields, splitSpecs, 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 txpUsable(out) ? out : null; } }; } // one text field is greedy so the split lands on the FIRST separator (default) — the // other stays lazy. A field immediately followed by a literal is ALWAYS lazy (stops at // the FIRST occurrence) regardless of that heuristic — same fix as Apollo's #522 // follow-up, re-ported here. const textSegs = fieldSegs.filter(s => s.field === 'role' || s.field === 'entity'); const greedyText = 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+' : txpEsc(seg.text); else if (seg.kind === 'sep') re += '\\s*' + sepClass + '\\s*'; else if (seg.kind === 'skip') re += '.*?'; else if (seg.kind === 'field') { if (seg.slice && (seg.slice.toDelim != null || seg.slice.fromDelim != null)) { const sl = seg.slice; re += (sl.fromDelim ? (sl.fromLast ? '.*' : '.*?') + txpEsc(sl.fromDelim) : '') + (sl.toDelim ? (sl.toLast ? '(.+)' : '(.+?)') + txpEsc(sl.toDelim) : '(.+)'); } // X[from:to] in flow: skip-to-fromDelim, capture, toDelim else { 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, splitSpecs, 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 txpUsable(out) ? out : null; } }; } // one comma is always a split point; any additional alternatives (e.g. "and") are // treated as whole surrounding-whitespace-delimited words, not substrings. function txpSplitRegex(alts) { return new RegExp(alts.map(a => a === ',' ? '\\s*,\\s*' : '\\s+' + txpEsc(a) + '\\s+').join('|'), 'i'); } // run a compiled pattern against one line; expand any split-flagged field's captured // text into several output rows (one matched line → N role/artist pairs). function txpExpand(compiled, line) { const out = compiled && compiled.exec(line); if (!out) return null; if (!compiled.splitSpecs || !compiled.splitSpecs.size) return [out]; let rows = [out]; for (const [field, alts] of compiled.splitSpecs) { const rx = txpSplitRegex(alts); rows = rows.flatMap(r => { const vals = (r[field] || '').split(rx).map(s => s.trim()).filter(Boolean); return vals.length ? vals.map(v => ({ ...r, [field]: v })) : [r]; }); } return rows; } // ── artist resolution (ported from apollo_editor.user.js's search + siblings block) ────── // #613: the exact-identity rule now comes from the shared matching module // (dev/match/artist-match.mjs, inlined below by dev/match/sync-match.mjs); the txp* // folding helpers stay as they were. // <ST-MATCH> — 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; } // </ST-MATCH> const txpFold = s => (s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/gi, 'd').replace(/[‐‑‒–—―−]/g, '-').toLowerCase().replace(/\s+/g, ' ').trim(); const txpSameName = (a, b) => txpFold(a) === txpFold(b); // resolve an MBID to a full entity (incl. the numeric id dispatchRelationship needs) async function txpFetchEntity(gid, fallbackType) { try { const j = await mbnFetch(`/ws/js/entity/${gid}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); if (j && j.gid) { if (!j.entityType) j.entityType = fallbackType || 'artist'; return j; } } catch (e) {} return null; } const _txpSearchCache = new Map(); async function txpSearchArtist(name, limit) { limit = limit || 8; const k = txpFold(name) + '|' + limit; if (!txpFold(name)) return []; if (_txpSearchCache.has(k)) return _txpSearchCache.get(k); let list = []; try { const j = await mbnFetch(`/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) {} list = list.filter(c => c && (c.name || '').trim()); // drop the trailing empty placeholder entry _txpSearchCache.set(k, list); return list; } // the unified exact-identity resolver (ported from Apollo's #442/#445): resolves a // credited name to the unique MB artist that carries it as an exact NAME or ALIAS, // accepted ONLY when exactly one artist across name+alias space has it. Returns // {entity, via} ('name'|'alias'), or null when ambiguous/no match. const _txpAliasCache = new Map(); // #522 follow-up (majkinetor, live): "Why is this label not auto resolved // as it seems like a single name match?" (© 2004 Geffen Records / ℗ 2015 // Geffen Records) — live-verified against production: MB genuinely has // TWO labels named exactly "Geffen Records" (the real one, plus a // "bootleg version of the real Geffen Records label" duplicate), so // refusing as ambiguous was technically correct. But MB's own search // relevance score already separates them clearly (100 vs 45) — a decisive // gap is tried as a last narrowing step before giving up as ambiguous. function txpNarrowByScore(exact) { const scored = exact.filter(a => typeof a.score === 'number').sort((a, b) => b.score - a.score); if (scored.length < 2) return exact; return (scored[0].score - scored[1].score >= 20) ? [scored[0]] : exact; } async function txpResolveByExactAlias(name) { const key = txpFold(name); if (!key) return null; if (_txpAliasCache.has(key)) return _txpAliasCache.get(key); const q = mbmIdentityQuery(name, 'artist'); if (!q) return null; let arr = null; try { arr = await mbnFetch(`/ws/2/artist?query=${encodeURIComponent(q)}&fmt=json&limit=${MBM_EXACT_LIMIT}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); } catch (e) { return null; } // transient → don't cache, let a later pass retry if (!arr || arr.error || typeof arr.count !== 'number') return null; // throttled / error envelope — not an answer, don't cache // #613: unique only when MB returned EVERY match (a common name is never provably // unique — the search doesn't rank exact holders first). #522's score-gap tie-break kept. const idn = mbmExactIdentity(arr, name, { scoreGap: 20 }); let out = null; if (idn.status === 'unique') { const ent = await txpFetchEntity(idn.hit.id); if (ent && ent.gid) out = { entity: ent, via: idn.via }; } _txpAliasCache.set(key, out); return out; } // label counterparts (#522 follow-up: copyright-notice holders are usually // labels, occasionally the release artist itself) — same shape, /ws/js/label // and /ws/2/label instead of artist. const _txpLabelSearchCache = new Map(); async function txpSearchLabel(name, limit) { limit = limit || 8; const k = txpFold(name) + '|' + limit; if (!txpFold(name)) return []; if (_txpLabelSearchCache.has(k)) return _txpLabelSearchCache.get(k); let list = []; try { const j = await mbnFetch(`/ws/js/label?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) {} list = list.filter(c => c && (c.name || '').trim()); _txpLabelSearchCache.set(k, list); return list; } // #525 (majkinetor): "We should have Place entity too, along with adequate // roles and tab on picker." Places are how liner notes credit studios — // "Recorded At Abbey Road" — and MB models those as place<->release link // types ("recorded at", "mixed at", "mastered at", …), a different set from // the artist/label ones. Same search shape as labels. const _txpPlaceSearchCache = new Map(); async function txpSearchPlace(name, limit) { limit = limit || 8; const k = txpFold(name) + '|' + limit; if (!txpFold(name)) return []; if (_txpPlaceSearchCache.has(k)) return _txpPlaceSearchCache.get(k); let list = []; try { const j = await mbnFetch(`/ws/js/place?q=${encodeURIComponent(name)}&limit=${limit}&direct=false`, { headers: { Accept: 'application/json' } }).then(r => r.json()); list = Array.isArray(j) ? j : (j.results || []); } catch (e) {} list = list.filter(c => c && (c.name || '').trim()); _txpPlaceSearchCache.set(k, list); return list; } const _txpLabelAliasCache = new Map(); async function txpResolveLabelByExactAlias(name) { const key = txpFold(name); if (!key) return null; if (_txpLabelAliasCache.has(key)) return _txpLabelAliasCache.get(key); const q = mbmIdentityQuery(name, 'label'); if (!q) return null; let arr = null; try { arr = await mbnFetch(`/ws/2/label?query=${encodeURIComponent(q)}&fmt=json&limit=${MBM_EXACT_LIMIT}`, { headers: { Accept: 'application/json' } }).then(r => r.json()); } catch (e) { return null; } // transient → don't cache, let a later pass retry if (!arr || arr.error || typeof arr.count !== 'number') return null; // throttled / error envelope — not an answer, don't cache // #613: unique only when MB returned EVERY match (a common name is never provably // unique — the search doesn't rank exact holders first). #522's score-gap tie-break kept. const idn = mbmExactIdentity(arr, name, { scoreGap: 20 }); let out = null; if (idn.status === 'unique') { const ent = await txpFetchEntity(idn.hit.id, 'label'); if (ent && ent.gid) out = { entity: ent, via: idn.via }; } _txpLabelAliasCache.set(key, out); return out; } // #522 follow-up (majkinetor): "take a look at kellnerd's parse-copyright-notice // and incorporate some form of copyright note parsing" — copyright/phonographic // notices have their own fixed shape (a symbol/word, a year, a holder), not the // R/A credit DSL. "phonographic copyright" contains the word "copyright" too, so // its markers are stripped before checking for a bare © — otherwise every ℗ line // would also register as a (wrong) plain © hit. // #524 follow-up (majkinetor): "distributed by and friends, that would be // some improvement" — kellnerd's musicbrainz-scripts wiki (Parse-Copyright- // Notices) treats these as the same class of notice as ©/℗: a marker // phrase, an optional holder, all label→release (or artist→release, see // TXP_CR_LABEL_ONLY below) relationship types. "licensed to X" → X is the // MB "licensee"; "licensed from X" → X is the MB "licensor" (live-verified // against linkTypesForPair — "licensor"/"licensee" are MB's actual names, // not "licensed from/to"). "marketed and distributed by X" fires BOTH // "marketed" and "distributed" against the same X. const TXP_CR_TYPE_NAME = { phonographic: 'phonographic copyright', copyright: 'copyright', licensee: 'licensee', licensor: 'licensor', distributed: 'distributed', marketed: 'marketed', }; const TXP_CR_LABELS = { phonographic: '℗ phonographic copyright', copyright: '© copyright', licensee: 'licensed to', licensor: 'licensed from', distributed: 'distributed by', marketed: 'marketed by', }; // distributor/marketer/licensee are company-only concepts — MB has no // artist-release relationship type for any of them at all (live-verified); // copyright/phonographic/licensor exist for both, see txpCrEntityType. const TXP_CR_LABEL_ONLY = new Set(['distributed', 'marketed', 'licensee']); // leading-marker phrase alternatives — the compound "marketed and // distributed by" is listed first so it isn't partially eaten by the // plainer "distributed by" / "marketed by" alternatives first. const TXP_CR_MARKER_ALT = '(?:marketed\\s+and\\s+distributed\\s+by' + '|under\\s+(?:exclusive\\s+)?licen[sc]e\\s+to|licen[sc]ed\\s+to' + '|under\\s+(?:exclusive\\s+)?licen[sc]e\\s+from|licen[sc]ed\\s+from' + '|distributed\\s+by|marketed\\s+by' + '|℗|\\(p\\)|phonographic\\s+copyright' + '|©|\\(c\\)|copyright)'; function txpParseCopyrightLine(line) { const s = String(line).trim(); const types = []; if (/℗|\(p\)|\bphonographic\s+copyright\b/i.test(s)) types.push('phonographic'); const sansPhono = s.replace(/℗|\(p\)|\bphonographic\s+copyright\b/gi, ' '); if (/©|\(c\)|\bcopyright\b/i.test(sansPhono)) types.push('copyright'); if (/\bunder\s+(?:exclusive\s+)?licen[sc]e\s+to\b|\blicen[sc]ed\s+to\b/i.test(s)) types.push('licensee'); if (/\bunder\s+(?:exclusive\s+)?licen[sc]e\s+from\b|\blicen[sc]ed\s+from\b/i.test(s)) types.push('licensor'); if (/\bmarketed\s+and\s+distributed\s+by\b/i.test(s)) { types.push('marketed'); types.push('distributed'); } else { if (/\bdistributed\s+by\b/i.test(s)) types.push('distributed'); if (/\bmarketed\s+by\b/i.test(s)) types.push('marketed'); } if (!types.length) return null; // #524 follow-up (majkinetor, live): "℗ & © «R&S Records»" and // "© «R&S Records»" have no year at all and were silently rejected — a // year used to be required just to anchor where the holder text starts. // Strip the LEADING marker cluster instead and anchor on THAT; the year // (if any) is now optional, pulled out from wherever it sits in what's // left. Only the leading cluster — never markers ANYWHERE in the line — // a first cut that stripped "copyright" globally also ate it out of a // holder name that legitimately contained the word ("Some Copyright // Test Label"). One marker (+ its own leading glue) at a time, repeated, // since "℗ & ©" is two markers joined by "&". // #525 follow-up (majkinetor, live, screenshot): "Distributed By – // Rush Hour Music" left a stray leading "–" in the holder text — the // glue-stripping char classes below only covered the plain ASCII // hyphen, not the en/em-dash (and U+2010 hyphen) variants majkinetor's // own credit format actually uses as a separator after the marker // phrase. TXP_CR_GLUE matches the same dash set TXP_SEPS already // treats as equivalent separators elsewhere in this file. const TXP_CR_GLUE = '\\s,.:;&+/‐–—-'; const LEAD_MARKER_RE = new RegExp(`^(?:[${TXP_CR_GLUE}]|and\\b)*${TXP_CR_MARKER_ALT}`, 'i'); let rest = s; while (LEAD_MARKER_RE.test(rest)) rest = rest.replace(LEAD_MARKER_RE, ''); // strip leading glue AND a leading wrapper quote/guillemet before // hunting for the year — otherwise "℗ «1995 R&S Records»" keeps the "«" // in the way and the year never reads as adjacent to the marker. const LEAD_GLUE_RE = new RegExp(`^[${TXP_CR_GLUE}]+`, 'i'); rest = rest.replace(LEAD_GLUE_RE, '').replace(/^[«"“”'’‘]+/, '').replace(LEAD_GLUE_RE, ''); // #524 follow-up: the year (or year LIST, "1994, 1996" / "1994 & 1996") // is only ever expected immediately after the marker, before the holder // name starts — NOT scanned for throughout the rest of the line, which // would also strip a year that's genuinely part of a holder's own name // ("Pink Floyd (1987) Ltd."). Multiple years are ambiguous (which one // is THE year?) so, same call kellnerd's musicbrainz-scripts makes, // none is kept — but they're still consumed so they don't leak into // the holder text. const yearListRe = /^(\d{4})(?:\s*[,&/+]\s*(\d{4}))*\s*/; const ym = rest.match(yearListRe); let year = null; if (ym) { const allYears = ym[0].match(/\d{4}/g); year = allYears.length === 1 ? allYears[0] : null; rest = rest.slice(ym[0].length); } // strip leading connective glue ("&", "and", punctuation) then any // wrapping quote marks — plain quotes AND guillemets («»), since // "R&S Records" arrived quoted that way and used to leak a stray "»". let holderText = rest.replace(LEAD_GLUE_RE, '').replace(/^and\s+/i, ''); holderText = holderText.replace(/^[«"“”'’‘]+/, '').replace(/[»"“”'’‘]+$/, ''); holderText = holderText.replace(/\s+/g, ' ').trim(); if (!holderText) return null; // #524 follow-up: "distributed by and friends" also raised multi-holder // splits ("Shady Records/Aftermath Records/Interscope Records") — split // on / and | (not "-": far more often part of a real single name than a // separator), but only when EVERY resulting piece has enough substance // (kellnerd's own guard, refined to >=3 word characters after finding // 2 too permissive for "EMI Belgium SA/NV") — otherwise a real name // like "SA/NV" gets wrongly chopped into two fake holders. const splitParts = holderText.split(/\s*[/|]\s*/).map(p => p.trim()).filter(Boolean); const holders = (splitParts.length > 1 && splitParts.every(p => (p.match(/\w/g) || []).length >= 3)) ? splitParts : [holderText]; return { types, year, holders }; } // #574 (majkinetor): the date period a parsed notice year turns into. A notice // year is a POINT in time, not an open range: filling only begin_date made MB // render the relationship as "from 2021 to present", where what it should say // is "in 2021". Begin = end, with ended set, is what produces that. // // This is MB's own documented guideline for these relationship types, not our // reading of them (chaban-mb on #574) — verbatim, on the Label-Release // Copyright type this parser actually writes: // "When a year is specified (as in "© 2015 Naxos Rights US, Inc."), use that // year as both the begin and end date." // musicbrainz.org/relationship/2ed5a497-4f85-4b3f-831e-d341ad28c544 (©) // musicbrainz.org/relationship/7fd5fbc0-fbf4-4d04-be23-417d50a4dc30 (℗) // It is also the call kellnerd's parse-copyright-notice makes — its setYear() // sets begin_date, end_date and ended together. // // Applies to every notice kind, not only ©/℗: a year is only ever parsed off a // notice line in the first place (txpParseCopyrightLine), an ordinary R/A credit // row carries none, and licensed/distributed/marketed come off that same line // with that same single year. kellnerd draws the line in the same place. // // Returns null for a missing or unparseable year, so callers can pass it // straight through to dispatchRelationship as "no dates". const txpNoticeDatePeriod = year => { const y = parseInt(year, 10); if (!y) return null; return { begin_date: { year: y, month: null, day: null }, end_date: { year: y, month: null, day: null }, ended: true }; }; // #528 (majkinetor): "Copyright: X under exclusive license to Y" packs TWO // holders (X the copyright holder, Y the licensee) into one line — // txpParseCopyrightLine above strips only the LEADING marker, so both // derived rows (copyright + licensee) get the SAME undifferentiated // remainder as their holder text, and the right-click "clean up to // canonical name" fix (#525) then destructively rewrites BOTH rows since // they share identical raw text. Rather than teach the parser an "ordered // marker positions" model, PRE-SPLIT the raw line into two lines — each // with its own single marker — so the unmodified pipeline above just // handles each one normally. Deliberately mechanical, not semantic: // majkinetor, live: "you don't know where the X ends... this is enough // and right click will clean the suffixes" — this doesn't try to // understand where holder1 "ends" grammatically, it just cuts at the // NEXT recognized marker's own start (so both halves stay independently // parseable without any regex changes), accepting that leftover glue is // right-click's job, not this function's. Called ONCE on paste / // annotation-load only (see the 'paste' listener and annoBtn.onclick // below) — never on ordinary typing, so it doesn't fight a mid-edit user // (majkinetor: "Once on load"). function txpSplitCompoundCopyrightLines(text) { const markerRe = new RegExp(TXP_CR_MARKER_ALT, 'gi'); return text.split('\n').map(line => { const positions = [...line.matchAll(markerRe)].map(m => ({ start: m.index, end: m.index + m[0].length })); if (positions.length < 2) return line; // 0 or 1 marker (incl. one compound phrase) — nothing to split const cuts = []; for (let i = 1; i < positions.length; i++) { // real substance between the PREVIOUS marker's end and this one's // start — otherwise it's glue/punctuation, not a genuine second // holder (e.g. two adjacent symbols like "℗©" sit right next to // each other with nothing real between them). const gap = line.slice(positions[i - 1].end, positions[i].start); if ((gap.match(/\w/g) || []).length >= 3) cuts.push(positions[i].start); } if (!cuts.length) return line; const out = []; let last = 0; for (const idx of cuts) { out.push(line.slice(last, idx).trim()); last = idx; } out.push(line.slice(last).trim()); return out.join('\n'); }).join('\n'); } // ── annotation loading (#522): "make sure that tool can also load annotation as a source // as that is where credits may end up after import." MB's public /ws/2/ API doesn't expose // raw annotation text — ported (collapsed to just the LATEST version) from Apollo's own // annoFetchHistory/annoFetchVersion two-hop scrape, same-origin. function txpAnnoHtmlToText(html) { if (!html || /this annotation is empty/i.test(html)) return ''; const root = new DOMParser().parseFromString('<div>' + html + '</div>', 'text/html').body.firstChild; // turn block-level boundaries into newlines before reading textContent — the parser // works one credit per LINE, so paragraph/list/heading boundaries matter here. root.querySelectorAll('br').forEach(br => br.replaceWith('\n')); root.querySelectorAll('p, li, div, h1, h2, h3, tr').forEach(b => b.insertAdjacentText('afterend', '\n')); const text = root.textContent || ''; return text.split('\n').map(l => l.replace(/\s+/g, ' ').trim()).filter(Boolean).join('\n'); } async function txpFetchAnnotation(mbid) { const r = await fetch(`/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'); // the newest version's "View this version" link, #722 by its address (MB translates the text) const view = doc.querySelector('#annotation-history a[href*="/annotation/"]') || [...doc.querySelectorAll('table tr a')].find(a => /this version/i.test(a.textContent || '')); if (!view) return ''; // no annotation history at all const vr = await fetch(new URL(view.getAttribute('href'), location.origin).href, { credentials: 'same-origin' }); if (!vr.ok) throw new Error('version ' + vr.status); const vdoc = new DOMParser().parseFromString(await vr.text(), 'text/html'); const body = vdoc.querySelector('.annotation-body'); return body ? txpAnnoHtmlToText(body.innerHTML) : ''; } // ── Text parser modal (#522) ──────────────────────────────────────────── function txpStyle() { if (document.getElementById('gt-tp-style')) return; const s = el('style'); s.id = 'gt-tp-style'; s.textContent = '.gt-cons.gt-tp{width:min(1040px,96vw);resize:both;overflow:hidden;max-width:98vw;max-height:94vh;min-width:640px;min-height:340px}' + '.gt-cons.gt-tp.gt-tp-max{position:fixed;left:10px;right:10px;top:10px;bottom:10px;width:auto;height:auto;max-width:none;max-height:none;margin:0}' + '.gt-tp-ctrl{display:flex;align-items:center;gap:6px;padding:8px 14px;border-bottom:1px solid var(--mbu-border);flex-wrap:wrap}' // #539 follow-up (majkinetor): "With enough tracks, Match button goes to // next row" — a long "→ 12 tracks: 1, 2, 3 …" pushed the button off the // end. Scope and Match now travel as ONE right-hand group that shrinks // (the track list truncates) instead of wrapping the button away. + '.gt-cons-foot .gt-tp-scope{margin-right:auto}' + '.gt-tp-pat{width:180px;padding:5px 8px;border:1px solid var(--mbu-border);border-radius:5px;font:13px monospace;outline:none}' + '.gt-tp-pat:focus{border-color:var(--mbu-info)}' + '.gt-tp-clr{background:none;border:none;color:var(--mbu-text-weak);cursor:pointer;padding:2px 4px}.gt-tp-clr:hover{color:var(--mbu-text-dim)}' + '.gt-tp-presets{display:flex;gap:4px;flex-wrap:wrap}' + '.gt-tp-chip{font:11px monospace;background:var(--mbu-bg-raised);border:1px solid var(--mbu-border);border-radius:11px;padding:2px 9px;cursor:pointer;color:var(--mbu-text)}.gt-tp-chip:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-info)}' + '.gt-tp-scope{display:flex;align-items:center;gap:6px;font-size:11.5px;color:var(--mbu-text-dim);background:var(--mbu-bg-raised);border-radius:11px;padding:3px 10px;min-width:0}' + '.gt-tp-scope-lbl{color:var(--mbu-text-weak)}' + '.gt-tp-scope-sel,.gt-tp-det-sel{font:inherit;font-size:11px;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);padding:1px 3px}' + '.gt-tp-tracks{font:inherit;font-size:11px;width:130px;border:1px solid var(--mbu-border);border-radius:5px;padding:1px 6px}' + '.gt-tp-tracks-info{font-size:10.5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;flex:0 1 auto;min-width:0}' + '.gt-cons-hdr .gt-tp-anno{margin-left:auto;padding:4px 10px;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);cursor:pointer;font-size:12px;font-family:inherit;color:var(--mbu-text)}.gt-cons-hdr .gt-tp-anno:hover{background:var(--mbu-bg-raised)}' + '.gt-tp-src{padding:0 14px}' + '.gt-tp-srctgl{display:block;background:none;border:none;color:var(--mbu-info);cursor:pointer;font-size:12px;font-family:inherit;padding:6px 0}' + '.gt-tp-ta{width:100%;box-sizing:border-box;min-height:70px;padding:6px 8px;border:1px solid var(--mbu-border);border-radius:5px;font:12px monospace;outline:none;resize:vertical}.gt-tp-ta:focus{border-color:var(--mbu-info)}' + '.gt-tp-body{padding-top:8px;overflow:auto}' // #522 follow-up (majkinetor, live): the column widths' own sum was // meant to roughly fit the panel, but td/th padding defaults to // content-box (added ON TOP of each <col>'s specified width, not // absorbed into it) — border-box makes the specified width the // actual rendered width, padding included, which is what a resize // handle dragged to an exact pixel amount should mean anyway. + '.gt-tp-tbl{table-layout:fixed}' + '.gt-tp-tbl th,.gt-tp-tbl td{box-sizing:border-box}' + '.gt-tp-tbl th{white-space:nowrap;position:relative;overflow:hidden;text-overflow:ellipsis}' + '.gt-tp-tbl td{vertical-align:top;padding:3px 8px;font-size:12px;border-bottom:1px solid var(--mbu-border);overflow:hidden;text-overflow:ellipsis}' /* #597 the "tracks" column — where this one credit is going */ + '.gt-tp-trk{font-size:11px;white-space:nowrap}' + '.gt-tp-trk-on{color:var(--mbu-ok);font-weight:600;cursor:help}' + '.gt-tp-trk-warn{color:var(--mbu-warn);cursor:help}' + '.gt-tp-trk-dim{color:var(--mbu-text-weak)}' // #522 follow-up (majkinetor, live): "Column bars are not visible (so // it hard to resize)" then "column separators are very fat now, make // them line" — a permanent but THIN (1px) line, drawn via a // pseudo-element inset inside a wider (7px) invisible drag target; // both stay within right:0's own box (not a negative offset) so a // th's own overflow:hidden (for its text-overflow ellipsis) doesn't // clip either of them. + '.gt-tp-colresize{position:absolute;top:0;right:0;width:7px;height:100%;cursor:col-resize;z-index:2}' + '.gt-tp-colresize::after{content:"";position:absolute;top:0;bottom:0;right:3px;width:1px;background:var(--mbu-bg-sunken)}' + '.gt-tp-colresize:hover::after{background:var(--mbu-info-bg)}' + '.gt-tp-row.gt-tp-nomatch{opacity:.55}' // #522 sixth round (majkinetor, live): "We still don't have // non-intrusive raw background color (like in CH)" — Credit // Hoarder tints its whole review-table ROW by status (a soft // yellow for "needs a look", soft red for "needs attention", white // once clean), not just a small dot; same idea here, keyed off the // same status the dot already uses. + '.gt-tp-row.gt-tp-st-amber{background:var(--mbu-warn-bg)}' + '.gt-tp-row.gt-tp-st-red{background:var(--mbu-error-bg)}' + '.gt-tp-dot{display:inline-block;width:9px;height:9px;border-radius:50%;margin-top:3px}' + '.gt-tp-dot-green{background:#2e9e5b}.gt-tp-dot-amber{background:#d68910}.gt-tp-dot-red{background:#c0392b}' + '.gt-tp-ov{box-sizing:border-box;width:100%;min-width:0;padding:3px 6px;border:1px solid var(--mbu-border);border-radius:4px;font:11px monospace;outline:none}.gt-tp-ov:focus{border-color:var(--mbu-info)}' /* #544: track the column instead of a fixed 110px, so a narrow column cannot clip the caret */ + '.gt-tp-rawwrap{display:flex;align-items:center;gap:4px;width:100%}' + '.gt-tp-raw{flex:1;min-width:0;padding:3px 6px;border:1px solid transparent;border-radius:4px;background:none;color:var(--mbu-text-dim);font-size:11px;font-family:inherit;outline:none}.gt-tp-raw:hover{border-color:var(--mbu-border)}.gt-tp-raw:focus{border-color:var(--mbu-info);background:var(--mbu-bg)}' + '.gt-tp-rowdel{flex:none;background:none;border:none;color:var(--mbu-info);cursor:pointer;padding:2px 4px;font-size:12px}.gt-tp-rowdel:hover{color:var(--mbu-error)}' + '.gt-tp-txt{color:var(--mbu-text)}' // #522 follow-up (majkinetor, live): "Tidy up artist / role column - // remove circles, short helper text so it doesn't reflow (use just // search word in both), plain text after selection. Remove [↗] icon, // let left click open artist." Unresolved = a plain text-link reading // "search"; resolved = plain text (a real <a> for artist, so the // click IS the open — no separate icon). + '.gt-tp-search{background:none;border:none;padding:0;cursor:pointer;color:var(--mbu-info);text-decoration:underline;font-size:11px;font-family:inherit}.gt-tp-search:hover{color:var(--mbu-info)}' + '.gt-tp-resolved{color:var(--mbu-text)}' + 'a.gt-tp-resolved,button.gt-tp-resolved{color:var(--mbu-info);text-decoration:none}a.gt-tp-resolved:hover,button.gt-tp-resolved:hover{text-decoration:underline;color:var(--mbu-info)}' + '.gt-tp-status{color:var(--mbu-text-weak);font-size:11px;white-space:nowrap}' + '.gt-tp-applied{color:var(--mbu-ok);font-weight:600}' + '.gt-cons-foot .gt-tp-cnt{flex:1;font-size:12px;color:var(--mbu-text-dim)}' // #525: moved up into .gt-tp-ctrl (top bar, right-aligned after the // Scope pill via its margin-left:auto) to match Match Works' own // toolbar convention — same bold-purple "primary" treatment as its // ⚡ Match button (.gt-wm-btn.primary). + '.gt-tp-freeze{flex:0 0 auto;cursor:pointer;border:1px solid var(--mbu-accent);background:var(--mbu-bg-raised);border-radius:4px;padding:3px 7px;font-size:12px;line-height:1}' + '.gt-tp-freeze:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent)}' + '.gt-tp-ctrl .gt-tp-resolve{margin-left:auto;flex:0 0 auto;white-space:nowrap;padding:5px 12px;border:1px solid transparent;border-radius:5px;background:transparent;cursor:pointer;font-size:13px;font-family:inherit;color:var(--mbu-accent-text);font-weight:bold}' + '.gt-tp-ctrl .gt-tp-resolve:hover{background:linear-gradient(#7a52df,#5f3ec0);color:#fff;border-color:var(--mbu-accent)}' + '.gt-tp-ctrl .gt-tp-resolve:disabled{opacity:.45;cursor:default;pointer-events:none}' + '.gt-tp-apop{width:340px}' // #524/#525 follow-up (majkinetor): "2 tabs in search, one for artist // and other for labels" — shown for any row whose entity type isn't // FORCED (see canToggle in txpPickEntity): copyright/phonographic/ // licensor holders, and ordinary roles MB allows on both sides. + '.gt-tp-apop .gt-tp-tabs{display:flex;gap:4px;margin-bottom:6px}' + '.gt-tp-apop .gt-tp-tab{flex:1;padding:4px 0;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);cursor:pointer;font-size:12px;font-family:inherit;color:var(--mbu-text-dim)}' + '.gt-tp-apop .gt-tp-tab:hover{background:var(--mbu-bg-raised)}' + '.gt-tp-apop .gt-tp-tab-on{background:#2e6da4;border-color:var(--mbu-info);color:#fff}' + '.gt-tp-apop .gt-tp-qwrap{display:flex;gap:5px;margin-bottom:6px}' + '.gt-tp-apop .gt-tp-q{flex:1;min-width:0;box-sizing:border-box;padding:5px 7px;border:1px solid var(--mbu-border);border-radius:5px;font-size:13px;font-family:inherit;outline:none}' + '.gt-tp-apop .gt-tp-plus{flex:0 0 auto;width:28px;box-sizing:border-box;border:1px solid var(--mbu-border);border-radius:5px;background:var(--mbu-bg);cursor:pointer;font:15px monospace;color:var(--mbu-info);line-height:1}.gt-tp-apop .gt-tp-plus:hover{background:var(--mbu-bg-raised);border-color:var(--mbu-info)}' + '.gt-tp-apop .gt-tp-hint{color:var(--mbu-text-weak);font-size:10px;margin-bottom:4px}' + '.gt-tp-apop .gt-tp-results{max-height:260px;overflow:auto}' + '.gt-tp-apop .gt-tp-res{padding:5px 7px;border-radius:5px;cursor:pointer}.gt-tp-apop .gt-tp-res:hover{background:var(--mbu-info-bg)}' + '.gt-tp-apop .gt-tp-restype{color:var(--mbu-text-weak);font-size:10px;text-transform:uppercase;margin-right:5px}' // #544: disambiguation must not read as part of the name — smaller, grey, // italic, in the results AND in the resolved table cell. + '.gt-tp-disamb{color:var(--mbu-text-weak);font-size:11px;font-style:italic;font-weight:400}' + '.gt-tp-apop .gt-tp-resname{color:var(--mbu-text)}' // #544: a background create no longer holds the popover open — the row says // what it is waiting for, and pulses so "still working" is visible at a glance. // nowrap + ellipsis: the entity column is narrow and a two-line placeholder // pushed every row taller — the name is already trimmed, this is the backstop + '.gt-tp-creating{display:inline-block;max-width:100%;box-sizing:border-box;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:bottom;color:var(--mbu-accent-text);font-size:11px;font-style:italic;border:none;background:none;font-family:inherit;cursor:pointer;border-radius:3px;padding:0 4px;animation:gt-tp-pulse 1.15s ease-in-out infinite}' + '.gt-tp-creating:hover{color:var(--mbu-error);text-decoration:underline}' // #544: it cancels + '@keyframes gt-tp-pulse{0%,100%{background:transparent}50%{background:var(--mbu-bg-hover)}}' + '@media (prefers-reduced-motion:reduce){.gt-tp-creating{animation:none;background:var(--mbu-bg-raised)}}'; document.head.appendChild(s); } let txpEl = null; // #522 follow-up (majkinetor, live): "Esc during role selection closes main // popup" — a nested picker (the role picker's own overlay, or the shared // artist/label-search popover) should close on Escape WITHOUT taking the // whole tool down too. Both this and the nested pickers' own Escape // handling are registered on `document` with capture:true — when a nested // picker is open, just return (don't stopPropagation) so its own handler, // registered later, still gets its turn on the same event. function onTxpKey(e) { if (e.key !== 'Escape') return; if (document.querySelector('.gt-role-pick') || popEl) return; e.stopPropagation(); closeTextParser(); } // #544 follow-up (majkinetor): "Maximized state is still not remembered." // saveState() was only ever called by the actions that CHANGE row data — // maximizing, or loading the annotation, changed the window and saved nothing, // so whether they survived a close depended entirely on whether some unrelated // save happened to fire afterwards. (That is also why the round-trip test // passed: its freeze click saved right after the maximize.) Saving once on the // way out makes the rule "what you last saw is what comes back", regardless of // which control was touched last. The parser registers its own saver here, // since saveState lives inside openTextParser's closure. let _txpSaveOnClose = null; function closeTextParser() { if (!txpEl) return; try { if (_txpSaveOnClose) _txpSaveOnClose(); } catch (e) {} _txpSaveOnClose = null; txpEl.remove(); txpEl = null; document.removeEventListener('keydown', onTxpKey, true); } // #522 follow-up: "it would be good to keep window state after closing it // on particular release" — remembers the pasted text / pattern per release // gid. Deliberately an in-memory object, NOT a GM value: majkinetor // (live, follow-up): "restarting popup should keep the state only within // current session. If I reload the page, it should not." A plain // module-level object already does exactly that — it survives the popup's // own DOM being torn down and rebuilt (same page, same JS context) but // resets for free on a real reload (fresh script injection). const _txpStateMemory = {}; function txpLoadState(gid) { return _txpStateMemory[gid] || null; } function txpSaveState(gid, state) { _txpStateMemory[gid] = state; const keys = Object.keys(_txpStateMemory); if (keys.length > 30) delete _txpStateMemory[keys[0]]; } // #522 follow-up (majkinetor): "auto resolve could find similar roles // (compiled -> compiler; mastered by -> mastering)". A crude but effective // stem: drop "by", drop a handful of common suffixes, so "mastered" and // "mastering" both reduce to "master", "compiled" and "compiler" to // "compil". Used only as a FALLBACK when a plain substring match is // ambiguous or absent, and only auto-binds when it narrows to one. function txpRoleStem(s) { return String(s || '').toLowerCase().replace(/\bby\b/g, ' ').replace(/\s+/g, ' ').trim() .replace(/(ies)$/, 'y').replace(/(ations?|ing|ers?|ed|es|s)$/, ''); } function openTextParser() { closeTextParser(); closeDatePicker(); closeConsolidate(); closePopover(); txpStyle(); const re0 = RE(); const release = re0 && re0.state && re0.state.entity; if (!release) { toast('Editor not ready'); return; } const saved = txpLoadState(release.gid); let pattern = (saved && saved.pattern) || _txpPattern; // #539 (majkinetor): "Currently we have Scope: Release only … This could be // done faster if user is allowed to select recording by track number(s)." // He was applying credits to the release and then moving them onto one // recording, four times over. `spec` is a track selector (see txpMatchTracks) // and only means anything while kind === 'recording'. // #597: `tracks` is the per-row track detection mode — 'auto' reads a track // clause out of each credit's own role text (see txpDetectTracks), 'off' // restores the pre-#597 behaviour where a row can only ever go where the // Scope control says. On by default: the clause is there to be obeyed, and // a run that silently ignores it is the bug this fixes. let scope = { kind: (saved && saved.scopeKind) || 'release', spec: (saved && saved.scopeSpec) || '', tracks: (saved && saved.scopeTracks) || 'auto' }; let lines = []; // [{ raw, override }] // #522 follow-up (majkinetor): drives whether "Apply & clear annotation" // is even offered — only true right after "Load annotation" succeeds, // reset by any real typing into the textarea (see the dedicated 'input' // listener below; a *programmatic* ta.value set does NOT fire it). let loadedFromAnnotation = false; // both auto-resolved (resolveAll) AND manual (a picker choice) resolutions // live in these same two caches, keyed by the row's own TEXT — not row // POSITION. Position-keying was tried first and had a real bug: pasting // brand-new text reuses the same li:pi:si row positions, so an OLD // manual pick silently "leaked" onto completely unrelated NEW text at // that same position (caught live: "Alice" inherited a stale artist // pick left over from an earlier, different paste). Text-keying matches // how the auto-resolve cache already behaved, and means "I picked X for // 'Alice'" sticks to 'Alice' wherever she appears, not to a row slot. const roleCache = new Map(); // lowercased role text -> classifyRoleText() result (ordinary rows only — crKind rows never touch this) const entityCache = new Map(); // lowercased holder/entity text -> entity | null // #522 follow-up (majkinetor): "while it is resolving, lets show a // message ... resolving 4/N" then "make it show in the button itself" — // resolveAll() updates the Resolve button's own text as it goes. // Scoped to just the two real-network-lookup loops (copyright/legal // holders + ordinary credit artists) — role resolution is synchronous/ // instant, counting it wouldn't make the "how much is left" number any // more useful. let resolveDone = 0, resolveTotal = 0; // #522 follow-up (majkinetor, live): "if one artist has multiple // instruments, selecting one selects all. Lets redo that thing so that // right clicking a choice in search sets all, and normal clicking only // that 1." A text-keyed cache is right for ROLE (a role name like // "Guitar" always means the same MB link type, so one pick should // apply everywhere it appears) but wrong for ARTIST by default (the // same name string is a common real collision) — so a normal click now // writes here, POSITION-keyed, scoped to just the one row clicked; // right-click still writes into the shared entityCache above, // propagating to every row with that exact text like before. const entityOverride = new Map(); // row.key (li:pi:si) -> entity // #544 (majkinetor): "After right clicking to create entity in the background, // close search popup. While background tab is active, add info about it // (`creating ...`) that resolves to entity name after tab is closed as usual. // ... The goal is me not waiting for tab to finish or manually closing it, as // I want to continue to other items while this one is being worked on." // Keyed by the same lowercased entity text as entityCache, because a // background create resolves in BULK — so every row showing that text says // "creating…" and every one of them flips to the entity when it lands. // Deliberately NOT persisted by saveState: the BroadcastChannel listener that // completes it dies with the page, so a restored "creating…" could never // resolve. After a reload the row honestly reads "search" again. const pendingCreates = new Map(); // lowercased entity text -> { kind, name } const appliedKeys = new Set(); // row position keys (li:pi:si) — "applied" IS about a specific dispatch, stays position-based const compiledCache = new Map(); const compiledFor = pat => { if (!compiledCache.has(pat)) { try { compiledCache.set(pat, txpCompile(pat)); } catch (e) { compiledCache.set(pat, null); } } return compiledCache.get(pat); }; // #522 follow-up (majkinetor): "one raw line can have multiple artists // and roles" — a line with a `;` is treated as several independent // credit pairs joined together (e.g. "Guitar: Alice; Bass: Bob"), each // parsed on its own; a line's pattern OVERRIDE still applies to every // piece of that same line uniformly. const splitPairs = raw => raw.includes(';') ? raw.split(/\s*;\s*/).filter(s => s.trim()) : [raw]; // #522 follow-up (majkinetor): "Copyright is always bundled together // with other credits. The line can be recognized by mentioned symbols // so that should be marker to switch mode." No separate Copyright mode // anymore — a ©/(C)/℗/(P) marker on a line/piece is itself the trigger, // checked before the normal R/A pattern, so a paste can freely mix // ordinary credits and copyright lines together. function parsedRows() { const out = []; // #597: read the page's track rows ONCE per parse, not once per credit — // txpTrackRows walks the whole editor table, and this runs on every // render. null when detection is off, which also skips the work entirely. const trackRows = scope.tracks === 'auto' ? txpTrackRows() : null; // Lift a track clause off a row's role text and hand the row its own // targets. The clause is REMOVED from the role, so everything downstream // (role matching, roleCache keying, the preview) sees "Lead vocals", not // "Lead vocals (tracks 2,6,9)". const attachTracks = (rec) => { if (!trackRows || !rec.role) return rec; const det = txpDetectTracks(rec.role, trackRows); if (!det) return rec; if (det.ambiguous) { rec.trackNote = `${det.ambiguous} track lists — split the line`; return rec; } if (det.clean) rec.role = det.clean; rec.trackSpec = det.spec; rec.trackTargets = det.targets; return rec; }; lines.forEach((ln, li) => { if (!ln.raw.trim()) return; // blank lines are silently skipped, not even shown unmatched splitPairs(ln.raw).forEach((piece, pi) => { const cr = txpParseCopyrightLine(piece); if (cr) { // #524: one row per (type × holder) combination — "marketed // and distributed by Sony" is 2 types, 1 holder = 2 rows; // "℗ 2012 Shady Records/Aftermath Records/..." is 1 type, 3 // holders = 3 rows, all sharing the same underlying notice. let si = 0; cr.types.forEach(kind => cr.holders.forEach(holder => out.push({ li, pi, si: si++, raw: ln.raw, role: TXP_CR_LABELS[kind], crKind: kind, entity: holder, year: cr.year, matched: true, }))); return; } const pat = (ln.override || pattern || '').trim(); const compiled = pat ? compiledFor(pat) : null; const expanded = compiled ? txpExpand(compiled, piece) : null; if (!expanded) { out.push({ li, pi, si: 0, raw: ln.raw, role: null, entity: null, matched: false }); return; } expanded.forEach((row, si) => out.push(attachTracks({ li, pi, si, raw: ln.raw, role: row.role || '', entity: row.entity || '', matched: true }))); }); }); return out; } // #524 follow-up (majkinetor): "regarding artist vs label, maybe we can // have 2 tabs in search" — kellnerd's musicbrainz-scripts takes a // simpler tack that covers the common case for free: default to label, // but if the holder name matches one of the RELEASE's own credited // artists, it's almost certainly that artist crediting themselves (the // whole reason the ambiguity exists) — auto-detect that, and offer the // 2-tab toggle in the picker (txpPickEntity) as the manual override for // when auto-detect guesses wrong. Shared by BOTH copyright/legal-notice // holders (via txpCrEntityType below) and ordinary credit rows (via // rowEntityType, #525) — the same ambiguity, the same fallback. // #525 follow-up (majkinetor, live): "one for the company was selected" // — "mastering" and "graphic design" (among others) turn out to ALSO // exist as label-release relationship types in MB (id 1293 and 1172, // live-verified), so classifyRoleText correctly sees them matching BOTH // sides at an equal (exact) tier and calls it genuinely ambiguous — but // defaulting an ambiguous ORDINARY credit row to label (a "company") // is wrong far more often than right: liner-note credits are // overwhelmingly people, not companies, and the release-own-artist // auto-detect below almost never fires true for them (it's really // about a self-releasing artist crediting themselves in a COPYRIGHT // line, #524's original case). Copyright/legal-notice holders keep the // opposite default (label) — that bias is correct there; a ©/℗ line // really is usually attributed to a company. function txpAutoEntityType(text, defaultType) { const names = (release.artistCredit && release.artistCredit.names) || []; const isReleaseArtist = names.some(n => n.artist && (txpSameName(n.artist.name, text) || txpSameName(n.artist.sort_name, text))); return isReleaseArtist ? 'artist' : (defaultType || 'label'); } function txpCrEntityType(kind, holder) { if (TXP_CR_LABEL_ONLY.has(kind)) return 'label'; // MB has no artist-release type for these at all return txpAutoEntityType(holder, 'label'); } // #525 (majkinetor, from a screenshot): "'Published by' is also label, // but artist is offered... We either map roles to entities or always // show label/artist tab." Ordinary credit rows used to be hardcoded // artist-only on BOTH ends (role AND entity) — "published" has no // artist-release relationship type at all (live-verified), so it could // never actually resolve, toggle or not. Now every role text is // classified against BOTH linkTypesForPair('artist','release') and // ('label','release'): a role that only exists on one side FORCES that // entity type (no toggle needed, e.g. "published" → label); a role that // exists on both (e.g. "licensor") or matches neither falls back to the // same auto-detect + manual-toggle escape hatch #524 already built for // copyright holders. See classifyRoleText (used by resolveAll to build // roleCache) and rowEntityType below. // returns {hit, tier} — tier 0 (exact) beats 1 (loose substring) beats 2 // (stem fallback), or null if nothing matches unambiguously at any tier. // #525 (majkinetor, live, screenshot): "Biography and Pictures" role // wrongly auto-resolved to "pi" — a real MB instrument name that just // happens to be a 2-character substring of "pictures". The loose tier // checks containment in BOTH directions; cn.includes(rt) (the real // candidate name contains what the user typed, e.g. "Hammond" inside // "Hammond organ") is safe regardless of length — the user's own text // is the deliberate, specific side. rt.includes(cn) (an unrelated, // often much longer role text happens to contain a SHORT candidate // name) is the risky direction: almost any sufficiently long string // will coincidentally contain some short vocabulary entry. Requiring // the candidate be at least 4 characters for THAT direction keeps // genuine short-name matches (e.g. "bass" inside "Bass Guitar") while // rejecting 2-3 character coincidences like "pi". function txpMatchRoleText(cands, rt) { if (!rt) return null; const exact = cands.filter(c => c.name.toLowerCase() === rt); if (exact.length === 1) return { hit: exact[0], tier: 0 }; const loose = cands.filter(c => { const cn = c.name.toLowerCase(); return cn.includes(rt) || (cn.length >= 4 && rt.includes(cn)); }); if (loose.length === 1) return { hit: loose[0], tier: 1 }; const stem = txpRoleStem(rt); const fuzzy = cands.filter(c => txpRoleStem(c.name) === stem); return fuzzy.length === 1 ? { hit: fuzzy[0], tier: 2 } : null; } // #666: "Guest Vocals", "Additional Guitar", "Co-Producer", "Executive // Producer" — a leading qualifier is an ATTRIBUTE, not part of the role. // Strip it, classify the rest, and add the qualifier as an extra attribute // when the resolved link type allows it (e.g. "co" is fine on producer, // not on writer). Anything else — no qualifier, the rest doesn't resolve, // or the type doesn't take that qualifier — falls back to classifying the // whole text exactly as before. function classifyRoleText(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands) { const fm = TXP_FLAG_RE.exec(rt || ''); if (fm) { const flagId = txpFlagAttrId(fm[1]); const base = flagId ? classifyBaseRole(fm[2], artistCands, labelCands, instrumentLt, instrumentCands, placeCands) : null; const m = base && (base.instrumentMatch || (base.forced === 'artist' ? base.artistMatch : null)); if (m && txpLtAllowsAttr(m.id, flagId)) { const ids = [...(m.attributeIds || (m.attributeId ? [m.attributeId] : [])), flagId]; const withFlag = { id: m.id, name: `${fm[1]} ${m.name}`, attributeIds: ids }; try { console.debug('[Group Therapy] #666 role qualifier:', JSON.stringify({ rt, flag: fm[1], flagId, lt: m.id, ids })); } catch (e) {} return { artistMatch: null, labelMatch: null, placeMatch: null, instrumentMatch: withFlag, forced: 'artist' }; } try { console.debug('[Group Therapy] #666 qualifier not applied:', JSON.stringify({ rt, flag: fm[1], flagId, base: m ? m.id : null })); } catch (e) {} } return classifyBaseRole(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands); } function classifyBaseRole(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands) { rt = TXP_VOCAL_SYNONYMS[rt] || rt; // #666: "backing vocals" → "background vocals" const a = txpMatchRoleText(artistCands, rt); const l = txpMatchRoleText(labelCands, rt); const pl = txpMatchRoleText(placeCands || [], rt); const artistMatch = a ? a.hit : null; const labelMatch = l ? l.hit : null; const placeMatch = pl ? pl.hit : null; let instrumentMatch = null; // #666: vocal types. "lead vocals" LOOSELY matches the bare "vocal" link // type (it contains "vocal"), so a vocal-attribute hit wins over that // loose/stem hit — never over an exact link-type match. Only tried when // the text says "vocal", so "Tenor" / "Soprano" stay with the // saxophones in the instrument vocabulary as before. const vocalLt = artistCands.find(c => c.name === 'vocal'); if (vocalLt && /vocal/.test(rt) && (!a || (a.hit.id === vocalLt.id && a.tier > 0))) { const vHit = txpMatchRoleText(txpVocalCandidates(), rt); if (vHit) instrumentMatch = { id: vocalLt.id, name: vHit.hit.name, attributeId: vHit.hit.id }; try { console.debug('[Group Therapy] #666 vocal type:', JSON.stringify({ rt, hit: vHit ? vHit.hit : null })); } catch (e) {} } if (!instrumentMatch && !a && !l && instrumentLt) { const iHit = txpMatchRoleText(instrumentCands, rt); if (iHit) instrumentMatch = { id: instrumentLt.id, name: iHit.hit.name, attributeId: iHit.hit.id }; } // instrument roles are inherently artist-only. A role matching only ONE // side forces that side outright. A role matching BOTH sides is only // genuinely ambiguous when both matched at the SAME confidence tier — // when one side matched more confidently (e.g. "published by" LOOSELY // matches the label type "published", id 362, but only STEM-matches // the unrelated artist type "publishing", id 32 — "the artist // publishes this release", a real but different concept, live-caught // by this exact case), the stronger tier wins outright rather than // falling back to "ambiguous, ask the user." // Place roles read "<verb> at" where the artist/label ones read "<verb> by", // so they rarely collide — but when they do, the same tier rule decides. let forced = null; const tiers = [['artist', a], ['label', l], ['place', pl]].filter(([, x]) => x); if (instrumentMatch) forced = 'artist'; else if (tiers.length === 1) forced = tiers[0][0]; else if (tiers.length > 1) { tiers.sort((x, y) => x[1].tier - y[1].tier); forced = tiers[0][1].tier < tiers[1][1].tier ? tiers[0][0] : null; // a clear winner, or genuinely ambiguous } return { artistMatch, labelMatch, placeMatch, instrumentMatch, forced }; } // the entity type for an ORDINARY credit row: forced by its role's own // classification when unambiguous, otherwise the same release-artist- // credit auto-detect crKind rows already use. function rowEntityType(row) { const rt = (row.role || '').toLowerCase().trim(); const cls = roleCache.get(rt); return (cls && cls.forced) || txpAutoEntityType(row.entity, 'artist'); } function attachResolution(row) { row.key = row.li + ':' + row.pi + ':' + row.si; if (!row.matched) { row.roleMatch = null; row.entityMatch = null; row.entityType = null; row.entityForced = null; row.creating = null; return row; } row.entityMatch = entityOverride.has(row.key) ? entityOverride.get(row.key) : (entityCache.get((row.entity || '').toLowerCase().trim()) || null); row.creating = pendingCreates.get((row.entity || '').toLowerCase().trim()) || null; // #544 background create in flight // #525 follow-up (majkinetor): "Can we just replace role with the // other one once the entity is selected? That way it should never // happen." A pre-resolution guess (forced-or-auto-detected) can only // ever be a best effort for a genuinely ambiguous role — once an // entity is ACTUALLY resolved (auto or manually, including via the // Artist/Label toggle), its own real entityType is ground truth and // decides which relationship type applies, no more guessing. A // FORCED type (role only exists on one side) still wins outright — // there's no valid alternate relationship type to fall back to even // if a pasted MBID happened to be the other kind. if (row.crKind) { row.entityForced = TXP_CR_LABEL_ONLY.has(row.crKind) ? 'label' : null; row.entityType = row.entityForced || (row.entityMatch ? row.entityMatch.entityType : txpCrEntityType(row.crKind, row.entity)); row.roleMatch = row.entityMatch ? txpCopyrightLinkType(row.crKind, row.entityType) : null; return row; } const rt = (row.role || '').toLowerCase().trim(); const cls = roleCache.get(rt) || null; row.roleClass = cls; row.entityForced = (cls && cls.forced) || null; row.entityType = row.entityForced || (row.entityMatch ? row.entityMatch.entityType : rowEntityType(row)); row.roleMatch = cls ? (row.entityType === 'label' ? cls.labelMatch : row.entityType === 'place' ? cls.placeMatch : (cls.instrumentMatch || cls.artistMatch)) : null; return row; } function txpCopyrightLinkType(kind, entityType) { const roles = linkTypesForPair(entityType, 'release'); return roles.find(r => r.name.toLowerCase() === TXP_CR_TYPE_NAME[kind]) || null; } const dotClass = r => !r.matched ? 'gt-tp-dot-red' : (r.roleMatch && r.entityMatch) ? 'gt-tp-dot-green' : 'gt-tp-dot-amber'; const statusText = r => { if (!r.matched) return 'no match'; if (appliedKeys.has(r.key)) return '✓ applied'; if (r.roleMatch && r.entityMatch) return 'ready'; return [!r.roleMatch && 'role?', !r.entityMatch && 'entity?'].filter(Boolean).join(' '); }; txpEl = el('div', 'gt-cons-ov'); const panel = el('div', 'gt-cons gt-tp'), hdr = el('div', 'gt-cons-hdr'); hdr.appendChild(el('span', 'gt-cons-title', 'Text parser')); const annoBtn = el('button', 'gt-tp-anno', 'Load annotation'); annoBtn.type = 'button'; hdr.appendChild(annoBtn); // #522 follow-up (majkinetor): "Add resizable columns and maximize // button" — same maximize/restore toggle Match Works already has. const maxBtn = el('button', 'gt-cons-x', '⛶'); maxBtn.type = 'button'; maxBtn.title = 'Maximize / restore'; maxBtn.onclick = () => { if (panel.classList.toggle('gt-tp-max')) { panel._savedW = panel.style.width; panel._savedH = panel.style.height; panel.style.width = ''; panel.style.height = ''; maxBtn.textContent = '❐'; maxBtn.title = 'Restore'; } else { panel.style.width = panel._savedW || ''; panel.style.height = panel._savedH || ''; maxBtn.textContent = '⛶'; maxBtn.title = 'Maximize'; } saveState(); // #544 follow-up: maximizing changed the window and saved nothing }; hdr.appendChild(maxBtn); const xb = el('button', 'gt-cons-x', '✕'); xb.type = 'button'; xb.onclick = closeTextParser; hdr.appendChild(xb); const ctrl = el('div', 'gt-tp-ctrl'); const patIn = el('input', 'gt-tp-pat'); patIn.type = 'text'; patIn.value = pattern; const patClr = el('button', 'gt-tp-clr', '✕'); patClr.type = 'button'; patClr.title = 'Clear pattern'; const presets = el('span', 'gt-tp-presets'); TXP_PRESETS.forEach(p => { const c = el('button', 'gt-tp-chip', p); c.type = 'button'; c.onclick = () => { patIn.value = p; onPatternChange(); }; presets.appendChild(c); }); // #525 (majkinetor): "lets move the Resolve all to the top, to match the // standard, here is the example in GT's work matcher" then "Name it also // Match with the icon" — mirrors Match Works' own toolbar exactly: its // "⚡ Match" button lives at the right of the TOP bar, not the footer // (renderWorkMatch's `tb`), styled the same bold-purple "primary" way. const resolveBtn = el('button', 'gt-tp-resolve', '⚡ Match'); resolveBtn.type = 'button'; resolveBtn.title = 'Resolve every role and entity that can auto-resolve'; // #539: scope picker. Release is still the default — the recording scope is // opt-in and inert until a track selector is typed. const scopeWrap = el('span', 'gt-tp-scope'); const scopeSel = el('select', 'gt-tp-scope-sel'); [['release', 'Release'], ['recording', 'Recordings']].forEach(([v, t]) => { const o = el('option', '', t); o.value = v; scopeSel.appendChild(o); }); scopeSel.value = scope.kind; const tracksIn = el('input', 'gt-tp-tracks'); tracksIn.type = 'text'; tracksIn.value = scope.spec; tracksIn.placeholder = 'tracks: 1,3,5-7 · 2:4 · all'; tracksIn.title = 'Which recordings the credits go on: a track number as shown (3, A1), a range (5-7), ' + 'a medium-qualified number or range (2:4, 2:4-6), a whole medium (2:*), or all. ' + 'Leave empty to use the tracks ticked in the editor.'; const tracksInfo = el('span', 'gt-tp-tracks-info', ''); // #597: per-row track detection. Separate from Scope on purpose — Scope // says where rows that name NO tracks go, this says whether a row may name // its own at all, and the two are useful in every combination. // ⚠ its own class, NOT a second .gt-tp-scope-sel — Playwright's page.* helpers // are strict, so a duplicate class makes verify-539's selectOption throw. const detSel = el('select', 'gt-tp-det-sel'); [['auto', 'Tracks: auto'], ['off', 'Tracks: off']].forEach(([v, t]) => { const o = el('option', '', t); o.value = v; detSel.appendChild(o); }); detSel.value = scope.tracks; detSel.title = 'auto: a credit that names its own tracks — "Lead vocals (tracks 2,6,9)", "Djembe (track 4)" — goes on those recordings, ' + 'whatever Scope says; everything else follows Scope. A clause only counts when every number in it is a real track on this release, ' + 'so "(2003)" is left alone.\noff: the pre-#597 behaviour — Scope decides for every row.'; scopeWrap.append(el('span', 'gt-tp-scope-lbl', 'Scope'), scopeSel, tracksIn, tracksInfo, detSel); // What did that selector actually match? Shown before anything is applied, // because "1-3" meaning something other than you thought is a silent way to // credit the wrong recordings. const refreshScopeUi = () => { const rec = scope.kind === 'recording'; tracksIn.style.display = rec ? '' : 'none'; tracksInfo.style.display = rec ? '' : 'none'; if (!rec) return; const targets = txpScopeTargets(); const ticked = !scope.spec.trim(); // #539 follow-up: listing all 23 numbers made the toolbar wide enough to // push ⚡ Match onto a second row. Show a handful, keep the rest in the // tooltip — the count is the part that matters at a glance. const nums = targets.map(t => t.num); const shown = nums.length > 6 ? `${nums.slice(0, 6).join(', ')} +${nums.length - 6}` : nums.join(', '); tracksInfo.textContent = targets.length ? `→ ${targets.length} track${targets.length > 1 ? 's' : ''}${ticked ? ' (ticked)' : ''}: ${shown}` : (ticked ? '→ no tracks ticked' : '→ nothing matched'); tracksInfo.style.color = targets.length ? 'var(--mbu-ok)' : 'var(--mbu-warn)'; tracksInfo.title = targets.map(t => `${t.num} ${t.title}`).join('\n'); }; // The recordings this run will credit: an explicit selector wins, otherwise // the editor's own ticked tracks (the same idiom as GT's other tools). function txpScopeTargets() { const rows = txpTrackRows(); if (scope.spec.trim()) return txpMatchTracks(scope.spec, rows); return rows.filter(r => { const cb = r.tr.querySelector('input.recording'); return cb && cb.checked; }); } scopeSel.onchange = () => { scope.kind = scopeSel.value; roleCache.clear(); refreshScopeUi(); render(); saveState(); }; tracksIn.oninput = () => { scope.spec = tracksIn.value; refreshScopeUi(); saveState(); }; detSel.onchange = () => { scope.tracks = detSel.value; render(); saveState(); }; // #597 // #539 follow-up (majkinetor): "We again have new row, this time for out of // the box patterns … Move scope to the bottom, opposite of Apply." So the // top bar is back to what it was — pattern · presets · ⚡ Match — and the // scope control lives in the footer instead, at the opposite end from // Apply. It belongs there anyway: it is part of deciding what Apply does, // not part of writing the pattern. // #544 (majkinetor): "Freeze matched should be next to the pattern input in // the header (like Apollo)". Icon-only, per the standing toolbar rule — the // top bar has twice been one control too wide (#539), and the tooltip // carries the meaning. const freezeBtn = el('button', 'gt-tp-freeze', '🔒'); freezeBtn.type = 'button'; freezeBtn.title = 'Freeze matched — lock this pattern onto every line that still uses the default pattern and already matches it, then try another pattern on what is left'; ctrl.append(patIn, patClr, freezeBtn, presets, resolveBtn); const src = el('div', 'gt-tp-src'); const srcTgl = el('button', 'gt-tp-srctgl', '▾ Paste credit text'); srcTgl.type = 'button'; const ta = el('textarea', 'gt-tp-ta'); // #522 follow-up (majkinetor, live): "move copyright help text into the // textbox hint when its empty" — folded into the placeholder instead of // a permanent line taking up space even once you're pasting real text. ta.placeholder = 'Paste credit text here, one credit per line…\n\nA ©/(C)/copyright or ℗/(P)/phonographic copyright line ("© 2020 Some Label", "℗ & © 2020 Some Label") is recognized automatically alongside your pattern. Each entity resolves against artists or labels depending on its role — a label-only role like "distributed by" is searched as a label; an ambiguous one auto-detects against this release\'s own credited artists, with a toggle to override.'; src.append(srcTgl, ta); const body = el('div', 'gt-cons-body gt-tp-body'); const tbl = el('table', 'gt-cons-tbl gt-tp-tbl'); // #522 follow-up: "Add resizable columns" — table-layout:fixed + a // <colgroup> so each column's width is just one <col>'s inline style, // adjustable by dragging a handle on its header without touching every // cell in the column. // #522 follow-up (majkinetor, live): "memorize as you do it constantly" // — resized column widths are remembered (a global GM value, not a // per-release one — this is a layout preference, not release data). const TXP_COLS_KEY = 'gt-tp-colwidths'; // #597 added the "tracks" column. A saved 8-entry array no longer matches // the 9 cols, so the length check below falls back to these defaults — // widths reset once on upgrade, which is the right way for that to go. const defaultColWidths = ['24px', '120px', '230px', '110px', '170px', '110px', '170px', '90px', '80px']; let savedColWidths = null; try { savedColWidths = JSON.parse(GM_getValue(TXP_COLS_KEY, '')) || null; } catch (e) {} const colgroup = el('colgroup'); (savedColWidths && savedColWidths.length === defaultColWidths.length ? savedColWidths : defaultColWidths).forEach(w => { const c = document.createElement('col'); c.style.width = w; colgroup.appendChild(c); }); const saveColWidths = () => { try { GM_setValue(TXP_COLS_KEY, JSON.stringify([...colgroup.children].map(c => c.style.width))); } catch (e) {} }; const thead = el('thead'); thead.innerHTML = '<tr><th></th><th>pattern</th><th>raw line</th><th>role</th><th>entity</th><th>→ role</th><th>→ entity</th><th>tracks</th><th></th></tr>'; [...thead.querySelectorAll('th')].forEach((th, i) => { if (i === 0) return; // the status-dot column stays fixed const handle = el('span', 'gt-tp-colresize'); th.appendChild(handle); let startX = 0, startW = 0; const onMove = e => { colgroup.children[i].style.width = Math.max(40, startW + (e.clientX - startX)) + 'px'; }; const onUp = () => { document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); saveColWidths(); }; handle.addEventListener('mousedown', e => { e.preventDefault(); startX = e.clientX; startW = colgroup.children[i].getBoundingClientRect().width; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); }); }); const tbody = el('tbody'); tbl.append(colgroup, thead, tbody); body.appendChild(tbl); const foot = el('div', 'gt-cons-foot'); const cnt = el('span', 'gt-tp-cnt'); // #550: no ↗ any more — nothing opens, the annotation edit is submitted here. const applyClearBtn = el('button', 'gt-cons-btn', 'Apply & clear annotation'); applyClearBtn.type = 'button'; applyClearBtn.title = 'Apply the resolved rows, then clear this release\'s annotation — submitted for you, with its own edit note'; applyClearBtn.style.display = 'none'; const applyBtn = el('button', 'gt-cons-btn gt-cons-apply', 'Apply'); applyBtn.type = 'button'; // #544 (majkinetor): "Add [freeze pattern] option for matching rows the same // as Apollo" — Apollo's tc-tpp-freeze. Stamps the current pattern into every // still-default line that matches it right now, so trying a different // pattern afterwards leaves those alone and only re-parses the rest. // It lives in the footer rather than the top bar because that bar has twice // been one control too wide (#539). foot.append(scopeWrap, cnt, applyClearBtn, applyBtn); // #539: scope at the far left, Apply at the far right const syncTextareaFromLines = () => { ta.value = lines.map(l => l.raw).join('\n'); }; // #522 follow-up (majkinetor, live): "Lets have an option to remove a // row. It is also removed from the input text." Removing a LINE shifts // every later line's index down by one — appliedKeys AND entityOverride // are keyed by position (li:pi:si), so both are renumbered here rather // than left stale (an appliedKeys entry could mislabel an unrelated row // as already-applied; a leftover override could silently reassign a // pick that was meant for a different, now-shifted row). const renumberKey = (k, delLi) => { const [li, pi, si] = k.split(':').map(Number); return (li > delLi ? li - 1 : li) + ':' + pi + ':' + si; }; function deleteLine(delLi) { lines.splice(delLi, 1); const renumbered = new Set(); appliedKeys.forEach(k => { const li = +k.split(':')[0]; if (li !== delLi) renumbered.add(renumberKey(k, delLi)); }); appliedKeys.clear(); renumbered.forEach(k => appliedKeys.add(k)); const ovRenumbered = new Map(); entityOverride.forEach((v, k) => { const li = +k.split(':')[0]; if (li !== delLi) ovRenumbered.set(renumberKey(k, delLi), v); }); entityOverride.clear(); ovRenumbered.forEach((v, k) => entityOverride.set(k, v)); syncTextareaFromLines(); render(); saveState(); } function render() { // #522 follow-up (majkinetor, live): "after editing pattern in a row, // focus is lost immediately" — render() rebuilds the whole tbody, which // destroys and recreates whichever input was focused on every // keystroke. Both the pattern-override AND the (new) inline raw-text // edit inputs carry a shared data-fkey; remember it (+ caret position) // and restore focus to whichever element gets the same fkey again // after rebuilding. const active = document.activeElement; const activeFkey = (active && active.dataset && active.dataset.fkey) || null; const activeSel = activeFkey && typeof active.selectionStart === 'number' ? [active.selectionStart, active.selectionEnd] : null; const rows = parsedRows().map(attachResolution); const spanByLine = new Map(); rows.forEach(r => spanByLine.set(r.li, (spanByLine.get(r.li) || 0) + 1)); tbody.textContent = ''; rows.forEach(r => { const stCls = dotClass(r).replace('gt-tp-dot-', 'gt-tp-st-'); // amber/red row tint, green stays plain const tr = el('tr', 'gt-tp-row' + (r.matched ? '' : ' gt-tp-nomatch') + (stCls === 'gt-tp-st-green' ? '' : ' ' + stCls)); const dotTd = el('td'); dotTd.appendChild(el('span', 'gt-tp-dot ' + dotClass(r))); tr.appendChild(dotTd); if (r.pi === 0 && r.si === 0) { const span = spanByLine.get(r.li); const ovTd = el('td'); if (span > 1) ovTd.rowSpan = span; const ov = el('input', 'gt-tp-ov'); ov.type = 'text'; ov.placeholder = pattern; ov.value = lines[r.li].override || ''; ov.title = 'Override the pattern for just this line'; ov.dataset.fkey = 'ov:' + r.li; ov.addEventListener('input', () => { lines[r.li].override = ov.value; render(); saveState(); }); ovTd.appendChild(ov); tr.appendChild(ovTd); // #522 follow-up: "add option to edit raw column in the table, and // it should also change original row in the text" — an editable // input synced back into the textarea, plus a remove button. const rawTd = el('td'); if (span > 1) rawTd.rowSpan = span; const rawWrap = el('span', 'gt-tp-rawwrap'); const rawIn = el('input', 'gt-tp-raw'); rawIn.type = 'text'; rawIn.value = r.raw; rawIn.title = r.raw; rawIn.dataset.fkey = 'raw:' + r.li; rawIn.addEventListener('input', () => { lines[r.li].raw = rawIn.value; [...entityOverride.keys()].filter(k => +k.split(':')[0] === r.li).forEach(k => entityOverride.delete(k)); syncTextareaFromLines(); render(); saveState(); }); const delBtn = el('button', 'gt-tp-rowdel', '✕'); delBtn.type = 'button'; delBtn.title = 'Remove this line'; delBtn.onclick = () => deleteLine(r.li); rawWrap.append(rawIn, delBtn); rawTd.appendChild(rawWrap); tr.appendChild(rawTd); } tr.appendChild(el('td', 'gt-tp-c', r.role || '')); // #525 (majkinetor): "since column Entity is used as 'credited as', // lets add right click to it, which will set it to choosen entity // (if there is one)... used to fast clear any suffixes that came // from raw text" — a raw credit like "Felix Vincent*" (Discogs-style // footnote marker) resolves fine to the real artist, but the raw // text is still what gets sent as the credited-as override on Apply // (applyResolvedRows: r.entity !== r.entityMatch.name → credit). // Right-click swaps the raw entity substring for the resolved // entity's own canonical name, clearing that stray override. const entRawTd = el('td', 'gt-tp-c', r.entity || ''); if (r.matched && r.entity) { entRawTd.title = r.entityMatch ? `Right-click: replace with the resolved name "${r.entityMatch.name}" (clears suffixes/typos in the raw text)` : 'Resolve the entity first, then right-click here to clean up the raw text'; entRawTd.addEventListener('contextmenu', e => { e.preventDefault(); if (!r.entityMatch) { toast('Resolve this row’s entity first'); return; } const canonical = r.entityMatch.name || ''; if (!canonical || canonical === r.entity) { toast('Already matches the resolved name'); return; } const rawBefore = lines[r.li].raw; if (!rawBefore.includes(r.entity)) { toast('Could not find that exact text in the raw line'); return; } lines[r.li].raw = rawBefore.replace(r.entity, canonical); // seed both caches with the cleaned-up text so the row (and any // other row sharing it) reads as already-resolved on the very // next render, instead of regressing to "search" just because // the raw text — and therefore the parsed entity text the // caches are keyed by — changed underneath it. entityOverride.set(r.key, r.entityMatch); entityCache.set(canonical.toLowerCase().trim(), r.entityMatch); syncTextareaFromLines(); render(); saveState(); toast(`Set to “${canonical}”`); }); } tr.appendChild(entRawTd); // #522 follow-up (majkinetor, live): "Tidy up artist / role column — // remove circles, short helper text..." then, after trying a plain // non-clickable resolved cell: "After a role is selected, I am not // able to change it as its not a link any more... Clicking an // element should always bring back search and for artist right // click should open it." Unresolved still shows the plain word // "search"; resolved is clickable again to reopen the picker — for // the entity, a real <a> so right-click still offers "open in new // tab" natively too, but a plain left-click is intercepted to reopen // the picker instead of navigating, and OUR contextmenu handler // opens it directly (skipping the browser's own context menu). const roleTd = el('td', 'gt-tp-c'); if (r.matched && !r.crKind) { if (r.roleMatch) { const rs = el('button', 'gt-tp-search gt-tp-resolved', r.roleMatch.name); rs.type = 'button'; rs.title = 'Click to change'; rs.onclick = () => txpPickRole(r); roleTd.appendChild(rs); } else { const rb = el('button', 'gt-tp-search', 'search'); rb.type = 'button'; rb.onclick = () => txpPickRole(r); roleTd.appendChild(rb); } } else if (r.matched) { roleTd.appendChild(el('span', r.roleMatch ? 'gt-tp-resolved' : 'gt-tp-status', r.roleMatch ? r.roleMatch.name : '(resolves once holder is picked)')); } tr.appendChild(roleTd); const entTd = el('td', 'gt-tp-c'); if (r.matched) { if (r.entityMatch) { const a = el('a', 'gt-tp-resolved', r.entityMatch.name || ''); // #544: the disambiguation is set apart from the name here too, so // the resolved cell and the search results read the same way. if (r.entityMatch.disambiguation) a.appendChild(el('span', 'gt-tp-disamb', ` (${r.entityMatch.disambiguation})`)); a.href = '/' + r.entityMatch.entityType + '/' + r.entityMatch.gid; a.target = '_blank'; a.rel = 'noopener'; a.title = 'Click to change · right-click to open'; a.addEventListener('click', e => { e.preventDefault(); txpPickEntity(r, a); }); a.addEventListener('contextmenu', e => { e.preventDefault(); window.open(a.href, '_blank', 'noopener'); }); entTd.appendChild(a); } else if (r.creating) { // #544: a background create is in flight for this text — say so // instead of offering "search", and let the row resolve itself when // the create tab commits. // the kind lives in the tooltip, not the label: this column is narrow // and "creating artist “…”…" wrapped to two lines in every row. // #544 follow-up: it is a BUTTON — clicking cancels, so a create that // failed in a way nothing here can see never strands the row. const c = el('button', 'gt-tp-creating', `creating “${trunc(r.creating.name, 16)}”… ✕`); c.type = 'button'; c.title = `A background tab is creating this ${r.creating.kind}; the row fills in on its own once it commits.\nClick to stop waiting and get the search back.`; c.onclick = () => { const p = r.creating; if (p && p.cancel) p.cancel('cancelled'); else { pendingCreates.delete((r.entity || '').toLowerCase().trim()); render(); } }; entTd.appendChild(c); } else { const ab = el('button', 'gt-tp-search', 'search'); ab.type = 'button'; ab.onclick = () => txpPickEntity(r, ab); entTd.appendChild(ab); } } tr.appendChild(entTd); /* #597: where this credit is actually going. Not decoration — the role matcher tolerates a "(tracks 2,6,9)" suffix and resolves it exactly as the clean text would, so before this column a run that ignored the clause and credited the whole release looked completely normal. */ const trkTd = el('td', 'gt-tp-trk'); if (r.trackNote) { // the line carried more than one track list — see txpDetectTracks const w = el('span', 'gt-tp-trk-warn', '⚠ split line'); w.title = `${r.trackNote}\n\nOne line can only carry one track list, so this credit is going wherever Scope points instead. Split it into one role per line.`; trkTd.appendChild(w); } else if (r.trackTargets && r.trackTargets.length) { const nums = r.trackTargets.map(t => t.num); const s = el('span', 'gt-tp-trk-on', nums.length > 4 ? `${nums.slice(0, 4).join(', ')} +${nums.length - 4}` : nums.join(', ')); s.title = `From “${r.trackSpec}” in the credit text:\n` + r.trackTargets.map(t => `${t.num} ${t.title}`).join('\n'); trkTd.appendChild(s); } else if (scope.kind === 'recording') { trkTd.appendChild(el('span', 'gt-tp-trk-dim', 'scope')); } tr.appendChild(trkTd); const stTd = el('td', 'gt-tp-status' + (appliedKeys.has(r.key) ? ' gt-tp-applied' : ''), statusText(r)); tr.appendChild(stTd); tbody.appendChild(tr); }); // #522 follow-up (majkinetor, live): "There is a tight column on the // left with text when there is no text input" — the empty-state <td> // had no colspan, so table-layout:fixed confined it to the FIRST // (narrowest) column, wrapping the message into a tall sliver. if (!rows.length) { const td = el('td', 'gt-pop-note', 'Paste credit text above, or load the annotation.'); td.colSpan = 9; tbody.appendChild(el('tr')).appendChild(td); } const matched = rows.filter(r => r.matched).length; const ready = rows.filter(r => r.matched && r.roleMatch && r.entityMatch && !appliedKeys.has(r.key)).length; const applied = rows.filter(r => appliedKeys.has(r.key)).length; cnt.textContent = rows.length ? `${matched}/${rows.length} matched` + (ready ? ` · ${ready} ready` : '') + (applied ? ` · ${applied} applied` : '') : ''; applyBtn.disabled = !ready; applyClearBtn.disabled = !ready; applyClearBtn.style.display = loadedFromAnnotation ? '' : 'none'; if (activeFkey) { const restored = tbody.querySelector(`[data-fkey="${activeFkey}"]`); if (restored) { restored.focus(); if (activeSel) { try { restored.setSelectionRange(activeSel[0], activeSel[1]); } catch (e) {} } } } } async function resolveAll() { resolveBtn.disabled = true; resolveBtn.textContent = 'Resolving…'; try { const rows = parsedRows().filter(r => r.matched); const crRows = rows.filter(r => r.crKind); const creditRows = rows.filter(r => !r.crKind); // #525 (majkinetor): "Published by is also label... map roles to // entities" — role classification is entirely synchronous (no // network) and must happen BEFORE the unified entity-resolution // list below, since an ordinary row's entity type (rowEntityType) // depends on its role's classification. // #539: the role vocabulary is per entity PAIR — "recorded at" exists // for both, but plenty of release roles have no recording equivalent // and vice versa. Follow the chosen scope, or a recording run would be // matched against release-only link types. const scopeTarget = scope.kind === 'recording' ? 'recording' : 'release'; const artistCands = linkTypesForPair('artist', scopeTarget); const labelCands = linkTypesForPair('label', scopeTarget); const placeCands = linkTypesForPair('place', scopeTarget); // #525: "recorded at", "mixed at", "mastered at", … const instrumentLt = artistCands.find(c => c.name === 'instrument'); const instrumentCands = instrumentLt ? txpInstrumentCandidates() : []; const roleTexts = [...new Set(creditRows.map(r => (r.role || '').toLowerCase().trim()).filter(Boolean))]; roleTexts.forEach(rt => { if (!roleCache.has(rt)) roleCache.set(rt, classifyRoleText(rt, artistCands, labelCands, instrumentLt, instrumentCands, placeCands)); }); // role matches are synchronous — show them immediately, before the // network-bound entity loop below even starts. render(); saveState(); // #524/#525: ONE unified (text, entityType) resolution list — // copyright/legal-notice holders and ordinary credit entities share // the exact same artist-or-label lookup, entityType decided per // text (forced where MB only allows one, otherwise auto-detected // against the release's own credited artists). Dedup by entity text // (the common case — the same text rarely needs both an artist AND // a label resolution in the same block; see txpCrEntityType/ // rowEntityType's own docs for that edge). const entityTexts = new Map(); // text -> entityType ('artist'|'label') crRows.forEach(r => { if (r.entity) entityTexts.set(r.entity, txpCrEntityType(r.crKind, r.entity)); }); creditRows.forEach(r => { if (r.entity) entityTexts.set(r.entity, rowEntityType(r)); }); const entities = [...entityTexts.entries()]; // #522 follow-up (majkinetor): "while it is resolving, lets show a // message ... resolving 4/N" then "make it show in the button // itself" — total counts only the names that actually need a real // lookup (already-cached ones are instant skips, not "work" the // button should promise is coming). resolveTotal = entities.filter(([t]) => !entityCache.has(t.toLowerCase().trim())).length; resolveDone = 0; const bumpProgress = () => { resolveDone++; resolveBtn.textContent = `Resolving ${resolveDone}/${resolveTotal}`; }; resolveBtn.textContent = `Resolving ${resolveDone}/${resolveTotal}`; for (const [text, entityType] of entities) { const key = text.toLowerCase().trim(); if (entityCache.has(key)) continue; try { let hit = entityType === 'artist' ? await txpResolveByExactAlias(text) : entityType === 'place' ? null // no alias resolver for places; exact-name search below covers it : await txpResolveLabelByExactAlias(text); if (!hit) { const cands = entityType === 'artist' ? await txpSearchArtist(text, 5) : entityType === 'place' ? await txpSearchPlace(text, 5) : await txpSearchLabel(text, 5); const exact = cands.filter(c => (c.name || '').toLowerCase().trim() === key); if (exact.length === 1) { const full = await txpFetchEntity(exact[0].gid || exact[0].id, entityType); if (full) hit = { entity: full }; } } entityCache.set(key, hit ? hit.entity : null); } catch (e) { entityCache.set(key, null); } // #524 follow-up (majkinetor): "'Resolve all' should update the // table as it goes... to avoid the looks of 'nothing happens'" — // each entity is a real network round-trip, so re-render after // every one instead of batching the whole thing behind one // render() at the very end. bumpProgress(); render(); saveState(); } } finally { resolveBtn.disabled = false; resolveBtn.textContent = '⚡ Match'; render(); saveState(); } } function txpPickRole(r) { const scopeTarget = scope.kind === 'recording' ? 'recording' : 'release'; // #539 const artistCands = linkTypesForPair('artist', scopeTarget); const labelCands = linkTypesForPair('label', scopeTarget); const instrumentLt = artistCands.find(c => c.name === 'instrument'); // offer specific instruments alongside the direct link types (tagged // so onPick can tell them apart and build the right {id, attributeId} // shape — see classifyRoleText's own auto-resolution for the same // split). #525: label-pair roles are offered too (tagged "(label)" // in their description) — a manual pick from either list is the // ultimate override for a role classifyRoleText couldn't force. const instrumentOpts = instrumentLt ? txpInstrumentCandidates().map(c => ({ id: c.id, name: c.name, desc: 'instrument', _instrument: true, _ltId: instrumentLt.id, _entityType: 'artist' })) : []; // #666: vocal types the same way — the "vocal" link type plus that attribute const vocalLt = artistCands.find(c => c.name === 'vocal'); const vocalOpts = vocalLt ? txpVocalCandidates().map(c => ({ id: c.id, name: c.name, desc: 'vocal', _instrument: true, _ltId: vocalLt.id, _entityType: 'artist' })) : []; const roles = [ ...artistCands.map(c => ({ ...c, _entityType: 'artist' })), ...labelCands.map(c => ({ ...c, _entityType: 'label', desc: (c.desc ? c.desc + ' ' : '') + '(label)' })), // #525: place roles too — "recorded at", "mixed at", "mastered at" … ...linkTypesForPair('place', scopeTarget).map(c => ({ ...c, _entityType: 'place', desc: (c.desc ? c.desc + ' ' : '') + '(place)' })), ...vocalOpts, ...instrumentOpts, ]; // #522 follow-up (majkinetor, live): "when clicking search on a role, // it is not already filled in in search box in popup like Artist" — // pre-fill/pre-filter with the parsed role text, same as the entity // popover already does with r.entity. openRolePicker(roles, `Pick a role for “${trunc(r.entity || r.raw, 40)}”`, picked => { const resolved = picked._instrument ? { id: picked._ltId, name: picked.name, attributeId: picked.id } : { id: picked.id, name: picked.name }; roleCache.set((r.role || '').toLowerCase().trim(), { artistMatch: picked._entityType === 'artist' && !picked._instrument ? resolved : null, labelMatch: picked._entityType === 'label' ? resolved : null, placeMatch: picked._entityType === 'place' ? resolved : null, instrumentMatch: picked._instrument ? resolved : null, forced: picked._entityType, }); render(); saveState(); }, r.role || ''); } function txpPickEntity(r, anchor) { closePopover(); // #524/#525 follow-up (majkinetor): "regarding artist vs label, maybe // we can have 2 tabs in search" — auto-detect (r.entityType, already // computed by attachResolution for BOTH copyright holders and // ordinary credit rows) picks the starting tab; a row whose entity // type is FORCED (r.entityForced — e.g. crKind distributed/marketed/ // licensee, or an ordinary role that only exists on one side like // "published") can't toggle at all, since the other search would just // produce an unresolvable pick. const canToggle = !r.entityForced; let searchKind = r.entityType || 'artist'; popEl = el('div', 'gt-pop gt-tp-apop'); const hdr = el('div', 'gt-pop-hdr'); popEl.appendChild(hdr); if (canToggle) { const tabs = el('div', 'gt-tp-tabs'); const TAB_LABEL = { artist: 'Artist', label: 'Label', place: 'Place' }; // #525: Place added const mkTab = kind => { const b = el('button', 'gt-tp-tab', TAB_LABEL[kind]); b.type = 'button'; b.dataset.kind = kind; b.onclick = () => { searchKind = kind; renderChrome(); runSearch(); }; return b; }; tabs.append(mkTab('label'), mkTab('artist'), mkTab('place')); popEl.appendChild(tabs); } // #522 follow-up (majkinetor, live): "Remove 'Create artist' from the // bottom of artist search, add it as a + in the search, as we do in // all other scripts" (mock: a "[+]" button inside the search box // itself) — replaces the separate link row below the results. const qWrap = el('div', 'gt-tp-qwrap'); const q = el('input', 'gt-tp-q'); q.type = 'text'; q.value = r.entity || ''; const createBtn = el('button', 'gt-tp-plus', '+'); createBtn.type = 'button'; // #525 (majkinetor): "We need to wait tab close after creating an artist // to pick up the mbid (as in CH). Also, as in CH, preset type to person // and set default sort (if artist is created, not if label is created)." // // Credit Hoarder gets the MBID back over a BroadcastChannel, but it also // RUNS on /artist/create and the created entity page. Group Therapy only // matches the relationship editor, so there is nobody on the other side to // post back. Keeping the window handle achieves the same thing: the create // tab is same-origin, so we can watch it land on /<kind>/<mbid> and adopt // that entity — and fall back to a name search if the tab is just closed. // #544 (majkinetor): "Implement right click on (+) to create entity in the // background" — same creation, without the tab stealing focus, so a run of // several unresolved names can be fired off and picked up as they land. const startCreate = (background) => { // #544: "it should not ignore the search string used but seed that one" // — if the search box was edited to drop a "(Greenprint)" suffix, THAT // is the name to create, not the raw parsed text. const name = (q.value || '').trim() || r.entity || ''; const kind = searchKind; const params = new URLSearchParams(); params.set(`edit-${kind}.name`, name); if (kind === 'artist') { params.set('edit-artist.sort_name', txpGuessSortName(name)); params.set('edit-artist.type_id', '1'); // Person — sort name above assumes it } // #544: "Adding entity doesn't have any edit notes. It should do the // same as CH/Apollo" — the created entity carried no attribution at // all. Same shape as Apollo's entityActionNote: signature, then what // was being done and where. params.set(`edit-${kind}.edit_note`, txpCreateNote(kind)); // ── background: GM_openInTab, and the created page posts the MBID back if (background && typeof GM_openInTab === 'function') { const token = Math.random().toString(36).slice(2); // #598 (majkinetor): "When 2 background tabs are open creating artist, // one of them doesn't close and consequently return MBID to Text // Pattern." This used to store THE pending create — a single slot — so // starting a second one overwrote the first. The first tab's /create // page then read back a token that was not its own, decided the form // in front of it belonged to somebody else, and stood down without // ever pressing Enter: a tab left sitting on a filled-in form, and a // row spinning on "creating…" until the ten-minute timeout. // The store is a LIST now, so concurrent creates cannot evict one // another; see gtPendingAdd and the two handlers at the bottom. gtPendingAdd({ kind, token, ts: Date.now() }); // #544 follow-up (majkinetor): "When creating in the background, it // doesn't click Enter in 2nd tab." The seeded /create page is only a // filled-in FORM — in the foreground you see it and press Enter, but a // background tab just sits there unsubmitted, so nothing is ever // created and the parser waits for a post-back that cannot come. // autoSubmitSeededCreate (below) presses it. This param is how that // handler knows the form in front of it is the one THIS create opened // — matching only on "a pending create of this kind exists" would also // fire on a /artist/create the user opened by hand minutes later. // MusicBrainz ignores parameters it does not know. // // ⚠ NOT named `gt_token`. `>` is a LEGACY HTML entity that decodes // without its semicolon, so `>_token=…` came back out of any HTML // attribute as `>_token=…` — measured: the submitted form's action // carried `%3E_token`, and the handler's own check would then never // match. Any name not starting with a legacy entity is fine. params.set('x_gtcreate', token); let ch = null, timeout = null, tab = null; // #544: the row, not the popover, now carries the "waiting" state — so // the popover closes at once and the next name can be started while this // one is still in its tab. Keyed by the ROW's text (what entityCache is // keyed by), not the possibly-edited search box, so the rows showing that // text are the ones that light up and the ones that later resolve. const pendKey = (r.entity || '').toLowerCase().trim(); // Nothing below may touch the popover: it is gone, and `popEl` may by now // belong to a DIFFERENT picker the user opened in the meantime. let watch = null; const stop = () => { try { ch && ch.close(); } catch (e) {} clearTimeout(timeout); clearInterval(watch); pendingCreates.delete(pendKey); }; // #544 follow-up (majkinetor): "The new `creating ...` placeholder stays // forever if there is error/tab closed. I should be able to cancel it // like in CH." Two ways out, because neither alone is enough: // • the row's placeholder is clickable — the reliable one, since a // create can fail in ways nothing here can observe (a duplicate-check // page, a validation error, the tab left open and forgotten); // • the tab handle is watched, so simply CLOSING the tab clears it. // GM_openInTab's handle is not guaranteed to report `closed` in every // manager, which is exactly why it is the backstop and not the fix. const cancel = (why) => { if (!pendingCreates.has(pendKey)) return; stop(); gtPendingDrop(token); // #598: drop only THIS create — another may still be in flight render(); saveState(); toast(`Stopped waiting for “${trunc(name, 30)}” — ${why}`); }; pendingCreates.set(pendKey, { kind, name, cancel }); closePopover(); render(); try { ch = new BroadcastChannel(GT_CREATE_CH); ch.onmessage = async (ev) => { const d = ev && ev.data; if (!d || d.token !== token || d.kind !== kind || !d.gid) return; stop(); // #544 follow-up (majkinetor): "It doesn't close the tab after // commit, like CH/Apollo." The created entity's page does call // window.close() on itself, but that is a no-op for a tab opened // by GM_openInTab unless the script grants window.close — so the // OPENER closes it, using the handle GM_openInTab hands back. // Exactly what Credit Hoarder does and for the same reason (#273: // "a GM-opened tab can't always self-close via window.close()"). // Closed here, before the entity fetch: the entity exists either // way, so a slow or failed lookup must not leave the tab open. try { if (tab && typeof tab.close === 'function') tab.close(); } catch (e) {} const ent = await txpFetchEntity(d.gid, kind); // #544: resolve in BULK, exactly as a left click in the picker does — // every row with this text flips from "creating…" to the new entity. if (ent) { entityCache.set(pendKey, ent); render(); saveState(); toast(`Created ${kind} “${ent.name || name}”`); } else { render(); toast(`Created the ${kind}, but could not read it back — click the row to search for it`); } }; } catch (e) { /* no BroadcastChannel — falls through to the timeout */ } // Give up quietly rather than spinning forever if the tab is closed // without saving; the row falls back to "search". #544 timeout = setTimeout(() => cancel('timed out after 10 minutes'), 10 * 60 * 1000); tab = GM_openInTab(`/${kind}/create?` + params.toString(), { active: false, insert: true, setParent: true }); // the tab closing without a post-back means the create did not happen — // clear the placeholder instead of leaving it spinning. A short grace // period first, because the SUCCESS path closes the tab too (from the // message handler) and must not be reported as an abandoned create. try { if (tab && 'onclose' in tab) tab.onclose = () => setTimeout(() => cancel('the create tab was closed'), 1500); } catch (e) {} watch = setInterval(() => { let closed = false; try { closed = !!(tab && tab.closed); } catch (e) { closed = false; } if (closed) { clearInterval(watch); watch = null; setTimeout(() => cancel('the create tab was closed'), 1500); } }, 1500); return; } const url = `/${kind}/create?` + params.toString(); // ── foreground: keep the window handle and watch where it lands. // Here the popover STAYS open (you are looking at the tab you just // opened), so the spinner belongs on the + button. The background path // above closed the popover and signals on the row instead. #544 createBtn.classList.add('gt-tp-plus-wait'); createBtn.title = `Waiting for the new ${kind}…`; const win = window.open(url, '_blank'); let done = false; const finish = async (gid) => { if (done) return; done = true; clearInterval(timer); createBtn.classList.remove('gt-tp-plus-wait'); renderChrome(); if (gid) { const ent = await txpFetchEntity(gid, kind); if (ent) { try { win.close(); } catch (e) {} pick(ent, false); return; } } q.value = name; runSearch(); // fell through — let them pick it }; const timer = setInterval(() => { let path = null; try { path = win.closed ? null : win.location.pathname; } catch (e) { path = null; } // mid-navigation if (path) { const m = path.match(new RegExp('^/' + kind + '/(' + GID_RE.source + ')$')); if (m) return finish(m[1]); return; } if (win.closed) finish(null); }, 600); }; createBtn.onclick = () => startCreate(false); createBtn.addEventListener('contextmenu', e => { e.preventDefault(); startCreate(true); }); // #544 qWrap.append(q, createBtn); popEl.appendChild(qWrap); const list = el('div', 'gt-tp-results'); popEl.appendChild(list); // #522 follow-up (majkinetor, live): "if one artist has multiple // instruments, selecting one selects all... right clicking a choice // in search sets all, and normal clicking only that 1." A left click // resolves ONLY this row (entityOverride, position-keyed); a right // click resolves every row sharing this exact entity text (the // shared entityCache, same as auto-resolve uses) — same propagation // as before, now opt-in instead of automatic. // #544 (majkinetor): "Let left click change all entities upon selection // (currently it is on right click)." Swapped: the common case — one // person credited on several lines — is now the plain click, and the // narrow "just this row" case moved to right-click. popEl.appendChild(el('div', 'gt-tp-hint', 'Click: every row with this same text · Right-click: this row only')); // header/placeholder/create-title/tab-highlight all depend on // searchKind, which the tabs above can change after the popover is // already open — kept in one place so toggling stays in sync. const renderChrome = () => { const ARTICLE = { artist: 'an artist', label: 'a label', place: 'a place' }; const PLURAL = { artist: 'artists', label: 'labels', place: 'places' }; hdr.textContent = `Pick ${ARTICLE[searchKind]} for “${trunc(r.role || r.raw, 40)}”`; q.placeholder = `search ${PLURAL[searchKind]}, or paste an MBID / URL…`; createBtn.title = `Create ${searchKind} “${trunc(r.entity || '', 40)}” ↗ · right-click to create in the background`; if (canToggle) [...popEl.querySelectorAll('.gt-tp-tab')].forEach(b => b.classList.toggle('gt-tp-tab-on', b.dataset.kind === searchKind)); }; renderChrome(); const pick = (entity, bulk) => { if (bulk) entityCache.set((r.entity || '').toLowerCase().trim(), entity); else entityOverride.set(r.key, entity); closePopover(); render(); saveState(); }; // #544 (majkinetor): "Disambiguation in search results should have // different font style than entity name so it isn't confused with the // name" — "Fiona (reggae artist)" read as one name. The comment is its // own span now: smaller, grey, italic. const resRow = (name, disamb, typ) => { const row = el('div', 'gt-tp-res'); row.appendChild(el('span', 'gt-tp-restype', typ)); row.appendChild(el('span', 'gt-tp-resname', name)); if (disamb) row.appendChild(el('span', 'gt-tp-disamb', ` (${disamb})`)); return row; }; const runSearch = async () => { const term = (q.value || '').trim(); list.textContent = ''; if (!term) return; const gid = (term.match(GID_RE) || [])[0]; if (gid) { // #544 (majkinetor): "pasting MBID should work imediately rather than // showing search result". An MBID is already an exact answer — there // is nothing to choose between — so resolve and take it. Applies to // every row with this text, matching what a left click now does. const ent = await txpFetchEntity(gid, searchKind); if (ent) { pick(ent, true); return; } list.appendChild(el('div', 'gt-pop-note', 'Nothing found with that MBID.')); return; } const searchFor = { artist: txpSearchArtist, label: txpSearchLabel, place: txpSearchPlace }[searchKind] || txpSearchArtist; const searches = [searchFor(term, 8).then(l => l.map(c => ({ ...c, _kind: searchKind })))]; const cands = (await Promise.all(searches)).flat(); if (!cands.length) { list.appendChild(el('div', 'gt-pop-note', 'No matches.')); return; } cands.forEach(c => { const row = resRow(c.name || '', c.comment || '', c._kind); row.addEventListener('click', async () => { const full = await txpFetchEntity(c.gid || c.id, c._kind); if (full) pick(full, true); }); // #544: all rows row.addEventListener('contextmenu', async e => { e.preventDefault(); const full = await txpFetchEntity(c.gid || c.id, c._kind); if (full) pick(full, false); }); // #544: this row only list.appendChild(row); }); }; document.body.appendChild(popEl); // #522 sixth round (majkinetor, live, screenshot): "Artist popup is // displaced" — it always anchored to the TABLE's own top-left corner // plus a fixed 40px offset, regardless of which row/column was // actually clicked. Anchor to the clicked element itself instead, // same convention every other popover in this file already uses. const anchorEl = anchor || txpEl.querySelector('.gt-tp-tbl'); // #522 follow-up (majkinetor, live, screenshot): "search popup can be // offscreen" — the FIRST clamp ran before any results existed, so it // sized against an almost-empty popover; once results/notes filled // it back in the popover grew well past that clamp. Re-run it after // every search (initial load AND subsequent typing), against the // popover's actual current size. // // #544 (majkinetor): "Entity search popup could be position better. It // bothers me it is always on the edge and touches scrollbar." Three // reasons it ended up jammed against the scrollbar, all fixed here: // • the clamp used window.innerWidth, which INCLUDES the vertical // scrollbar — so "inside the window" still meant underneath it, and // the right-hand tab and + button were clipped. clientWidth is the // scrollbar-free box. // • the 8px margin left it visually glued to the edge even when it fit. // • when the anchor sits far right (maximized parser), aligning the // popover's LEFT edge to it always overflows; align its RIGHT edge to // the anchor instead, which keeps it next to what was clicked. // Vertically it now flips ABOVE the anchor when there isn't room below, // rather than sliding up to cover the row that opened it. const MARGIN = 16; const reposition = () => { // a debounced search can still resolve after the popover itself was // already closed (closePopover nulls the shared popEl) — nothing to // reposition at that point. if (!popEl) return; const rr = popEl.getBoundingClientRect(); // re-measured every time: the anchor moves when the parser window is // maximized or scrolled while the popover is open const ar = anchorEl.getBoundingClientRect(); const vw = document.documentElement.clientWidth, vh = document.documentElement.clientHeight; const maxLeft = vw - rr.width - MARGIN; let left = ar.left; if (left > maxLeft) left = Math.min(maxLeft, ar.right - rr.width); // right-align to the anchor popEl.style.left = Math.max(MARGIN, Math.min(left, maxLeft)) + 'px'; const below = ar.bottom + 4, above = ar.top - rr.height - 4; const top = (below + rr.height + MARGIN <= vh || above < MARGIN) ? below : above; popEl.style.top = Math.max(MARGIN, Math.min(top, vh - rr.height - MARGIN)) + 'px'; }; const run = async () => { try { await runSearch(); } finally { reposition(); } }; reposition(); let t = null; q.addEventListener('input', () => { clearTimeout(t); t = setTimeout(run, 300); }); q.addEventListener('paste', () => setTimeout(run, 0)); setTimeout(() => { document.addEventListener('mousedown', onPopDown, true); document.addEventListener('keydown', onPopKey, true); q.focus(); run(); }, 0); } // core of Apply, factored out so "Apply & clear annotation" can run the // exact same dispatch logic before opening the annotation editor. async function applyResolvedRows() { const re = RE(); if (!re) { toast('Editor not ready'); return null; } // #539: one dispatch per (credit × target). appliedKeys is keyed per // target too, so the same parsed text can be applied to track 3 now and // track 7 later without the second run thinking it already ran. const fallback = scope.kind === 'recording' ? txpScopeTargets().map(t => t.rec) : [release]; // #597: a row that named its own tracks goes THERE; the Scope control is // only the default for rows that named none. That is what lets one run // handle a liner-note block where most credits are release-wide and a few // are "(track 4)" — no mode switching between them. const targetsFor = r => (r.trackTargets && r.trackTargets.length) ? r.trackTargets.map(t => t.rec) : fallback; const allRows = parsedRows().map(attachResolution); if (!fallback.length && !allRows.some(r => r.trackTargets && r.trackTargets.length)) { toast(scope.spec.trim() ? 'No track matched that selection' : 'No tracks ticked — type track numbers or tick some'); return null; } const keyFor = (r, target) => { const t = targetsFor(r); return (t.length === 1 && t === fallback && scope.kind !== 'recording') ? r.key : `${r.key}|${target.gid || target.id}`; }; const rows = allRows.filter(r => r.matched && r.roleMatch && r.entityMatch && targetsFor(r).length && targetsFor(r).some(t => !appliedKeys.has(keyFor(r, t)))); if (!rows.length) { toast('Nothing resolved to apply'); return null; } let ok = 0, fail = 0, dated = 0; let okOwn = 0; // #597: credits that went to tracks the ROW named const ownNums = new Set(); for (const r of rows) { const own = !!(r.trackTargets && r.trackTargets.length); if (own) r.trackTargets.forEach(t => ownNums.add(t.num)); const credit = r.entity && r.entity !== (r.entityMatch.name || '') ? r.entity : ''; const dates = txpNoticeDatePeriod(r.year); // #574: "in 2021", not "from 2021 to present" if (dates) dated++; // an instrument-role match carries an attributeId (the "instrument" // link type doesn't say WHICH instrument on its own). #666: vocal-type // and qualifier matches carry attributeIds (one or more) instead. const attrIds = r.roleMatch.attributeIds || (r.roleMatch.attributeId ? [r.roleMatch.attributeId] : []); const attrs = attrIds.length ? buildAttrTree(attrIds.map(id => ({ typeID: id, text_value: '', credited_as: '' }))) : null; for (const target of targetsFor(r)) { const k = keyFor(r, target); if (appliedKeys.has(k)) continue; try { dispatchRelationship(re, target, r.entityMatch, r.roleMatch.id, credit, attrs, dates); appliedKeys.add(k); ok++; if (own) okOwn++; } catch (e) { fail++; try { console.warn('[Group Therapy] text-parser apply failed:', e); } catch (_) {} } } } // #574: which rows carried a notice year is the one thing that is invisible // in the parser's own preview (it shows role/holder, never the date), so it // goes in the log — otherwise a wrong year is only findable in MB's diff. try { const yrs = rows.filter(r => r.year).map(r => `${r.crKind || 'credit'} ${r.entity || ''} = in ${r.year}`); console.debug(`[Group Therapy] Text parser: applied ${ok} rel(s), ${fail} failed; ${dated} carried a notice year (begin = end, ended)` + (yrs.length ? ` — ${yrs.join('; ')}` : '')); } catch (_) {} // #539 follow-up (majkinetor): "Make sure scope info is added to the edit // note." Which tracks were credited is the part a reviewer cannot infer // from the diff alone when several runs are batched into one edit, so the // note names them (capped — a 40-track selection would bury the note). // #597: one run can now do both — some credits to tracks they named // themselves, the rest wherever Scope points — so the note has to say so // rather than describing only the Scope half and quietly misreporting // where the other credits went. const cap = ns => (ns.length > 8 ? `${ns.slice(0, 8).join(', ')} … (+${ns.length - 8})` : ns.join(', ')); const nums = scope.kind === 'recording' ? txpScopeTargets().map(t => t.num) : []; const okFallback = ok - okOwn; const on = [...ownNums]; // `where` is a phrase that has to read correctly in two sentences (this // note and txpClearAnnotationNote's), so the two single-destination cases // keep their original wording exactly and only a genuinely MIXED run gets // the longer "n … and n …" form. const fallbackWhere = scope.kind === 'recording' ? ` to ${fallback.length} recording${fallback.length > 1 ? 's' : ''} (track${nums.length > 1 ? 's' : ''} ${cap(nums)})` : ' on the release'; const ownWhere = ` to track${on.length > 1 ? 's' : ''} ${cap(on)} named in the credit text`; const where = (okOwn && okFallback) ? ` — ${okFallback}${fallbackWhere} and ${okOwn}${ownWhere}` : okOwn ? ownWhere : okFallback ? fallbackWhere : ''; if (ok) markUsed(`Parsed ${ok} credit${ok > 1 ? 's' : ''} from text${where}`); const shortWhere = okOwn && !okFallback ? ` to ${ownNums.size} track${ownNums.size > 1 ? 's' : ''}` : scope.kind === 'recording' ? ` to ${fallback.length} recording${fallback.length > 1 ? 's' : ''}` : ''; toast(fail ? `Applied ${ok}, ${fail} failed — see console` : `✓ Applied ${ok} credit${ok > 1 ? 's' : ''}${shortWhere} — review & save`); saveState(); // #550: `where` is handed back so the annotation edit's note can name the // same scope as the relationship edit's, rather than recomputing it. return { ok, fail, where }; } // #522 follow-up (majkinetor, live): "Apply should close the window." async function txpApply() { const res = await applyResolvedRows(); if (res) closeTextParser(); } // #522 follow-up (majkinetor): "after loading annotation, add another // button - Apply and remove annotation" — applies exactly like Apply, // then clears the release's annotation. // // #550 (majkinetor): "Apply and clear annotation should do clearing in the // background. It is weird to double check empty field." It used to open the // annotation editor in a new tab with the text pre-emptied, which left you // reviewing a page whose only content was a field this tool had just // blanked — a confirmation step with nothing to confirm. It is submitted // here instead, with its own edit note. // // Only offered once text has actually come FROM the annotation this // session — clearing an unrelated annotation because you happened to // paste your own credit text would be a real, silent mistake. async function txpApplyAndClearAnnotation() { const res = await applyResolvedRows(); if (!res) return; // Nothing actually dispatched — clearing the annotation now would delete // the text with nothing to show for it. applyResolvedRows has already said // what went wrong. if (!res.ok) return; const label = applyClearBtn.textContent; applyClearBtn.disabled = true; applyBtn.disabled = true; applyClearBtn.textContent = 'Clearing annotation…'; try { const out = await txpClearAnnotation(release.gid, txpClearAnnotationNote(res.ok, res.where), 'Credits moved to relationships'); closeTextParser(); toast(out.skipped ? 'Applied — the annotation was already empty' : `Applied, and the annotation was cleared (${out.was} characters)`); } catch (e) { // the relationships ARE applied at this point; only the clear failed. // Leave the window open so it is obvious which half needs attention. applyClearBtn.textContent = label; applyClearBtn.disabled = false; applyBtn.disabled = false; toast(`Applied, but the annotation was not cleared: ${(e && e.message) || e}`); } } // #522 follow-up (majkinetor, live): "When you exit and return to text // parser window, if you resolved anything its gone. Keep the complete // state." — role/artist resolutions and which rows were already applied // are now persisted too, not just the pasted text and pattern. Maps/Sets // aren't JSON-native, so they're flattened to plain arrays here and // rebuilt on load (see the `saved` restore below). const saveState = () => txpSaveState(release.gid, { // #544 follow-up (majkinetor): "When exiting and returning Text parser, // any freezed patterns are gone. State should be kept completely." The // per-line pattern override IS the freeze — freezeBtn stamps `pattern` // into lines[i].override — and it was the one piece of row state the save // never carried, so reopening rebuilt every line with override:''. // Positional is correct here and nowhere else: `text` is saved in the very // same call, so index i means the same line on the way back in. (Compare // onTextChange, which must DROP position-keyed state, precisely because // there the text has changed underneath it.) overrides: lines.map(l => (l && l.override) || ''), // "State should be kept completely" — the window's own two states reset // on every reopen as well: whether it was maximized, and whether the paste // box was rolled up. Both are one flag each and both are visible. maximized: panel.classList.contains('gt-tp-max'), srcOpen: ta.style.display !== 'none', // #544 follow-up (majkinetor): 'Button "load and remove annotation" is lost // when exiting / returning.' "Apply & clear annotation" only shows once the // text CAME FROM the annotation, and that flag was a plain local — so // reopening always hid the button even though the annotation text it acts on // was restored right beside it. loadedFromAnnotation, text: ta.value, pattern, scopeKind: scope.kind, scopeSpec: scope.spec, scopeTracks: scope.tracks, roleCache: [...roleCache.entries()], entityCache: [...entityCache.entries()], entityOverride: [...entityOverride.entries()], appliedKeys: [...appliedKeys], }); const onTextChange = () => { const newLines = ta.value.split('\n').map((raw, i) => ({ raw, override: (lines[i] && lines[i].override) || '' })); // ANY line whose raw text actually changed — not just a line being // added/removed — can point its li:pi:si position at completely // different content (caught live: replacing a whole single-line // paste with a DIFFERENT single-line paste keeps the same li:0:0 // position). Two position-keyed things go stale this way: the // per-row entity override (a wrong pick could silently reattach to // unrelated new text) AND appliedKeys (a brand-new, never-applied // line could show "✓ applied" and get silently skipped by Apply, // caught live: a fresh paste inherited "applied" from an EARLIER, // unrelated line that happened to land at the same li:0:0). Drop // both for every touched line; untouched lines elsewhere keep theirs. const max = Math.max(newLines.length, lines.length); for (let li = 0; li < max; li++) { if (!lines[li] || !newLines[li] || lines[li].raw !== newLines[li].raw) { [...entityOverride.keys()].filter(k => +k.split(':')[0] === li).forEach(k => entityOverride.delete(k)); [...appliedKeys].filter(k => +k.split(':')[0] === li).forEach(k => appliedKeys.delete(k)); } } lines = newLines; render(); saveState(); }; const onPatternChange = () => { pattern = patIn.value; _txpPattern = pattern; render(); saveState(); }; patIn.addEventListener('input', onPatternChange); patClr.onclick = () => { patIn.value = ''; onPatternChange(); }; ta.addEventListener('input', onTextChange); ta.addEventListener('input', () => { loadedFromAnnotation = false; }); // #528 (majkinetor): "Once on load" — the compound-copyright-line split // runs once right after a paste lands (not on every keystroke, so it // doesn't fight a mid-edit user), same setTimeout(0)-after-paste idiom // already used elsewhere in this file. ta.addEventListener('paste', () => setTimeout(() => { const split = txpSplitCompoundCopyrightLines(ta.value); if (split !== ta.value) { ta.value = split; onTextChange(); } }, 0)); const setSrcOpen = (open) => { ta.style.display = open ? '' : 'none'; srcTgl.textContent = (open ? '▾' : '▸') + ' Paste credit text'; }; srcTgl.onclick = () => { setSrcOpen(ta.style.display === 'none'); saveState(); }; resolveBtn.onclick = resolveAll; // #544 freeze: per LINE, since the override is a line's property — a line // that expanded into several rows is frozen once. freezeBtn.onclick = () => { const seen = new Set(); let n = 0; parsedRows().forEach(r => { if (seen.has(r.li)) return; const ln = lines[r.li]; if (!ln || (ln.override || '').trim()) return; // already pinned to something if (!r.matched) return; // nothing matched, nothing to freeze seen.add(r.li); ln.override = pattern; n++; }); render(); saveState(); toast(n ? `Froze ${n} matched line${n !== 1 ? 's' : ''} to “${pattern}”` : 'No still-default lines match this pattern'); }; applyBtn.onclick = txpApply; applyClearBtn.onclick = txpApplyAndClearAnnotation; // #522 follow-up (majkinetor, live): "Load annotation should load it into // text field without any confirmation, there is no need for additional // interface" — straight into the paste box, no preview/confirm step. annoBtn.onclick = async () => { annoBtn.disabled = true; annoBtn.textContent = 'Loading…'; try { const text = await txpFetchAnnotation(release.gid); if (!text) toast('No annotation found for this release'); // onTextChange() saves, but it also CLEARS loadedFromAnnotation via the // textarea's own input handler — so the flag is set and saved after it. #544 else { ta.value = txpSplitCompoundCopyrightLines(text); onTextChange(); loadedFromAnnotation = true; render(); saveState(); } } catch (e) { toast('Failed to load annotation: ' + (e && e.message || e)); } annoBtn.disabled = false; annoBtn.textContent = 'Load annotation'; }; panel.append(hdr, ctrl, src, body, foot); txpEl.appendChild(panel); document.body.appendChild(txpEl); txpEl.addEventListener('mousedown', e => { if (e.target === txpEl) closeTextParser(); }); document.addEventListener('keydown', onTxpKey, true); if (saved) { if (saved.text) { ta.value = saved.text; const ov = saved.overrides || []; // #544 follow-up: frozen patterns lines = ta.value.split('\n').map((raw, i) => ({ raw, override: ov[i] || '' })); } (saved.roleCache || []).forEach(([k, v]) => roleCache.set(k, v)); (saved.entityCache || []).forEach(([k, v]) => entityCache.set(k, v)); (saved.entityOverride || []).forEach(([k, v]) => entityOverride.set(k, v)); (saved.appliedKeys || []).forEach(k => appliedKeys.add(k)); loadedFromAnnotation = !!saved.loadedFromAnnotation; // #544: brings "Apply & clear annotation" back with its text if (saved.maximized) maxBtn.onclick(); // same path the button takes, so the saved size handling matches if (saved.srcOpen === false) setSrcOpen(false); } // #544 follow-up: whatever was last on screen is what comes back, whichever // control changed it — see closeTextParser. _txpSaveOnClose = saveState; render(); refreshScopeUi(); // #539: show what the saved scope selects, before anything is touched setTimeout(() => { try { patIn.focus(); patIn.select(); } catch (e) {} }, 30); } // ── work credits: right-click a work's checkbox → copy its writer/composer credits to ticked works ─ // (Per maintainer: we don't copy the work itself, we add the source work's own relationships to the // selected works.) Read the work's artist rels (writer/composer/lyricist/…) via fiber, dedup, dispatch. function workCreditRels(work) { const out = [], seen = new Set(); document.querySelectorAll('.relationship-item').forEach(item => { const rel = relFromNode(item); if (!rel || !looksRel(rel)) return; const w0 = sameEntity(rel.entity0, work), w1 = sameEntity(rel.entity1, work); if (!w0 && !w1) return; const other = w0 ? rel.entity1 : rel.entity0; if (!other || ['recording', 'url', 'work'].includes(other.entityType)) return; // work credits (writer/composer/lyricist/publisher/…) — skip performance (recording), based-on (work), url const k = rel.linkTypeID + '|' + other.gid; if (seen.has(k)) return; seen.add(k); const credit = w0 ? rel.entity1_credit : rel.entity0_credit; out.push({ item, other, credit: val(credit) || '', linkTypeID: rel.linkTypeID, attributes: rel.attributes || null, begin_date: rel.begin_date || null, end_date: rel.end_date || null, ended: !!rel.ended, removed: rel._status === 3 }); }); return out; } async function removeWorkRels(work, srcRels) { const want = new Set(srcRels.map(s => s.linkTypeID + '|' + (s.other && s.other.gid))); for (let guard = 0; guard < 300; guard++) { let btn = null; for (const item of document.querySelectorAll('.relationship-item')) { const rel = relFromNode(item); if (!rel || !looksRel(rel) || rel._status === 3) continue; const w0 = sameEntity(rel.entity0, work), w1 = sameEntity(rel.entity1, work); if (!w0 && !w1) continue; const other = w0 ? rel.entity1 : rel.entity0; if (other && !['recording', 'url', 'work'].includes(other.entityType) && want.has(rel.linkTypeID + '|' + other.gid)) { btn = item.querySelector(REMOVE_SEL); break; } } if (!btn) break; try { btn.click(); } catch (e) {} await new Promise(r => setTimeout(r, 70)); } } /* ── #470 replace a role ─────────────────────────────────────────────────── "I'm editing a jazz release that is 100% instrumental. All the works have 'writer' instead of 'composer'. It'd be awesome if I could just mark all writers on highlighted works and change them from 'writer' to 'composer'." MB has no "change the type of this relationship" bulk action, so this is a remove + re-add of the same pair under a different link type, driven through the same editor dispatch everything else here uses. Scope mirrors the ×-menu's: this ROLE everywhere, or this ROLE for this one target — narrowed to the ticked recordings/works when there is a selection. Attributes are deliberately NOT carried over: they belong to a specific link type (a "drums (drum set)" attribute is meaningless on "composer"), and MB would reject or silently drop them. Rels that HAVE attributes are therefore flagged in the confirm step rather than quietly mangled — which is the "it complicates on roles that have attributes" majkinetor called out, handled by being explicit instead of clever. */ // the entity ends of a rel, as {src, other, credit}: `src` is the work or // recording the credit hangs off, `other` the artist/label/… on the far end. // credit always belongs to the far end, so it's read from whichever side that is. function relEnds(rel) { if (!rel) return null; const isContainer = e => e && (e.entityType === 'work' || e.entityType === 'recording'); const a = rel.entity0, b = rel.entity1; if (isContainer(a) && !isContainer(b)) return { src: a, other: b, credit: rel.entity1_credit || '' }; if (isContainer(b) && !isContainer(a)) return { src: b, other: a, credit: rel.entity0_credit || '' }; return null; } const relHasAttributes = rel => { try { return !!rel.attributes && [...W.MB.tree.iterate(rel.attributes)].length > 0; } catch (e) { return false; } }; // every link type valid for the SAME entity pair as `rel` — that's what MB // will accept as a replacement. Deprecated types and the current type are // dropped; the rest are sorted by name so the picker reads alphabetically. function replacementRoles(rel) { const lts = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {}; const cur = lts[rel.linkTypeID]; if (!cur) return []; // MB keys link_type by BOTH numeric id and gid, so Object.values() yields // every type twice — dedupe by id or the picker lists everything double. const seen = new Set(); return Object.values(lts) .filter(t => t && !t.deprecated && t.id !== cur.id && t.type0 === cur.type0 && t.type1 === cur.type1) .filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; }) .map(t => ({ id: t.id, name: t.name, desc: (t.description || '').replace(/<[^>]*>/g, '').trim() })) .sort((p, q) => p.name.localeCompare(q.name)); } // #522: the same filtering as replacementRoles(), but for a pair that has NO // existing relationship yet (the text parser is CREATING new credits, not // replacing one) — driven from two raw entityType strings instead. MB's // link_type table keys type0/type1 in a canonical (sorted) order. // #544: attribution for an entity created from the parser. Mirrors Apollo's // entityActionNote — the script signature, then what was being done and on // which release — so the new artist/label is not an unexplained edit. // Module scope on purpose: the __groupTherapy export at the bottom // references it, and that export is wrapped in try/catch — defined inside // openTextParser it threw ReferenceError there and silently took the WHOLE // test hook down with it. /* #550 (majkinetor): "Apply and clear annotation should do clearing in the background. It is weird to double check empty field. … It should also add appropriate edit note." MusicBrainz's annotation editor is a plain server-rendered form with no CSRF token — same shape as /add-alias, which Falcon already posts to directly — so it can be fetched, emptied and submitted without ever showing the user a page whose only content is a field we just blanked. An annotation edit is an auto-edit, so it applies immediately. Two things this deliberately does NOT do: • it never submits when the annotation is already empty (there is no edit to make, and MB would reject it anyway) — that case is reported, not faked; • it re-reads the annotation afterwards and only reports success if it is actually gone. A 200 from a form POST proves the request left the browser and nothing more. */ const TXP_ANNO_URL = gid => `/release/${gid}/edit_annotation`; function txpClearAnnotationNote(applied, scopeLabel) { return `${editNoteSig()} Cleared the annotation: its credits were entered as ${applied} relationship${applied === 1 ? '' : 's'}${scopeLabel || ''} instead, so the text was redundant.`; } async function txpFetchAnnotationForm(gid) { const r = await fetch(TXP_ANNO_URL(gid), { credentials: 'same-origin' }); if (!r.ok) throw new Error(`could not open the annotation editor (HTTP ${r.status})`); const html = await r.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const ta = doc.querySelector('textarea[name="edit-annotation.text"]'); // A logged-out session is redirected to the login page, which is a perfectly // valid 200 with no annotation form on it — say which of the two happened. if (!ta) throw new Error(/\/login/.test(r.url) ? 'you are not logged in to MusicBrainz' : 'MusicBrainz did not return the annotation form'); return { doc, ta, text: ta.value || '' }; } async function txpClearAnnotation(gid, note, changelog) { const { doc, text } = await txpFetchAnnotationForm(gid); if (!text.trim()) return { skipped: true }; const body = new URLSearchParams(); // carry every hidden field the form declares rather than naming the ones we // know about — if MusicBrainz adds one, dropping it silently would be a // rejected or malformed edit with no clue why. doc.querySelectorAll('form input[type="hidden"][name]').forEach(i => body.append(i.name, i.value)); body.set('edit-annotation.text', ''); body.set('edit-annotation.changelog', changelog || ''); body.set('edit-annotation.edit_note', note || ''); const r = await fetch(TXP_ANNO_URL(gid), { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body, }); if (!r.ok) throw new Error(`MusicBrainz rejected the annotation edit (HTTP ${r.status})`); // read it back — the POST returning 200 is not evidence the annotation is gone const after = await txpFetchAnnotationForm(gid); if (after.text.trim()) throw new Error('MusicBrainz accepted the request but the annotation is still there'); return { cleared: true, was: text.trim().length }; } function txpCreateNote(kind) { const relUrl = location.href.split(/[?#]/)[0].replace(/\/edit(-relationships)?$/, ''); return `${editNoteSig()} Created this ${kind} while adding credits parsed from text to ${relUrl}`; } function linkTypesForPair(typeA, typeB) { const lts = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type) || {}; const [t0, t1] = [typeA, typeB].sort(); const seen = new Set(); return Object.values(lts) .filter(t => t && !t.deprecated && t.type0 === t0 && t.type1 === t1) .filter(t => { if (seen.has(t.id)) return false; seen.add(t.id); return true; }) .map(t => ({ id: t.id, name: t.name, desc: (t.description || '').replace(/<[^>]*>/g, '').trim() })) .sort((p, q) => p.name.localeCompare(q.name)); } // #522 follow-up (majkinetor, live): "Instrument roles (flute, saxophone // etc.) are not resolved automatically" — MB has no standalone "Guitar" / // "Flute" link type; a specific instrument is an ATTRIBUTE (id 14's own // subtree, root_id === 14, live-verified) on the generic "instrument" // performer relationship. Role text that doesn't match any direct link // type is also tried against this vocabulary — a hit becomes the // "instrument" link type PLUS that attribute, not a link type on its own. const INSTRUMENT_ATTR_ROOT = 14; // #666: vocal types ("lead vocals", "background vocals", "soprano vocals" …) // are the same shape — the "vocal" link type plus an attribute from root 3 // (live-verified on the sandbox: 4 lead, 12 background, 13 choir, 461 other …). const VOCAL_ATTR_ROOT = 3; function txpAttrCandidates(root) { const lat = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type) || {}; const seen = new Set(); return Object.values(lat) .filter(a => a && a.root_id === root && a.id !== root) .filter(a => { if (seen.has(a.id)) return false; seen.add(a.id); return true; }) .map(a => ({ id: a.id, name: a.name })) .sort((p, q) => p.name.localeCompare(q.name)); } const txpInstrumentCandidates = () => txpAttrCandidates(INSTRUMENT_ATTR_ROOT); const txpVocalCandidates = () => txpAttrCandidates(VOCAL_ATTR_ROOT); // #666: liner-note wordings MB's vocal vocabulary doesn't use. Applied to the // whole (lower-cased, flag-stripped) role text before matching. const TXP_VOCAL_SYNONYMS = { 'backing vocals': 'background vocals', 'backing vocal': 'background vocals', 'backing vocalist': 'background vocals', 'backup vocals': 'background vocals', 'backup vocal': 'background vocals', 'background vocal': 'background vocals', 'background vocalist': 'background vocals', 'backing': 'background vocals', 'lead vocal': 'lead vocals', 'lead vocalist': 'lead vocals', 'lead singer': 'lead vocals', 'choir': 'choir vocals', 'spoken word': 'spoken vocals', }; // #666: qualifier words that are ATTRIBUTES on many link types ("Guest // Vocals", "Co-Producer", "Executive Producer"). Names, not ids, are the key — // the id is looked up from MB's own table at runtime. const TXP_FLAG_RE = /^(guest|additional|solo|co|executive|assistant|associate)(?:\s*-\s*|\s+)(.+)$/; function txpFlagAttrId(name) { const lat = (W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_attribute_type) || {}; const hit = Object.values(lat).find(a => a && a.id === a.root_id && a.name === name); return hit ? hit.id : null; } // does link type `ltId` accept attribute `attrId`? (its `attributes` map is keyed by attribute id) function txpLtAllowsAttr(ltId, attrId) { const lt = W.MB && W.MB.linkedEntities && W.MB.linkedEntities.link_type && W.MB.linkedEntities.link_type[ltId]; return !!(lt && lt.attributes && (lt.attributes[attrId] || lt.attributes[String(attrId)])); } const ROLE_HIST_KEY = 'gt-role-history'; const roleHistory = () => { try { return JSON.parse(GM_getValue(ROLE_HIST_KEY, '[]')) || []; } catch (e) { return []; } }; function rememberRole(id, name) { try { const h = roleHistory().filter(x => x.id !== id); h.unshift({ id, name }); GM_setValue(ROLE_HIST_KEY, JSON.stringify(h.slice(0, 8))); } catch (e) {} } // searchable role picker. Recently-used roles float to the top (majkinetor: // "keep last ones in combo history") since a cleanup pass usually applies the // same replacement over and over. Takes the role LIST directly (not a `rel`) // so #522's text parser can feed it `linkTypesForPair(...)` roles for a pair // that has no existing relationship yet. function openRolePicker(roles, title, onPick, initialQuery) { if (!roles.length) { toast('No relationship types are valid for this pair'); return; } const ov = el('div', 'gt-cons-ov'), panel = el('div', 'gt-cons gt-role-pick'); const hdr = el('div', 'gt-cons-hdr'); // #544 (majkinetor): "Role close button should be on standard position." // The title span carried no class, so it missed the `flex:1` that pushes // the ✕ to the right in every other dialog here — the button sat glued to // the end of the title text. Same classes as the rest now. hdr.appendChild(el('span', 'gt-cons-title', title)); const close = el('button', 'gt-cons-x', '✕'); close.type = 'button'; close.title = 'Cancel'; hdr.appendChild(close); panel.appendChild(hdr); const search = el('input', 'gt-role-search'); search.type = 'text'; search.placeholder = 'Type to filter roles…'; if (initialQuery) search.value = initialQuery; panel.appendChild(search); const list = el('div', 'gt-role-list'); panel.appendChild(list); const done = () => { try { ov.remove(); } catch (e) {} }; close.addEventListener('click', done); ov.addEventListener('mousedown', e => { if (e.target === ov) done(); }); let rows = []; // #544 (majkinetor): "Keyboard up/down cant be used when selecting role." // Enter used to fire rows[0] blindly, so the only reachable choice was // whatever sorted first. An explicit cursor makes the list navigable and // makes Enter mean "the one I can see highlighted". let active = 0; const paintActive = () => { rows.forEach((row, i) => row.classList.toggle('gt-role-active', i === active)); const row = rows[active]; if (row && row.scrollIntoView) row.scrollIntoView({ block: 'nearest' }); }; const moveActive = delta => { if (!rows.length) return; active = (active + delta + rows.length) % rows.length; // wraps, so Up from the top lands on the last paintActive(); }; function render() { const q = search.value.trim().toLowerCase(); const hist = roleHistory(); const rank = r => { const i = hist.findIndex(h => h.id === r.id); return i < 0 ? 999 : i; }; const shown = roles.filter(r => !q || r.name.toLowerCase().includes(q)) .sort((p, o) => (rank(p) - rank(o)) || p.name.localeCompare(o.name)); list.textContent = ''; rows = shown.slice(0, 200).map(r => { const row = el('div', 'gt-role-row'); const nm = el('span', 'gt-role-name', r.name); if (rank(r) < 999) nm.appendChild(el('span', 'gt-role-recent', ' recent')); row.appendChild(nm); if (r.desc) row.appendChild(el('span', 'gt-role-desc', r.desc)); // #544: no 110-char truncation — the CSS clamps to two lines row.addEventListener('click', () => { rememberRole(r.id, r.name); done(); onPick(r); }); row.addEventListener('mousemove', () => { const i = rows.indexOf(row); if (i >= 0 && i !== active) { active = i; paintActive(); } }); // #544: keep mouse and keyboard on the same row list.appendChild(row); return row; }); if (!shown.length) list.appendChild(el('div', 'gt-note', 'No role matches that filter')); if (active >= rows.length) active = 0; // #544: the filter just changed the list under us paintActive(); } search.addEventListener('input', render); search.addEventListener('keydown', e => { if (e.key === 'Escape') { e.preventDefault(); done(); return; } // #544: Up/Down move the cursor; PageUp/PageDown and Home/End jump, since // the list runs to 200 roles and paging through it one at a time is not // navigation. Enter takes whatever is highlighted. if (e.key === 'ArrowDown') { e.preventDefault(); moveActive(1); return; } if (e.key === 'ArrowUp') { e.preventDefault(); moveActive(-1); return; } if (e.key === 'PageDown') { e.preventDefault(); moveActive(10); return; } if (e.key === 'PageUp') { e.preventDefault(); moveActive(-10); return; } if (e.key === 'Home') { e.preventDefault(); active = 0; paintActive(); return; } if (e.key === 'End') { e.preventDefault(); active = Math.max(0, rows.length - 1); paintActive(); return; } if (e.key === 'Enter' && rows.length) { e.preventDefault(); (rows[active] || rows[0]).click(); } }); render(); ov.appendChild(panel); document.body.appendChild(ov); // #522 sixth round (majkinetor, live): "Make role text in the search // box selected" — pre-filled text is selected so typing immediately // overwrites it instead of appending. setTimeout(() => { try { search.focus(); search.select(); } catch (e) {} }, 30); } // remove + re-add each matching rel under `newLt`. Re-add FIRST, then remove: // if a dispatch throws we bail with the original still intact rather than // having deleted a credit we then failed to recreate. function replaceRole(items, newLt, describe) { const re = RE(); if (!re) { toast('MusicBrainz relationship editor not ready'); return 0; } let n = 0, skipped = 0, lostAttrs = 0; for (const it of items) { const rel = relFromNode(it); const ends = relEnds(rel); if (!rel || !ends || !ends.src || ends.src.id == null) { skipped++; continue; } if (relHasAttributes(rel)) lostAttrs++; try { dispatchRelationship(re, ends.src, ends.other, newLt.id, ends.credit, null, rel); } catch (e) { skipped++; try { console.warn('[Group Therapy] replace-role dispatch failed:', e); } catch (_) {} continue; } const rm = it.querySelector(REMOVE_SEL); if (rm) { try { rm.click(); } catch (e) {} } n++; } if (n) markUsed(describe(n)); const bits = [`Replaced ${n} credit${n > 1 ? 's' : ''} with “${newLt.name}”`]; if (lostAttrs) bits.push(`${lostAttrs} had attributes that could not carry over`); if (skipped) bits.push(`${skipped} skipped (see console)`); toast(bits.join(' — ') + ' — review & save'); return n; } // the two menu entries offered on a rel's pencil: this role everywhere, and // this role only where the far end is this specific entity. function replaceRoleMenuItems(item) { const rel = relFromNode(item); if (!rel || !relEnds(rel)) return []; const btn = item.querySelector(REMOVE_SEL); if (!btn) return []; const roleLabel = pickRoleLabel(item.closest('tr')) || ltName(rel.linkTypeID); const tgt = targetLabel(item); const sel = selectionKeys(); const scoped = list => (sel.recs.size || sel.works.size) ? list.filter(i => itemInSelection(i, sel)) : list; const byRole = scoped(collect(btn, 'role')); const byRoleTgt = scoped(collect(btn, 'role-and-target')); const scopeNote = (sel.recs.size || sel.works.size) ? `scoped to ${[sel.recs.size && `${sel.recs.size} recording${sel.recs.size > 1 ? 's' : ''}`, sel.works.size && `${sel.works.size} work${sel.works.size > 1 ? 's' : ''}`].filter(Boolean).join(' + ')} selected` : null; const t = (s, n) => { s = String(s || ''); return s.length > n ? s.slice(0, n - 1) + '…' : s; }; const mk = (label, list, what) => ({ label, sub: String(list.length), run: () => { if (!list.length) { toast('Nothing matches that scope'); return; } openRolePicker(replacementRoles(rel), `Replace with which role? (${list.length} credit${list.length > 1 ? 's' : ''})`, newLt => replaceRole(list, newLt, n => `Replaced ${what} with “${newLt.name}” on ${n} credit${n > 1 ? 's' : ''}` + (scopeNote ? ` (${scopeNote})` : ''))); }, }); const out = ['sep']; if (scopeNote) out.push({ note: scopeNote }); out.push( mk(`Replace role ${t(roleLabel, 34)}…`, byRole, `“${roleLabel}”`), mk(`Replace ${t(roleLabel, 20)} for “${t(tgt, 20)}”…`, byRoleTgt, `“${roleLabel}” for “${tgt}”`), ); return out; } function openWorkMenu(workRef, x, y, preselect, extraItems) { const srcWork = (workRef && workRef.entityType === 'work') ? workRef : workEntity(workRef); // #373 accept a work entity (from a rel) or a checkbox if (!srcWork) { openMenu(x, y, [{ header: 'Could not read this work' }]); return; } const srcRels = workCreditRels(srcWork).filter(r => !r.removed); const entries = srcRels.map(s => ({ rel: s, role: roleKeyOfSpec(s), pos: roleLabelOf(s), text: val(s.other.name) + (s.credit && s.credit !== val(s.other.name) ? ` (${s.credit})` : ''), checked: preselect ? !!preselect(s) : true })); // #373 pencil/+ pre-tick a subset const chosen = () => entries.filter(e => e.cb ? e.cb.checked : e.checked !== false).map(e => e.rel); // Destinations: ticked works come from MB's own selection state, not the DOM — a newly-created // work's checkbox has no readable React entity (its fiber differs), so a DOM scan misses it, but // selectedWorks holds every ticked work. New works carry a NEGATIVE id and may have no gid yet, so // identify by gid-or-id (not gid alone, which would drop them). If NOTHING is ticked, fall back to // every OTHER work on the page — mirrors openCopyMenu's recording behavior (#522 follow-up: work // credit copy required a pre-tick while recording credit copy already copied-to-all by default). const idOf = w => w.gid || ('#' + w.id); const tickedWorks = () => { const out = [], seen = new Set(); try { for (const e of W.MB.tree.iterate(RE().state.selectedWorks)) { const w = Array.isArray(e) ? e[1] : e; if (!w) continue; if (srcWork.gid && w.gid === srcWork.gid) continue; // skip the source work if (srcWork.id != null && w.id === srcWork.id) continue; const k = idOf(w); if (seen.has(k)) continue; seen.add(k); out.push(w); } } catch (e) {} return out; }; const allOtherWorks = () => { const out = [], seen = new Set(); document.querySelectorAll('input.work').forEach(cb => { const w = workEntity(cb); if (!w) return; if (srcWork.gid && w.gid === srcWork.gid) return; if (srcWork.id != null && w.id === srcWork.id) return; const k = idOf(w); if (seen.has(k)) return; seen.add(k); out.push(w); }); return out; }; const ticked0 = tickedWorks(); const destWorks = ticked0.length ? ticked0 : allOtherWorks(); const nR = srcRels.length, nD = destWorks.length; const destNames = destWorks.map(w => val(w.name)); const items = []; if (!nR) items.push({ header: `“${trunc(val(srcWork.name), 34)}” has no credits` }); else if (!nD) items.push({ header: 'No other works to copy to' }); else { const copyItem = { label: 'Copy', sub: String(nR), run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, destWorks)) markUsed(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} from work “${val(srcWork.name)}” to ${nD} work${nD > 1 ? 's' : ''}`); toast(`Copied ${c.length} credit${c.length > 1 ? 's' : ''} to ${nD} work${nD > 1 ? 's' : ''} — review & save`); } }; const moveItem = { label: 'Move (remove here)', danger: true, run: () => { const c = chosen(); if (!c.length) { toast('No credits selected'); return; } if (copyCredits(c, destWorks)) markUsed(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} from work “${val(srcWork.name)}” to ${nD} work${nD > 1 ? 's' : ''}`); removeWorkRels(srcWork, c); toast(`Moved ${c.length} credit${c.length > 1 ? 's' : ''} to ${nD} work${nD > 1 ? 's' : ''} — review & save`); } }; items.push({ header: `Copy to ${ticked0.length ? '' : 'all '}${nD} work${nD > 1 ? 's' : ''}` }, { note: destNames.slice(0, 6).join(' · ') + (destNames.length > 6 ? ` +${destNames.length - 6} more` : '') }, { checklist: entries, onToggle: () => { const n = chosen().length; copyItem._setSub && copyItem._setSub(String(n)); } }, copyItem, moveItem); } if (extraItems && extraItems.length) items.push(...extraItems); // #470 "Replace role…" from the pencil openMenu(x, y, items); } // discoverability tooltips: label the controls GT hooks (set lazily on first hover; React may wipe // them on re-render, so we re-set via the data flag). Kept out of `title` when MB already set one. function hintControls(ev) { const t = ev.target; if (!t || !t.closest) return; const cb = t.closest && t.closest('tr.track input.recording, tr.track input.work'); if (cb && !cb.dataset.gtHint) { cb.dataset.gtHint = '1'; if (!cb.title) cb.title = cb.matches('input.work') ? 'Group Therapy: right-click to copy/move this work’s relationships to the ticked works' : 'Group Therapy: right-click to copy/move this recording’s credits to the ticked recordings'; return; } const x = t.closest('button.icon.remove-item'); if (x && !x.dataset.gtHint) { x.dataset.gtHint = '1'; if (!x.title) x.title = 'Group Therapy: right-click to remove a whole group (this role / this target / both)'; } } // ── boot ──────────────────────────────────────────────────────────────── function boot() { injectStyle(); document.body.addEventListener('contextmenu', onContextMenu, true); document.body.addEventListener('mouseover', onOver); document.body.addEventListener('mousemove', onMove); document.body.addEventListener('mouseout', onOut); document.body.addEventListener('mouseover', hintControls, true); let tries = 0; (function tryInject() { if (injectCloneButton() || tries++ > 40) return; setTimeout(tryInject, 500); })(); try { W.__groupTherapy = { VERSION, fmtBadges, badgeInk, FMT_FAMILY, collect, removeButtons, highlightPage, recordingRels, recordingEntity, copyCredits, checkedDestinations, openCopyMenu, removeSourceRels, rowForRecording, fetchReleaseRels, injectCloneButton, openCopyFromPopover, workEntity, workCreditRels, openWorkMenu, mediumFormatOf, formatExcludeRolesFor, RE, replacementRoles, replaceRole, replaceRoleMenuItems, relEnds, openRolePicker, // #522 text parser txpTokenize, txpCompile, txpExpand, linkTypesForPair, openTextParser, closeTextParser, txpTrackRows, txpMatchTracks, // #539 recording scope txpMatchTrackToken, txpDetectTracks, // #597 per-row tracks txpCreateNote, // #544 txpClearAnnotation, txpFetchAnnotationForm, txpClearAnnotationNote, // #550 txpSearchArtist, txpResolveByExactAlias, txpFetchEntity, txpFetchAnnotation, txpAnnoHtmlToText, txpSearchLabel, txpResolveLabelByExactAlias, txpParseCopyrightLine, txpNoticeDatePeriod, txpNarrowByScore, txpInstrumentCandidates, txpVocalCandidates, txpSplitCompoundCopyrightLines, wmMedleyParts, wmRecordings, openWorkMatch, // #718 medleys }; } catch (e) {} console.log('[Group Therapy] ' + mbuStartupInfo('Group Therapy')); console.log(`[Group Therapy] v${VERSION} ready — right-click a relationship's × for group delete; hover a name/role to highlight.`); } /* #544 (majkinetor): "Right click on + doesn't create in the background" — window.open + win.blur()/window.focus() does not put a tab in the background in Chrome, so it behaved exactly like a left click. GM_openInTab({active:false}) does, but it hands back no window handle, so the created MBID cannot be read by polling the tab's URL the way the foreground path does. Credit Hoarder solved this by running on the created entity's own page and posting the MBID back (#273); the same shape here. That is what the new /artist/*, /label/*, /place/* matches are for — this handler and nothing else. Everything below still boots only on the relationship editor. */ const GT_CREATE_CH = 'gt-entity-created'; const GT_PENDING_KEY = 'gt:pendingCreate'; const GT_PENDING_TTL = 10 * 60 * 1000; /* #598: which create a given tab is running. sessionStorage, deliberately — it is per-TAB and survives navigation within that tab, which is exactly the relation we need: the /<kind>/create page and the /<kind>/<mbid> page it submits itself into are the same tab, and no amount of shared GM state can tell those two pages apart once several creates are in flight at once. The create page stamps its token here before pressing Enter; the entity page reads it and knows, with no guessing, which row is waiting for it. */ const GT_TAB_KEY = 'gt:createTab'; /* #598: the pending-create store. One slot became a list, because two concurrent background creates evicted each other — see the comment at the GM_openInTab call. Every read prunes: a create nobody ever completed must not keep matching forever, and the TTL is the same ten minutes the opener waits before it gives up on its own. */ function gtPending() { let v = null; try { v = JSON.parse(GM_getValue(GT_PENDING_KEY, '') || 'null'); } catch (e) {} // An install upgrading mid-create still has the old single-object shape // under this key; adopt it rather than throwing that create away. if (v && !Array.isArray(v)) v = [v]; if (!Array.isArray(v)) return []; const now = Date.now(); return v.filter(p => p && p.token && p.kind && p.ts && now - p.ts < GT_PENDING_TTL); } function gtPendingWrite(list) { try { GM_setValue(GT_PENDING_KEY, JSON.stringify(list)); } catch (e) {} } function gtPendingAdd(rec) { gtPendingWrite(gtPending().concat([rec])); } function gtPendingDrop(token) { gtPendingWrite(gtPending().filter(p => p.token !== token)); } function gtTabMark(rec) { try { sessionStorage.setItem(GT_TAB_KEY, JSON.stringify(rec)); } catch (e) {} } function gtTabRead() { try { return JSON.parse(sessionStorage.getItem(GT_TAB_KEY) || 'null'); } catch (e) { return null; } } function gtTabClear() { try { sessionStorage.removeItem(GT_TAB_KEY); } catch (e) {} } const GT_ENTITY_PATH = /^\/(artist|label|place)\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/?$/i; (function announceCreatedEntity() { const m = location.pathname.match(GT_ENTITY_PATH); if (!m) return; // not an entity page const kind = m[1].toLowerCase(), gid = m[2].toLowerCase(); const list = gtPending(); let token = null; // #598: the tab's own stamp is the authority. It was written by this script // in this tab moments ago, immediately before pressing "Enter edit", so it // identifies the create exactly even with several tabs mid-flight. const mark = gtTabRead(); if (mark && mark.kind === kind && mark.token && mark.ts && Date.now() - mark.ts < GT_PENDING_TTL) { token = mark.token; } else { // No stamp: an older create page, or sessionStorage unavailable. Fall // back to the pre-#598 behaviour, but ONLY where there is nothing to // guess between — handing the wrong MBID to the wrong row is a worse // outcome than leaving a tab open for the user to deal with. const same = list.filter(p => p.kind === kind); if (same.length !== 1) return; token = same[0].token; } // Only answer a create WE started — otherwise merely browsing an artist // page would close the tab. if (!token) return; gtTabClear(); gtPendingDrop(token); try { const ch = new BroadcastChannel(GT_CREATE_CH); ch.postMessage({ kind, gid, token }); setTimeout(() => { try { ch.close(); } catch (e) {} try { window.close(); } catch (e) {} }, 300); } catch (e) {} })(); /* #544 follow-up (majkinetor): "When creating in the background, it doesn't click Enter in 2nd tab." A seeded /<kind>/create URL only PRE-FILLS the form; MusicBrainz still waits for "Enter edit". In the foreground that is the user's own click. In a background tab nobody ever clicks it, so the entity is never created and the text parser waits out its full ten minutes for a post-back that cannot come. Three things keep this from ever submitting a form it should not: • the x_gtcreate token in the URL must name a create that is actually pending, so this can only ever fire on a page one background create opened — not on a /artist/create the user opened themselves while a create was pending; • the name field must actually be filled, i.e. the seed really landed; • it stamps the tab and marks the record `submitted` BEFORE clicking, so it presses Enter exactly once. If MusicBrainz answers with a duplicate-check or validation page, this stands down and the tab is left for the user — which is the right outcome for anything needing a human decision. (The stamp outlives that hand-off, so a create the user finishes themselves still reports its MBID back to the row. #598) */ const GT_CREATE_PATH = /^\/(artist|label|place)\/create\/?$/i; (function autoSubmitSeededCreate() { const m = location.pathname.match(GT_CREATE_PATH); if (!m) return; const kind = m[1].toLowerCase(); // This runs unattended in a tab nobody is looking at, so it says what it // decided — without a line here, "it didn't create anything" is // indistinguishable from "it never ran". const say = (msg) => { try { console.log('[Group Therapy] background create: ' + msg); } catch (e) {} }; // #598: the URL's token is the question, and the pending LIST is looked up // by it. The old order asked "what is the one pending create?" and then // required this page to be it — so the moment a second create was started, // the first tab's page failed that test and never pressed Enter. Which // create this page belongs to is written right there in its own URL; there // was never a reason to ask a shared slot instead. let token = null; try { token = new URLSearchParams(location.search).get('x_gtcreate'); } catch (e) {} if (!token) { say('no create token in this URL — we did not open this form, leaving it alone'); return; } const pending = gtPending().find(p => p.token === token && p.kind === kind); if (!pending) { say(`no pending ${kind} create matches this page's token — leaving this form alone`); return; } if (pending.submitted || gtTabRead()) { say('already pressed Enter once for this create — standing down (MusicBrainz may be asking something)'); return; } const go = () => { // MusicBrainz's edit form needs its own JavaScript before "Enter edit" // does anything — measured: clicking at document-start left the tab // sitting on a filled-in /create page with nothing submitted, while the // same click after load went straight through to the new artist. A // userscript manager runs this at document-end, but that is not late // enough to rely on, so wait for the document to actually be done. if (document.readyState !== 'complete') return false; const form = document.querySelector(`form.edit-${kind}`); const name = form && form.querySelector(`[name="edit-${kind}.name"]`); const submit = form && form.querySelector('button[type="submit"], input[type="submit"]'); if (!form || !name || !name.value.trim() || !submit) return false; // Re-read the record HERE, not just at start-up: `pending` was captured // before the wait, so two instances of this script on one document (or a // second create started meanwhile) could both pass the start-up guard and // both click — two artists from one press. Observed with a doubled // injection in testing; cheap to make impossible for a create action. // // #598: the tab stamp is what makes this exact rather than approximate. // It is per-tab, so a create running in ANOTHER tab can neither set nor // clear it, while a second instance in THIS document sees it at once. // Writing it is also what lets the created entity's page — the same tab, // one navigation later — know which of several creates it is. if (gtTabRead()) { say('already pressed Enter once in this tab — standing down'); return true; } const now = gtPending().find(p => p.token === token); if (!now || now.submitted) { say('this create is no longer pending (cancelled, or already submitted) — standing down'); return true; } gtTabMark({ kind, token, ts: Date.now() }); gtPendingWrite(gtPending().map(p => (p.token === token ? Object.assign({}, p, { submitted: true }) : p))); say(`pressing "Enter edit" for “${name.value.trim()}”`); try { submit.click(); } catch (e) { say('the click threw: ' + ((e && e.message) || e)); } return true; }; // MusicBrainz's edit forms bind late (the same reason Falcon waits for its // seeded rows to settle), so clicking the instant the DOM exists can land // before the seed is bound. Retry briefly rather than guessing one delay. say('waiting for the seeded form to be ready'); let tries = 0; const iv = setInterval(() => { if (go()) { clearInterval(iv); return; } if (++tries > 120) { clearInterval(iv); say('gave up waiting for the form — the tab is left for you to submit by hand'); } else if (tries % 20 === 0) say(`still waiting (readyState=${document.readyState}, form=${!!document.querySelector('form.edit-' + kind)})`); }, 250); // ~30s })(); // Self-guard the page: in the String Theory bundle this script runs on EVERY union-matched URL // (Apollo's /release/*/edit, /artist/*, …), so its hover-highlight etc. would bleed onto other pages. // Standalone the @match restricts it; the bundle doesn't — so only boot on the relationship editor. if (/\/release\/[^/]+\/edit-relationships\/?$/i.test(location.pathname)) { if (document.body) boot(); else document.addEventListener('DOMContentLoaded', boot, { once: true }); } })();