// ==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 || /\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;
}
//
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 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
… × name …
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
, 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: " v by - ".
// 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 ); 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/ 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.
// — generated by dev/tokens/sync-tokens.mjs from dev/tokens/design-tokens.mjs — DO NOT EDIT
const MBU_TOKENS = ':root{--mbu-bg:var(--background, #fff);--mbu-bg-raised:#faf9fe;--mbu-bg-raised:color-mix(in srgb, var(--mbu-bg) 96%, var(--mbu-accent));--mbu-bg-sunken:#f4f2f9;--mbu-bg-sunken:color-mix(in srgb, var(--mbu-bg) 94%, var(--mbu-text));--mbu-bg-hover:#f3eefe;--mbu-bg-hover:color-mix(in srgb, var(--mbu-bg) 91%, var(--mbu-accent));--mbu-text:var(--text, #222);--mbu-text-dim:#555;--mbu-text-dim:color-mix(in srgb, var(--mbu-text) 78%, var(--mbu-bg));--mbu-text-weak:#999;--mbu-text-weak:color-mix(in srgb, var(--mbu-text) 52%, var(--mbu-bg));--mbu-text-on-accent:#fff;--mbu-border:var(--border, #cfc6e6);--mbu-border-soft:#e2dcef;--mbu-border-strong:#9a8ccb;--mbu-border-strong:color-mix(in srgb, var(--mbu-border) 70%, var(--mbu-text));--mbu-divider:#eee;--mbu-divider:color-mix(in srgb, var(--mbu-bg) 92%, var(--mbu-text));--mbu-accent:#5f3ec0;--mbu-accent-hover:#4e329f;--mbu-accent-deep:#3b2c70;--mbu-accent-soft:#ece4ff;--mbu-accent-soft:color-mix(in srgb, var(--mbu-bg) 86%, var(--mbu-accent));--mbu-accent-fg:#fff;--mbu-accent-text:#5f3ec0;--mbu-accent-deep-text:#3b2c70;--mbu-ok:#1f9d6b;--mbu-ok:color-mix(in srgb, #1f9d6b 78%, var(--mbu-text));--mbu-ok-bg:#eef7f1;--mbu-ok-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-ok));--mbu-ok-border:#9bd3b6;--mbu-warn:#a05a00;--mbu-warn:color-mix(in srgb, #b4791f 78%, var(--mbu-text));--mbu-warn-bg:#fff7e6;--mbu-warn-bg:color-mix(in srgb, var(--mbu-bg) 88%, var(--mbu-warn));--mbu-warn-border:#f0c877;--mbu-error:#c0392b;--mbu-error:color-mix(in srgb, #d0473a 78%, var(--mbu-text));--mbu-error-bg:#fdecec;--mbu-error-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-error));--mbu-error-border:#e2a1a1;--mbu-info:#2f7fbf;--mbu-info:color-mix(in srgb, #3f8fd0 78%, var(--mbu-text));--mbu-info-bg:#eef4fb;--mbu-info-bg:color-mix(in srgb, var(--mbu-bg) 90%, var(--mbu-info));--mbu-info-border:#a9c8e6;--mbu-font:-apple-system,Segoe UI,Roboto,Arial,sans-serif;--mbu-font-mono:ui-monospace,SFMono-Regular,Consolas,Menlo,monospace;--mbu-fs:14px;--mbu-fs-sm:12px;--mbu-fs-xs:11px;--mbu-radius:6px;--mbu-radius-lg:10px;--mbu-shadow:0 1px 5px rgba(60,40,110,.07);--mbu-shadow-lg:0 8px 30px rgba(40,20,80,.3);--mbu-z-panel:30;--mbu-z-pop:99998;--mbu-z-modal:2147483000;--mbu-z-modal-panel:2147483001}:root[data-mbu-theme="dark"]{--mbu-bg:#1e1b24;--mbu-text:#e9e5f2;--mbu-border:#3b3548;--mbu-accent-text:#b9a7f0;--mbu-accent-deep-text:#a493e0}:root[data-mbu-theme="dark"][data-mbu-seed="theme"]{--mbu-bg:var(--background, #1e1b24);--mbu-text:var(--text, #e9e5f2);--mbu-border:var(--border, #3b3548)}';
//
// The shared UI components (#563). Definitions live in dev/ui/ui-components.mjs
// and are inlined here by dev/ui/sync-ui.mjs — edit them THERE, never here.
// — generated by dev/ui/sync-ui.mjs from dev/ui/ui-components.mjs — DO NOT EDIT
const MBU_UI_CSS = '.mbu-help{font-size:12px;color:var(--mbu-accent-text);text-decoration:none;border:1px solid var(--mbu-border);border-radius:var(--mbu-radius);padding:1px 8px;white-space:nowrap;line-height:1.6;background:none}.mbu-help:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-accent);text-decoration:none}h4>.mbu-help,.mbu-cfg-h>.mbu-help{margin-left:8px;flex:0 0 auto;font-weight:normal}#mbu-toast{position:fixed;left:50%;bottom:24px;transform:translateX(-50%);z-index:var(--mbu-z-pop);background:var(--mbu-accent-deep);color:var(--mbu-text-on-accent);padding:10px 16px;border-radius:9px;font:13px/1.35 var(--mbu-font);box-shadow:var(--mbu-shadow-lg);opacity:0;transition:opacity .2s;pointer-events:none;max-width:80vw;text-align:center;white-space:pre-wrap}#mbu-toast.mbu-toast-on{opacity:1}#mbu-toast.mbu-toast-act{pointer-events:auto}#mbu-toast .mbu-toast-btn{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 '' + (label || '? Help') + ' ';
}
function mbuHelpEl(name, label) {
var a = document.createElement('a');
a.className = 'mbu-help';
a.href = mbuHelpHref(name);
a.target = '_blank';
a.rel = 'noopener';
a.title = 'open the README in a new tab';
a.textContent = label || '? Help';
return a;
}
// The config (settings) icon every script's settings button shows: the gear in text
// presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650
var MBU_CFG_ICON = '\u2699\uFE0E';
// HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a
// plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy
// passes the string through; the markup is the script's own. Elsewhere it is the string.
var _mbuTT;
function mbuHtml(s) {
if (_mbuTT === undefined) {
_mbuTT = null;
try {
var tt = (typeof window !== 'undefined' && window.trustedTypes) || null;
if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } });
} catch (e) { /* the page allows no new policy: plain strings, as before */ }
}
return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s);
}
// The first line of every script's log: the script, its version, and what runs it, so a
// pasted log says which manager and browser it came from (#282 started it in Art Station):
// Log.info(mbuStartupInfo('Fusion'));
// -> Fusion v2026.10.1 · Violentmonkey 2.31.0 · firefox 143.0 (win)
// Inside String Theory GM_info describes the bundle, so the line names it:
// -> Fusion (String Theory v2026.10.1) · Tampermonkey 5.3.3 · chrome 140.0 (win)
// 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)) {
// , 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 , which every copy sees whatever its sandbox.
// It is decided at once, so no script starts late. The copy that starts first takes the
// page. When it is the older one, the newer copy stays off for this page and leaves a note
// in localStorage, and from the next page load the older copy finds the note and steps
// aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the
// page loads, so it runs again from the load after.
function mbuClaimVer(v) {
return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; });
}
function mbuClaimCmp(a, b) {
var x = mbuClaimVer(a), y = mbuClaimVer(b);
for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
function mbuClaim(key, label) {
var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {};
var name = String(info.name || label || key), ver = String(info.version || '0');
var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver;
var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key;
var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } };
var note = null;
try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ }
// A note older than this copy is spent. This copy's own note stays: it is what keeps the
// older copy aside on every later load, not only the next one (#671).
var noteCmp = note ? mbuClaimCmp(note.ver, ver) : 1;
if (noteCmp < 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } }
if (noteCmp <= 0) note = null;
var held = root && root.getAttribute(attr);
var off = function (why) {
// tell the running copy (any sandbox hears a DOM event), or the copy that runs after
// this one (it reads the attribute), and stay quiet
try { if (root) root.setAttribute(attr + '-off', JSON.stringify({ mine: mine, why: why })); } catch (e) { /* no attribute */ }
try { document.dispatchEvent(new CustomEvent(ev, { detail: JSON.stringify({ mine: mine, ver: ver, why: why }) })); } catch (e) { /* no event */ }
return false;
};
if (held) {
var heldVer = (root.getAttribute(attr + '-ver') || '0');
if (mbuClaimCmp(ver, heldVer) > 0) {
try { localStorage.setItem(noteKey, JSON.stringify({ ver: ver, mine: mine, at: Date.now() })); } catch (e) { /* storage blocked */ }
return off('newer, from the next page load');
}
return off('older or the same');
}
if (note) {
// a newer copy said it is installed: leave the page to it, unless it never shows up
var watch = function () {
setTimeout(function () {
if (root.getAttribute(attr)) return;
try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ }
try { console.info('[' + (label || key) + '] the newer copy (' + note.mine + ') did not start: this copy runs again from the next page load'); } catch (e) { /* no console */ }
}, 3000);
};
if (document.readyState === 'complete') watch(); else window.addEventListener('load', watch, { once: true });
return off('older: a newer copy runs');
}
if (root) { root.setAttribute(attr, mine); root.setAttribute(attr + '-ver', ver); }
var told = function (o) {
log((label || key) + ' is installed twice: ' + mine + ' runs, ' + (o.mine || 'another copy') + ' is switched off'
+ (o.why === 'newer, from the next page load' ? ' for this page (it is newer and runs from the next page load)' : '') + '.');
};
document.addEventListener(ev, function (e) { var o = {}; try { o = JSON.parse(e.detail); } catch (x) { /* not ours */ } told(o); });
// a copy that stepped aside before this one started (it found a note): log it once the script's log is up
var before = root && root.getAttribute(attr + '-off');
if (before) setTimeout(function () { var o = {}; try { o = JSON.parse(before); } catch (x) { /* not ours */ } told(o); }, 0);
return true;
}
// Toast. mbuToast(msg) or mbuToast(msg, { ms, kind, at:{x,y}, action:{ label, onClick } }).
//
// An action adds one button to the toast (e.g. "Copy log"): the toast is then clickable,
// stays up longer (12 s unless ms says otherwise), and closes when the button is used.
//
// Severity is inferred from a leading warning/tick glyph when not given — Art
// Station already did that and it is why its toasts reached its log with the
// right level. Set mbuToast.log = function (level, message) {...} once at
// startup and every toast mirrors into that script's own log; leave it unset
// and the toast still shows.
var _mbuToastT = null;
function mbuToast(msg, opts) {
opts = opts || {};
var s = String(msg);
var kind = opts.kind || (/^\s*[⚠✗×]/.test(s) ? 'warn' : /[✓✅]/.test(s) ? 'ok' : 'info');
try {
if (typeof mbuToast.log === 'function') mbuToast.log(kind, s.replace(/^\s*[⚠✗×✓✅]\s*/, ''));
} catch (e) { /* a broken log sink must never swallow the toast */ }
var el = document.getElementById('mbu-toast');
if (!el) {
el = document.createElement('div');
el.id = 'mbu-toast';
(document.body || document.documentElement).appendChild(el);
}
el.className = 'mbu-toast-on' + (kind !== 'info' ? ' mbu-toast-' + kind : '') + (opts.action ? ' mbu-toast-act' : '');
el.textContent = s;
if (opts.action) {
var b = document.createElement('button');
b.type = 'button'; b.className = 'mbu-toast-btn'; b.textContent = opts.action.label || 'OK';
b.onclick = function () {
try { if (opts.action.onClick) opts.action.onClick(b); } catch (e) { /* the toast still closes */ }
clearTimeout(_mbuToastT); _mbuToastT = setTimeout(function () { el.className = ''; }, 900);
};
el.appendChild(b);
}
// Anchor above a click point when asked, clamped into the viewport; otherwise
// fall back to the centred default by clearing the inline placement.
if (opts.at) {
var w = el.offsetWidth, h = el.offsetHeight;
el.style.left = Math.max(6, Math.min(window.innerWidth - w - 6, opts.at.x - w / 2)) + 'px';
el.style.top = Math.max(6, Math.min(window.innerHeight - h - 6, opts.at.y - h - 10)) + 'px';
el.style.bottom = 'auto';
el.style.transform = 'none';
} else {
el.style.left = ''; el.style.top = ''; el.style.bottom = ''; el.style.transform = '';
}
clearTimeout(_mbuToastT);
_mbuToastT = setTimeout(function () { el.className = ''; }, opts.ms || (opts.action ? 12000 : 2600));
return el;
}
// Config-window title bar.
//
// mbuCfgHeader({ script:'art_station', name:'Art Station', version:'2026.9.2',
// icon:'' | ' ', log:true, logClass:'as-setup-logbtn' })
//
// Returns the markup for the whole bar. 'log' adds the Log button; a script with
// no log window leaves it out rather than shipping a dead control. logClass /
// logId are carried through IN ADDITION to the shared class so a script's
// existing click handler keeps working — adopting the component must not mean
// rewiring every listener at the same time.
function mbuCfgHeader(o) {
o = o || {};
var esc = function (s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"' }[c];
});
};
var html = '';
if (o.icon) html += '' + o.icon + ' ';
html += '' + esc(o.name) + ' ';
if (o.version) html += 'v' + esc(o.version) + ' ';
html += ' ';
if (o.log) {
html += 'Log ';
}
html += mbuHelpHtml(o.script);
return html + '
';
}
// Test hooks. A script puts its test hook on window only when the test harness has
// marked the page (dev/test/harness.mjs sets window.__mbuTest before any script runs):
// if (mbuTestHooks()) window.__fooTest = { … };
// On a user's page the hooks are never built. (#623)
function mbuTestHooks() {
try { return typeof window !== 'undefined' && window.__mbuTest === true; } catch (e) { return false; }
}
// 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.'; 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 '' + url + ' ' + t;
});
};
var load = o.load || function (k) { try { return GM_getValue(k, undefined); } catch (e) { return undefined; } };
var save = o.save || function (k, v) { try { GM_setValue(k, v); } catch (e) { /* no storage: the window just forgets */ } };
var state = function () { try { return JSON.parse(load(o.key) || '{}') || {}; } catch (e) { return {}; } };
var remember = function (patch) { try { save(o.key, JSON.stringify(Object.assign(state(), patch))); } catch (e) { /* see save */ } };
// 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 '' + title(!!past) + ' — ' + what + n + ' \n\n' + fence + 'log\n' + body + '\n' + fence + '\n\n ';
}
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('' + (floating ? '' + esc(o.header || 'Activity log') + ' ' : '') + ' '
+ ' '
+ (H ? ' ' : '')
+ ' '
+ (H ? 'Clear history ' : '')
+ ' debug '
+ 'Clear '
+ '⧉ Copy '
+ (floating ? '⛶ '
+ '– '
+ '✕ ' : '') + '
'
+ '
'
+ '
' + (floating ? '
' : ''));
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('' + ts(e.t) + ' '
+ '' + esc(e.cat || '') + ' '
+ '' + linkify(e.msg) + ' ');
return d;
};
// the filter row, redrawn when a level or category first appears; hiding is one
// rule in the viewer's own