// ==UserScript==
// @name Mammoth
// @namespace https://musicbrainz.org/
// @version 2026.10.3
// @description Edit-note memory for MusicBrainz: auto-remembers your last edit notes and lets you save reusable ones, recalling them from a compact panel beside the edit-note field on every edit form. A nicer replacement for Elephant Editor.
// @author majkinetor
// @icon data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij48dGV4dCB4PSI2NCIgeT0iNjgiIGZvbnQtc2l6ZT0iMTA0IiB0ZXh0LWFuY2hvcj0ibWlkZGxlIiBkb21pbmFudC1iYXNlbGluZT0iY2VudHJhbCI+8J+mozwvdGV4dD48L3N2Zz4=
// @homepageURL https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md
// @match https://*.musicbrainz.org/*
// @grant GM_setValue
// @grant GM_getValue
// @grant unsafeWindow
// @run-at document-idle
// ==/UserScript==
//
// Mammoth puts a compact saved-notes panel to the RIGHT of MusicBrainz's native
// Edit note field (textarea.edit-note), which appears on every edit form, and
// widens that (centered) field to make room.
//
// - AUTO-HISTORY: remembers the last N edit notes you submit (default 10, deduped).
// - SAVED notes: β favourite (sorts to top), drag (β Ώ) to reorder, π delete.
// One line each (full text on hover).
// - INSERT: a click applies your default action (append or replace, see β);
// right-click does the other. Ctrl/β + β/β cycles saved notes, replacing the
// field. Append skips a line already present. Never auto-overwrites blindly,
// so it won't clobber notes Apollo / Credit Hoarder / Platform Check write.
// - BABY MAMMOTHS (β "Show mammoth babies"): the same save/reuse idea on other
// controls β catalog number, label, artist, status, languageβ¦ A small 𦣠pin
// on each field recalls values you've saved for it; β pins one as an always-
// visible button under the field; one entry can be the default (auto-fills an
// empty field). Targets: a built-in release-editor set + any element another
// script tags class="mmth-pin". Stored separately (mammoth-fields:data).
(function () {
'use strict';
// one copy per page: with String Theory and a standalone install both on, the newer one runs (#653)
if (!mbuClaim('mammoth', 'Mammoth')) return;
try { console.info('[Mammoth] ' + mbuStartupInfo('Mammoth')); } catch (e) { /* no console */ } // Mammoth keeps no session log
const KEY = 'mammoth:data';
const SKEY = 'mammoth:settings';
const DEFAULTS = { historySize: 10, hideHelp: false, defaultInsert: 'replace', visibleRows: 6, sideWidth: 300, appendNewline: true, minimized: false, showBabies: true, noteSort: 'manual', btnChars: 24, scopePerResource: false, customFields: [] }; // defaultInsert: 'replace' | 'append'; noteSort: 'manual' | 'uses' | 'recent'; btnChars: pinned-button label length; scopePerResource: per-type note pools (#309); customFields: user-defined baby fields [{match,label,key,dx,entity}]
const VERSION = '?'; // GM_info carries the real one; every script manager provides it (a hard-coded copy only ever went stale)
const SYNTAX_URL = 'https://musicbrainz.org/doc/Edit_Note';
const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } };
// #308: the 𦣠emoji (U+1F9A3) renders as a tofu box in Chrome on systems whose
// emoji font lacks it (Firefox bundles its own, hence the inconsistency). Use a
// self-contained vector mammoth everywhere the icon shows, so it's font-independent.
// #564 (majkinetor): "Mammoth baby could be a bit lighter". The fur was a
// fixed #7a4a1f, chosen against a white field; on a dark one it is a brown
// smudge. Both colours are tokens now (see --mmth-fur / --mmth-tusk below),
// written through style= rather than the fill= attribute, because a
// presentation attribute is not a reliable place for var().
const MAMMOTH_SVG = '';
// #309: optionally keep notes SEPARATE per edit-note entity type (release /
// artist / recording / β¦), derived from the page URL β opt-in via the
// "Scope per resource" setting. Off (default) = one shared pool ('all').
const ENTITY_TYPES = ['area', 'artist', 'event', 'genre', 'instrument', 'label', 'place', 'recording', 'release', 'release-group', 'series', 'work'];
const GLOBAL_SCOPE = 'all';
function noteScope() {
const seg = (location.pathname.split('/').filter(Boolean)[0] || '').toLowerCase();
return ENTITY_TYPES.includes(seg) ? seg : 'other';
}
const SCOPE = noteScope();
const scopeLabel = s => s === 'other' ? 'other' : s.replace(/-/g, ' ');
// Multi-scope store: { [scope]: { saved, history } }. Migrate the old flat
// { saved, history } into the shared 'all' pool (scoping is off by default).
function loadStore() {
let raw; try { raw = JSON.parse(GM_getValue(KEY, '{}') || '{}'); } catch (e) { raw = {}; }
if (!raw || typeof raw !== 'object') raw = {};
if (Array.isArray(raw.saved) || Array.isArray(raw.history)) raw = { [GLOBAL_SCOPE]: { saved: raw.saved || [], history: raw.history || [] } };
return raw;
}
const loadSet = () => { try { return Object.assign({}, DEFAULTS, JSON.parse(GM_getValue(SKEY, '{}') || '{}')); } catch (e) { return Object.assign({}, DEFAULTS); } };
const persistSet = () => { try { GM_setValue(SKEY, JSON.stringify(SET)); } catch (e) {} }; // quiet save (no re-render)
const saveSet = () => { persistSet(); applyHelp(); render(); };
// Built-in "OTB" baby fields β now seeded INTO the config (SET.customFields) so they're visible/editable
// in the Fields tab like any custom field. Storage keys are derived from the LABEL (same-label fields share
// one list) β no explicit `key`. `mbid:true` (JSON-only flag) enables entity-MBID capture on Label/Artist.
const BUILTIN_FIELDS = [
{ match: 'input[id^="catno-"]', label: 'Catalog number' },
{ match: '#primary-type', label: 'Primary type' },
{ match: '#packaging', label: 'Packaging' },
{ match: '#status', label: 'Status' },
{ match: '#language', label: 'Language' },
{ match: '#script', label: 'Script' },
{ match: 'select[id^="country-"]', label: 'Country' },
{ match: 'input[id^="label-"]', label: 'Label', mbid: true },
{ match: '#ac-source-single-artist, input[id^="ac-source-artist-"]', label: 'Artist', mbid: true },
{ match: '.attribute-container.text.task input[type="text"]', label: 'Task' },
];
let SET = loadSet();
// seed the built-ins once (merge-in any not already present by selector; never re-adds after you delete one)
if (!SET.fieldsSeeded) { const have = new Set((SET.customFields || []).map(c => c && c.match)); SET.customFields = [...BUILTIN_FIELDS.filter(b => !have.has(b.match)).map(b => ({ ...b })), ...(SET.customFields || [])]; SET.fieldsSeeded = true; persistSet(); }
// keys are now derived from the label β drop any explicit `key` left over from older seeds / pasted JSON
if ((SET.customFields || []).some(c => c && c.key != null)) { SET.customFields.forEach(c => { if (c) delete c.key; }); persistSet(); }
let STORE = loadStore();
const dataKey = () => SET.scopePerResource ? SCOPE : GLOBAL_SCOPE;
let DATA;
function useScope() { // (re)point DATA at the current pool (called on load + when the setting toggles)
const k = dataKey();
DATA = STORE[k] || (STORE[k] = { saved: [], history: [] });
if (!Array.isArray(DATA.saved)) DATA.saved = [];
if (!Array.isArray(DATA.history)) DATA.history = [];
}
useScope();
const saveData = () => { try { GM_setValue(KEY, JSON.stringify(STORE)); } catch (e) {} render(); };
const updateScopeChips = () => document.querySelectorAll('.mmth-scope').forEach(c => { c.style.display = SET.scopePerResource ? '' : 'none'; });
const uid = () => 'n' + Math.random().toString(36).slice(2, 9);
const babyMammoths = createBabyMammoths(); // field-memory module (gated by SET.showBabies)
// ββ data ops βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function recordHistory(text) {
text = (text || '').trim(); if (!text) return;
DATA.history = DATA.history.filter(h => h.text !== text);
DATA.history.unshift({ text, ts: Date.now() });
DATA.history = DATA.history.slice(0, Math.max(1, Math.min(50, SET.historySize | 0 || 10)));
saveData();
}
function addSaved(text) {
text = (text || '').trim(); if (!text) return false;
if (DATA.saved.some(s => s.text === text)) return false;
DATA.saved.push({ id: uid(), text, ts: Date.now() });
saveData(); return true;
}
const removeSaved = id => { DATA.saved = DATA.saved.filter(s => s.id !== id); saveData(); };
const removeHistory = text => { DATA.history = DATA.history.filter(h => h.text !== text); saveData(); };
function reorder(srcId, tgtId, before) {
if (srcId === tgtId) return;
const a = DATA.saved, si = a.findIndex(s => s.id === srcId); if (si < 0) return;
const [it] = a.splice(si, 1);
let ti = a.findIndex(s => s.id === tgtId); if (ti < 0) { a.splice(si, 0, it); return; }
a.splice(before ? ti : ti + 1, 0, it); saveData();
}
// #304: scaling helpers for big note lists.
const togglePinNote = id => { const s = DATA.saved.find(x => x.id === id); if (s) { s.pinned = !s.pinned; saveData(); } };
// record that a saved note was used (drives the "Most used" / "Recent" sort)
function bumpUse(id) { const s = DATA.saved.find(x => x.id === id); if (!s) return; s.uses = (s.uses | 0) + 1; s.lastUsed = Date.now(); saveData(); }
// display order for the Saved list β pinned never reorder the list (they get their
// own quick-button bar); only the chosen sort mode reshuffles. Manual = stored order.
function sortedSaved() {
const a = DATA.saved.slice();
const mode = SET.noteSort || 'manual';
if (mode === 'uses') a.sort((x, y) => (y.uses | 0) - (x.uses | 0) || (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0));
else if (mode === 'recent') a.sort((x, y) => (y.lastUsed || y.ts || 0) - (x.lastUsed || x.ts || 0));
return a;
}
// #304: parse pasted notes β each line a note, or (byBlock) blank-line-separated
// blocks so multi-line notes survive. Blank lines are dropped either way, so a
// readable (blank-line) export round-trips fine in "1 note per line" mode too.
function parseNotes(text, byBlock) {
const parts = byBlock ? String(text || '').split(/\r?\n[ \t]*\r?\n/) : String(text || '').split(/\r?\n/);
return parts.map(s => s.replace(/^\s+|\s+$/g, '')).filter(Boolean);
}
// add parsed notes to the edit-note saved list (dedup). Returns the added count.
function addSavedNotes(notes) {
let added = 0; const have = new Set(DATA.saved.map(s => s.text));
for (const t of notes) { if (have.has(t)) continue; have.add(t); DATA.saved.push({ id: uid(), text: t, ts: Date.now() }); added++; }
if (added) saveData();
return added;
}
// ββ insert (React-safe + undoable) βββββββββββββββββββββββββββββββββββββββββββ
const NATIVE_SET = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value').set;
// Set the whole field value via the native edit pipeline so the change joins
// the browser's undo stack β ctrl/β+Z restores the previous note (#226).
// `execCommand` also fires a genuine `input` event, so the React-controlled
// edit-note field (release editor) still updates. Falls back to the native
// value setter + synthetic events if execCommand is unavailable or no-ops.
function setValue(ta, val) {
let ok = false;
try {
ta.focus();
ta.setSelectionRange(0, ta.value.length); // select all β replace as one undoable step
ok = val ? document.execCommand('insertText', false, val)
: document.execCommand('delete', false, null);
if (ok && ta.value !== val) ok = false; // some engines return true but no-op
} catch (e) { ok = false; }
if (!ok) {
NATIVE_SET.call(ta, val);
ta.dispatchEvent(new Event('input', { bubbles: true }));
}
ta.dispatchEvent(new Event('change', { bubbles: true }));
}
// #304: set when Ctrl/β+, un-minimized the panel just to reach search β so we
// re-minimize once a note is applied (or the search is cancelled).
let restoredFromMin = false;
const reMinIfRestored = () => { if (restoredFromMin) { restoredFromMin = false; setMinimized(true); } };
function applyNote(ta, text, replace) {
const cur = ta.value || '';
if (!replace && cur.trim()) {
// #212: don't append a note already in the field β match whole-field, a
// blank-line-separated block, or a single line (handles multi-line notes).
const norm = s => s.split('\n').map(l => l.replace(/[ \t]+/g, ' ').trim()).filter(Boolean).join('\n').trim();
const tN = norm(text);
const cands = [cur, ...cur.split(/\n{2,}/), ...cur.split('\n')].map(norm);
if (tN && cands.includes(tN)) { toast('Already in the note'); reMinIfRestored(); return; }
}
setValue(ta, (replace || !cur.trim()) ? text : cur.replace(/\s+$/, '') + (SET.appendNewline ? '\n\n' : '\n') + text);
ta.focus(); try { ta.setSelectionRange(ta.value.length, ta.value.length); } catch (e) {}
reMinIfRestored();
}
// Toggle `marker` around the selection (GitHub-style): if the text is already
// wrapped, strip the markers; otherwise add them. With no selection, act on the
// word the caret is in (or just insert/remove an empty pair).
function wrapSel(ta, marker) {
const v = ta.value;
const m = marker, ml = m.length;
let s = ta.selectionStart ?? v.length, e = ta.selectionEnd ?? s;
if (s === e) { let a = s, b = e; while (a > 0 && /\S/.test(v[a - 1])) a--; while (b < v.length && /\S/.test(v[b])) b++; if (b > a) { s = a; e = b; } }
const sel = v.slice(s, e);
const reselect = (from, len) => { ta.focus(); try { ta.setSelectionRange(from, from + len); } catch (x) {} };
// #326: already wrapped β markers INSIDE the selection β unwrap
if (sel.length >= 2 * ml && sel.startsWith(m) && sel.endsWith(m)) {
const inner = sel.slice(ml, sel.length - ml);
setValue(ta, v.slice(0, s) + inner + v.slice(e));
reselect(s, inner.length);
return;
}
// #326: already wrapped β markers just OUTSIDE the selection β unwrap
if (v.slice(s - ml, s) === m && v.slice(e, e + ml) === m) {
setValue(ta, v.slice(0, s - ml) + sel + v.slice(e + ml));
reselect(s - ml, sel.length);
return;
}
// otherwise wrap
setValue(ta, v.slice(0, s) + m + sel + m + v.slice(e));
if (sel) reselect(s + ml, sel.length);
else { const caret = s + ml; ta.focus(); try { ta.setSelectionRange(caret, caret); } catch (x) {} }
}
// ββ capture on submit ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
const captureNote = () => document.querySelectorAll('textarea.edit-note').forEach(ta => recordHistory(ta.value));
document.addEventListener('submit', captureNote, true);
document.addEventListener('click', e => {
const b = e.target.closest && e.target.closest('button, input[type="submit"]'); if (!b) return;
if (b.closest('.mmth-side, .mmth-pop')) return; // our own buttons aren't edit submits
const t = (b.textContent || b.value || '').trim().toLowerCase();
if (b.id === 'enter-edit' || /^(enter edit|submit|add edit|save)/.test(t) || (b.classList && b.classList.contains('submit'))) captureNote();
}, true);
// ββ 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{margin-left:10px;padding:2px 9px;border:1px solid currentColor;border-radius:5px;background:transparent;color:inherit;font:inherit;cursor:pointer}#mbu-toast .mbu-toast-btn:hover{background:rgba(255,255,255,.18)}#mbu-toast.mbu-toast-ok{background:var(--mbu-ok)}#mbu-toast.mbu-toast-warn{background:var(--mbu-warn)}#mbu-toast.mbu-toast-error{background:var(--mbu-error)}.mbu-cfg-h{display:flex;align-items:center;gap:8px;margin:0 0 10px;padding:0 0 9px;border-bottom:1px solid var(--mbu-border-soft);font:600 15px/1.3 var(--mbu-font);color:var(--mbu-text)}.mbu-cfg-ic{flex:0 0 auto;display:inline-flex;align-items:center;width:22px;height:22px}.mbu-cfg-ic img,.mbu-cfg-ic svg{width:22px;height:22px;object-fit:contain;display:block}.mbu-cfg-name{flex:0 0 auto;font-weight:700;color:var(--mbu-accent-text)}.mbu-cfg-ver{flex:0 0 auto;font:400 11px var(--mbu-font);color:var(--mbu-text-weak);white-space:nowrap}.mbu-cfg-sp{flex:1 1 auto;min-width:8px}.mbu-cfg-log{flex:0 0 auto;font:400 12px var(--mbu-font);color:var(--mbu-accent-text);cursor:pointer;background:none;border:1px solid transparent;border-radius:var(--mbu-radius);padding:1px 8px;line-height:1.6}.mbu-cfg-log:hover{background:var(--mbu-bg-hover);border-color:var(--mbu-border)}#mbu-logpop{position:fixed;top:74px;left:50%;transform:translateX(-50%);z-index:var(--mbu-z-modal);display:flex;flex-direction:column;width:min(720px,94vw);max-height:72vh;background:var(--mbu-bg);border:1px solid var(--mbu-border);border-radius:11px;box-shadow:var(--mbu-shadow-lg);font:13px var(--mbu-font);color:var(--mbu-text);overflow:hidden}.mbu-logpop-h{display:flex;align-items:center;gap:8px;padding:10px 13px;border-bottom:1px solid var(--mbu-border-soft);color:var(--mbu-accent-text);cursor:move;user-select:none}.mbu-logpop-sp{margin-left:auto}.mbu-logpop-clear,.mbu-logpop-copy,.mbu-logpop-x,.mbu-logpop-min{font-size:12px;color:var(--mbu-accent-text);background:var(--mbu-bg-hover);border:1px solid var(--mbu-border);border-radius:5px;padding:2px 9px;cursor:pointer;font-family:inherit}.mbu-logpop-clear:hover,.mbu-logpop-copy:hover,.mbu-logpop-x:hover,.mbu-logpop-min:hover{background:var(--mbu-accent-soft)}#mbu-logpop.min .mbu-log-list,#mbu-logpop.min .mbu-logpop-clear,#mbu-logpop.min .mbu-logpop-copy,#mbu-logpop.min .mbu-logpop-x{display:none}#mbu-logpop.min{max-height:none;width:auto}#mbu-logpop.min .mbu-logpop-sp{display:none}.mbu-log-badge{color:var(--mbu-border-strong);font-size:11px}.mbu-log-list{flex:1 1 auto;overflow:auto;overscroll-behavior:contain;padding:9px 13px;display:flex;flex-direction:column;gap:3px}.mbu-log-li{display:flex;gap:9px;white-space:pre-wrap;word-break:break-word}.mbu-log-t{color:var(--mbu-text-weak);flex:0 0 auto;font-variant-numeric:tabular-nums}.mbu-log-m{flex:1 1 auto;color:var(--mbu-text-dim)}#mbu-logpop .mbu-log-m a{color:var(--mbu-accent-text)}.mbu-log-ok .mbu-log-m{color:var(--mbu-ok)}.mbu-log-warn .mbu-log-m{color:var(--mbu-warn)}.mbu-log-error .mbu-log-m{color:var(--mbu-error)}.mbu-log-debug{opacity:.85}.mbu-log-debug .mbu-log-m{color:var(--mbu-text-weak)}.mbu-log-empty{color:var(--mbu-text-weak)}.mbu-ov{position:fixed;inset:0;z-index:var(--mbu-z-modal);background:rgba(15,12,28,.45);display:flex;align-items:center;justify-content:center;padding:24px}.mbu-ov-panel{background:var(--mbu-bg);color:var(--mbu-text);border-radius:var(--mbu-radius-lg);box-shadow:var(--mbu-shadow-lg);max-width:94vw;max-height:88vh;display:flex;flex-direction:column;overflow:hidden}.mbu-ov-h{display:flex;align-items:center;gap:10px;padding:12px 16px;border-bottom:1px solid var(--mbu-border-soft);font-weight:700}.mbu-ov-h .mbu-ov-title{flex:1 1 auto;min-width:0}.mbu-ov-x{flex:0 0 auto;width:26px;height:26px;display:inline-flex;align-items:center;justify-content:center;font-size:15px;line-height:1;cursor:pointer;color:var(--mbu-text-dim);background:none;border:none;border-radius:var(--mbu-radius)}.mbu-ov-x:hover{background:var(--mbu-bg-hover);color:var(--mbu-text)}.mbu-ov-body{flex:1 1 auto;overflow:auto;padding:14px 16px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) ::placeholder{color:var(--mbu-text-weak);opacity:1;font-style:italic}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :is(table,td,th,div,span,label)[style*=background]{color:var(--mbu-text)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:not(:where([type=checkbox],[type=radio],[type=range],[type=color],[type=file])),:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select{background:var(--mbu-bg-sunken);color:var(--mbu-text);border-color:var(--mbu-border)}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) input:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) textarea:focus-visible,:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) select:focus-visible{outline:2px solid var(--mbu-accent);outline-offset:1px}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) :where(input[type=checkbox],input[type=radio],input[type=range]){accent-color:var(--mbu-accent)}:root[data-mbu-theme=dark] :where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu){color-scheme:dark;--invert-value:none;--invert:none}:where(.mbu-ov,.mbu-ui,#mbu-logpop,.discogs-bar,.discogs-review-panel-li,#as-root,#as-setup,.as-pop,#as-switch-wrap,#ii-btn,.fs-launch,#tc-bar,#tc-nav-bar,#tc-settings,#tc-anno-wrap,.tc-panel,.tc-toolcfg,.tc-acpop,.tc-recpop,.tc-lppop,.tc-tpppop,.tc-tpp-mpop,.tc-anno-help-pop,.tc-mirror,.tc-addrow,.tc-medopts,.tc-tools,#tc-recwrap,#tc-ri-toolbar,.tc-fmt-flat,.gt-toolbar,.gt-cons,.gt-menu,.gt-pop,.gt-cfg-pop,.gt-wm-pop,#ii-modal,#ii-sxpanel,#mb-pc-panel,#mb-provider-modal-card,.fs-cons,#fs-settings,.fs-overlay,.mmth-pop,.mmth-cfg,.mmth-side,.mmth-pinbar,.mmthf-pop,.mmthf-bar,#falcon-panel,#falcon-launcher,#falcon-item-popup,#falcon-add-page,.falcon-bar,.falcon-addmenu) button{background-color:var(--mbu-bg-raised);color:var(--mbu-text);border-color:var(--mbu-border)}.mbu-compact .mbu-bt{display:none}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input){-webkit-appearance:none;-moz-appearance:none;appearance:none;width:18px;height:18px;margin:0;border:none;border-radius:3px;cursor:pointer;background:transparent url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23888%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E") center/13px no-repeat;opacity:.45;box-shadow:none;flex:0 0 auto;vertical-align:middle}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):hover{opacity:1}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):checked{opacity:1;background-color:var(--mbu-accent);background-image:url("data:image/svg+xml,%3Csvg%20xmlns=%27http://www.w3.org/2000/svg%27%20viewBox=%270%200%2016%2016%27%20fill=%27%23fff%27%3E%3Crect%20x=%271%27%20y=%273.5%27%20width=%2710%27%20height=%279%27%20rx=%271.5%27/%3E%3Cpath%20d=%27M11.5%207L15%204.8v6.4L11.5%209z%27/%3E%3C/svg%3E")}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):focus-visible{outline:1px solid var(--mbu-accent);outline-offset:1px}:is(.mbu-video,body.tc-ri-on #external-links-editor tr.relationship-item .attribute-container input):disabled{cursor:default;opacity:.3}';
// Help link markup. Every script's help link is this, pointing at its own README.
// `name` is the userscript folder, e.g. mbuHelpHref('art_station').
function mbuHelpHref(name) {
return 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/' + name + '/README.md';
}
function mbuHelpHtml(name, label) {
return '' + (label || '? Help') + '';
}
function mbuHelpEl(name, label) {
var a = document.createElement('a');
a.className = 'mbu-help';
a.href = mbuHelpHref(name);
a.target = '_blank';
a.rel = 'noopener';
a.title = 'open the README in a new tab';
a.textContent = label || '? Help';
return a;
}
// The config (settings) icon every script's settings button shows: the gear in text
// presentation (U+FE0E), so it takes the button's colour instead of an emoji's. #650
var MBU_CFG_ICON = '\u2699\uFE0E';
// HTML for an innerHTML on a page that enforces Trusted Types (YouTube Music, #650): there a
// plain string is refused ("This document requires 'TrustedHTML' assignment"). The policy
// passes the string through; the markup is the script's own. Elsewhere it is the string.
var _mbuTT;
function mbuHtml(s) {
if (_mbuTT === undefined) {
_mbuTT = null;
try {
var tt = (typeof window !== 'undefined' && window.trustedTypes) || null;
if (tt && tt.createPolicy) _mbuTT = tt.createPolicy('mbu-' + Math.random().toString(36).slice(2, 8), { createHTML: function (x) { return x; } });
} catch (e) { /* the page allows no new policy: plain strings, as before */ }
}
return _mbuTT ? _mbuTT.createHTML(String(s)) : String(s);
}
// The first line of every script's log: the script, its version, and what runs it, so a
// pasted log says which manager and browser it came from (#282 started it in Art Station):
// Log.info(mbuStartupInfo('Fusion'));
// -> Fusion v2026.10.1 Β· Violentmonkey 2.31.0 Β· firefox 143.0 (win)
// Inside String Theory GM_info describes the bundle, so the line names it:
// -> Fusion (String Theory v2026.10.1) Β· Tampermonkey 5.3.3 Β· chrome 140.0 (win)
function mbuStartupInfo(name) {
var g = null;
try { g = (typeof GM_info !== 'undefined' && GM_info) || null; } catch (e) { /* no GM_info */ }
var s = (g && g.script) || {}, p = (g && g.platform) || {};
var host = String(s.name || '').replace(/\*$/, ''), ver = s.version || '?';
var line = !name ? (host || 'Script') + ' v' + ver
: (host && host !== name) ? name + ' (' + host + ' v' + ver + ')'
: name + ' v' + ver;
if (g) line += ' Β· ' + (g.scriptHandler || 'unknown manager') + (g.version ? ' ' + g.version : '');
if (p.browserName) line += ' Β· ' + p.browserName + (p.browserVersion ? ' ' + p.browserVersion : '') + (p.os ? ' (' + p.os + ')' : '');
else { try { line += ' Β· ' + navigator.userAgent; } catch (e) { /* no navigator */ } }
return line;
}
// One copy per page (#653). With String Theory and a standalone install of the same script
// both on, two copies build the same element ids and fight over them: each settings window
// fills in the other's checkboxes, rows flip between two rule sets. So one copy runs, the
// one with the higher version, and the other stays off without a word; only the running
// copy notes it in its log (majkinetor: "disable copy that has lower version without any
// info (except in log of active copy)"):
// if (!mbuClaim('platform_check', 'Platform Check')) return; // first line of the script
// The claim is a data- attribute on , which every copy sees whatever its sandbox.
// It is decided at once, so no script starts late. The copy that starts first takes the
// page. When it is the older one, the newer copy stays off for this page and leaves a note
// in localStorage, and from the next page load the older copy finds the note and steps
// aside. A note whose copy has gone (uninstalled) is cleared by the older copy after the
// page loads, so it runs again from the load after.
function mbuClaimVer(v) {
return String(v || '').split('.').map(function (n) { return parseInt(n, 10) || 0; });
}
function mbuClaimCmp(a, b) {
var x = mbuClaimVer(a), y = mbuClaimVer(b);
for (var i = 0; i < Math.max(x.length, y.length); i++) { var d = (x[i] || 0) - (y[i] || 0); if (d) return d < 0 ? -1 : 1; }
return 0;
}
function mbuClaim(key, label) {
var info = (typeof GM_info !== 'undefined' && GM_info && GM_info.script) || {};
var name = String(info.name || label || key), ver = String(info.version || '0');
var mine = (name.slice(-1) === '*' ? 'String Theory' : 'standalone') + ' v' + ver;
var root = document.documentElement, attr = 'data-mbu-run-' + key, ev = 'mbu-claim-' + key, noteKey = 'mbu-newer-' + key;
var log = function (msg) { try { if (typeof mbuLog !== 'undefined' && mbuLog.active) mbuLog.active.info(msg); else if (typeof mbuToast !== 'undefined' && typeof mbuToast.log === 'function') mbuToast.log('info', msg); else console.info('[' + (label || key) + '] ' + msg); } catch (e) { /* no log */ } };
var note = null;
try { note = JSON.parse(localStorage.getItem(noteKey) || 'null'); } catch (e) { /* storage blocked */ }
if (note && mbuClaimCmp(note.ver, ver) <= 0) { try { localStorage.removeItem(noteKey); } catch (e) { /* storage blocked */ } 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:'
const css = MBU_TOKENS + MBU_UI_CSS + `
fieldset.editnote, .editnote { max-width:100% !important; }
/* On the release editor the edit note sits in a 540px .half-width column whose
only sibling is the changes warning (not a guidelines column), so give that
column the full form width when Mammoth is active. The :has() selector scopes
it to our column. min-width:0 lets the editnote fieldset (min-content by
default) take that width so margin:auto can center it.
SCOPED to the release editor (body.mmth-reledit): on entity-creation/edit
pages (artist/label/β¦ /create, /edit) the .editnote sits in a genuine
half-width column beside the guidelines, and widening it to 100% broke that
two-column layout β visibly so alongside scripts that write into it (#268). */
.mmth-reledit .half-width:has(> .editnote.mmth-on), .mmth-reledit .col:has(> .editnote.mmth-on) { width:100% !important; max-width:100% !important; }
.editnote.mmth-on { width:100% !important; max-width:100% !important; min-width:0 !important; box-sizing:border-box; }
.editnote.mmth-on > .row { width:100% !important; box-sizing:border-box; }
/* hide only the redundant inline "Edit note:" label next to the field β keep
the section header (the fieldset's legend) visible (#212). */
.editnote.mmth-on > .row > label[for] { display:none !important; }
/* align-items:flex-start (not stretch) so the panel keeps its own bounded
height. Stretch made the panel grow to match the field, and the #229 floor
(field min-height = panel height) then fed back through it β each pass added
the field's padding/border, inflating both without bound (#245). The field is
still floored to the panel via JS, so it's never shorter. */
.mmth-wrap { display:flex; gap:0; align-items:flex-start; width:100%; max-width:1040px; margin:6px auto; box-sizing:border-box; position:relative; }
/* #304: field column = textarea + pinned-quick-buttons bar, stacked */
.mmth-fieldcol { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; }
.mmth-fieldcol > textarea.edit-note { width:100% !important; min-width:0; box-sizing:border-box; }
/* #288/#290: foreign edit-note error/warning
s that MB (and other scripts)
insert next to the textarea are RELOCATED out of the flex row by JS (see
relocateForeign) so they never sit beside the field. No flex-wrap here β that
made the panel itself wrap below the field in a narrow column (#290). */
/* Minimized mode (#265): the panel collapses to a small Mammoth badge in the
field's top-right corner; the field takes the full width and the panel floats
in only on hover. No width/height coupling, so it can't feed the #245 loop. */
.mmth-min .mmth-vsep { display:none !important; }
.mmth-min > .mmth-side { position:absolute; top:30px; right:2px; z-index:60; display:none;
box-shadow:0 8px 26px rgba(20,50,35,.22); }
.mmth-min > .mmth-side.mmth-open { display:flex; }
.mmth-badge { display:none; position:absolute; top:4px; right:5px; z-index:61; width:25px; height:25px;
align-items:center; justify-content:center; cursor:pointer; border:1px solid var(--mbu-border);
border-radius:7px; background:var(--mbu-bg); box-shadow:0 1px 3px rgba(0,0,0,.12);
font-size:15px; line-height:1; user-select:none; }
.mmth-badge:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); }
.mmth-min > .mmth-badge { display:flex; }
.mmth-vsep { flex:none; width:9px; align-self:flex-start; cursor:col-resize; position:relative; } /* #304: height synced to the field (not the field+pinbar column) */
.mmth-vsep::before { content:''; position:absolute; left:4px; top:0; bottom:0; width:1px; background:var(--mbu-bg-sunken); }
.mmth-vsep:hover::before, .mmth-vsep.mmth-dragv::before { background:#5aa67e; width:3px; left:3px; }
.mmth-hidehelp > p { display:none !important; }
.mmth-side { flex:0 0 300px; max-width:300px; display:flex; flex-direction:column; border:1px solid var(--mbu-border);
border-radius:8px; background:var(--mbu-bg); font:12px/1.35 -apple-system,Segoe UI,Arial,sans-serif; overflow:hidden; }
.mmth-ft { display:flex; align-items:center; gap:2px; padding:3px 5px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg-raised); }
.mmth-fb { cursor:pointer; border:none; background:none; font-size:13px; line-height:1; padding:3px 6px; border-radius:5px; color:var(--mbu-text-dim); }
.mmth-fb:hover { background:var(--mbu-ok-bg); }
.mmth-fb.on { background:var(--mbu-ok-bg); color:var(--mbu-ok); }
.mmth-fb.mmth-spacer { flex:1; pointer-events:none; }
.mmth-fb.mmth-grp { margin-left:10px; }
/* #309: per-type scope indicator (release / artist / recording / β¦) */
.mmth-scope { align-self:center; flex:none; font-size:10px; color:var(--mbu-text-dim); background:var(--mbu-bg-raised); border:1px solid var(--mbu-border); border-radius:9px; padding:1px 7px; margin-right:4px; white-space:nowrap; max-width:72px; overflow:hidden; text-overflow:ellipsis; }
.mmth-ft { flex-wrap:wrap; } /* #304: never clip toolbar buttons β wrap as a last resort below the min width */
/* #304: opt-in search row (search box + count) between the toolbar and the list */
.mmth-filterrow { display:flex; align-items:center; gap:5px; padding:3px 5px; border-bottom:1px solid var(--mbu-border); background:var(--mbu-bg); }
/* width:auto !important defends against MB's form CSS (#content input), which
otherwise forces a fixed width and squashes the flex layout (#304) */
/* display:block !important beats MB's ".add-edit-note input { display:none }" (which
otherwise hides our search box on /edit and /edits pages); width:auto !important beats
MB's fixed input width. #304 */
.mmth-filter { display:block !important; flex:1 1 auto; min-width:0; width:auto !important; box-sizing:border-box; border:1px solid var(--mbu-border); border-radius:5px; padding:2px 6px; font:12px -apple-system,Segoe UI,Arial,sans-serif; }
.mmth-filter:focus { outline:none; border-color:var(--mbu-ok); }
.mmth-count { flex:none; font-size:11px; color:var(--mbu-text-weak); white-space:nowrap; }
/* #304: pinned saved notes as quick-insert buttons BELOW the field (like baby-field bars) */
:root { --mmth-fur:#7a4a1f; --mmth-tusk:#efe7d2; }
:root[data-mbu-theme="dark"] { --mmth-fur:#c98a44; --mmth-tusk:#f6efdd; }
.mmth-pinbar { display:flex; flex-wrap:wrap; gap:5px; margin:5px 0 2px; }
.mmth-segb { border:1px solid var(--mbu-border) !important; background:var(--mbu-bg); border-radius:7px; padding:3px 10px !important; font:12px/1.2 -apple-system,Segoe UI,Arial,sans-serif !important; color:var(--mbu-ok); cursor:pointer; max-width:200px; height:auto !important; min-height:0 !important; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; box-shadow:0 1px 2px rgba(0,0,0,.06); }
.mmth-segb:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); }
.mmth-row.mmth-pinned .mmth-txt::before { content:'β '; color:var(--mbu-warn); margin-right:4px; font-size:10px; vertical-align:1px; }
/* #304: tabbed config window (Settings / Import-Export) */
.mmth-cfgtabs { display:flex; gap:4px; margin:0 0 8px; border-bottom:1px solid var(--mbu-border); }
.mmth-cfgtab { border:none; background:none; padding:4px 9px; font-size:12px; color:var(--mbu-text-dim); cursor:pointer; border-bottom:2px solid transparent; margin-bottom:-1px; }
.mmth-cfgtab:hover { color:var(--mbu-ok); }
.mmth-cfgtab.on { color:var(--mbu-ok); border-bottom-color:var(--mbu-ok); font-weight:600; }
.mmth-cfgsec { font-weight:600; font-size:11px; color:var(--mbu-text-dim); text-transform:uppercase; letter-spacing:.04em; margin:10px 0 4px; padding-bottom:2px; border-bottom:1px solid var(--mbu-border); }
.mmth-cfgpane > .mmth-cfgsec:first-child { margin-top:2px; }
/* custom baby fields (config) */
/* Custom fields β flat, borderless grid */
.mmth-cf-top { display:flex; align-items:center; gap:6px; margin:2px 0 8px; }
.mmth-cf-total { margin-left:auto; font-size:11px; color:var(--mbu-text-dim); white-space:nowrap; }
.mmth-cf-total b { font-weight:700; color:var(--mbu-ok); }
.mmth-cf-total .mmth-cf-off-n { color:var(--mbu-warn); }
.mmth-cf-list { display:flex; flex-direction:column; gap:0; margin:0 0 4px; max-height:46vh; overflow-y:auto; overflow-x:hidden; }
.mmth-cf-list::-webkit-scrollbar { width:8px; }
.mmth-cf-list::-webkit-scrollbar-thumb { background:var(--mbu-bg-sunken); border-radius:4px; }
.mmth-cf-row { display:flex; align-items:center; gap:5px; flex-wrap:nowrap; padding:0 3px; border-radius:5px; }
.mmth-cf-row:hover { background:var(--mbu-bg-raised); }
.mmth-cf-in { border:none; border-bottom:1px solid var(--mbu-border); border-radius:0; background:transparent; padding:2px 3px; font:11.5px -apple-system,Segoe UI,Arial,sans-serif; box-sizing:border-box; min-width:0; }
.mmth-cf-in:focus { outline:none; border-bottom-color:var(--mbu-ok); }
.mmth-cf-in::placeholder { color:var(--mbu-text-weak); }
.mmth-cf-match { flex:1 1 auto; min-width:90px; }
.mmth-cf-in[type=number] { border:none; border-bottom:1px solid var(--mbu-border); -moz-appearance:textfield; text-align:center; } /* -moz-appearance:textfield hides FF spinners; keep the flat border (appearance:textfield re-added a native box in Chromium) */
.mmth-cf-in[type=number]::-webkit-inner-spin-button, .mmth-cf-in[type=number]::-webkit-outer-spin-button { -webkit-appearance:none; margin:0; }
.mmth-cf-sub { display:inline-flex; align-items:center; gap:3px; font-size:11px; color:var(--mbu-text-weak); white-space:nowrap; cursor:pointer; }
/* matches count β a compact green pill; grey when 0, red when the selector is invalid */
.mmth-cf-cnt { min-width:18px; text-align:center; font:700 10px/16px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); background:var(--mbu-ok-bg); border-radius:9px; padding:0 5px; }
.mmth-cf-cnt:empty { background:none; }
.mmth-cf-cnt.mmth-cf-zero { color:var(--mbu-text-weak); background:var(--mbu-bg-raised); }
.mmth-cf-cnt.mmth-cf-bad { color:var(--mbu-text-on-accent); background:#c0392b; }
.mmth-cf-del { border:none; background:none; cursor:pointer; font-size:13px; padding:2px 4px; border-radius:4px; visibility:hidden; }
.mmth-cf-row:hover .mmth-cf-del { visibility:visible; }
.mmth-cf-del:hover { background:var(--mbu-error-bg); }
/* enable/disable toggle β shown on row hover, and always on a disabled row so it can be switched back */
.mmth-cf-tog { border:none; background:none; cursor:pointer; font-size:11px; line-height:1; padding:2px 3px; border-radius:4px; color:var(--mbu-ok); visibility:hidden; }
.mmth-cf-tog:hover { background:var(--mbu-ok-bg); }
.mmth-cf-row:hover .mmth-cf-tog, .mmth-cf-row.mmth-cf-off .mmth-cf-tog { visibility:visible; }
.mmth-cf-row.mmth-cf-off { background:var(--mbu-bg); }
.mmth-cf-row.mmth-cf-off .mmth-cf-in, .mmth-cf-row.mmth-cf-off .mmth-cf-cnt, .mmth-cf-row.mmth-cf-off .mmth-cf-sub { opacity:.4; }
.mmth-cf-row.mmth-cf-off .mmth-cf-tog { color:var(--mbu-text-weak); }
.mmth-cf-add { border:1px dashed var(--mbu-border); background:var(--mbu-bg); border-radius:var(--mbu-radius); padding:5px 12px; cursor:pointer; color:var(--mbu-ok); font-size:12px;font-family:inherit; }
.mmth-cf-add:hover { background:var(--mbu-ok-bg); }
.mmth-cf-empty { color:var(--mbu-text-weak); font-style:italic; font-size:12px; padding:2px 0; }
.mmth-cf-reset { margin-left:6px; border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 9px; font:11px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); cursor:pointer; text-transform:none; letter-spacing:normal; }
.mmth-cf-reset:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); }
.mmth-cf-mode { border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 9px; font:11px -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-ok); cursor:pointer; text-transform:none; letter-spacing:normal; }
.mmth-cf-mode:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); }
.mmth-cf-json { width:100%; box-sizing:border-box; min-height:150px; font:12px/1.4 ui-monospace,Consolas,monospace; border:1px solid var(--mbu-border); border-radius:var(--mbu-radius); padding:7px; resize:vertical; }
.mmth-cf-json:focus { outline:none; border-color:var(--mbu-ok); }
.mmth-cf-jsonrow { display:flex; align-items:center; gap:9px; margin-top:6px; }
.mmth-cf-jsonapply { border:1px dashed var(--mbu-border); background:var(--mbu-bg); border-radius:var(--mbu-radius); padding:5px 12px; cursor:pointer; color:var(--mbu-ok); font-size:12px;font-family:inherit; }
.mmth-cf-jsonapply:hover { background:var(--mbu-ok-bg); }
.mmth-cf-jsonmsg { font-size:11px; color:var(--mbu-text-weak); }
.mmth-cf-jsonmsg.mmth-cf-bad { color:var(--mbu-error); }
/* #304: import/export pane (the pane IS the flex column β no inner .mmth-io wrapper) */
.mmth-cfgpane[data-pane="io"] { display:flex; flex-direction:column; gap:8px; }
.mmth-io-modes { display:flex; flex-flow:row wrap; gap:6px 18px; font-size:12px; align-items:center; }
.mmth-io-modes label { margin:0; display:inline-flex; align-items:center; gap:6px; }
/* width/height !important to beat MB's #content textarea form CSS (#304) */
.mmth-io-ta { width:100% !important; box-sizing:border-box; height:160px !important; min-height:120px; resize:vertical; border:1px solid var(--mbu-border); border-radius:5px; padding:6px 8px; font:13px/1.55 -apple-system,Segoe UI,Arial,sans-serif; }
.mmth-io-row { display:flex; align-items:center; gap:6px; flex-wrap:wrap; }
.mmth-io-btn { cursor:pointer; border:1px solid var(--mbu-border); background:var(--mbu-bg); border-radius:5px; padding:2px 8px; font-size:12px; color:var(--mbu-ok); }
.mmth-io-btn:hover { background:var(--mbu-ok-bg); border-color:var(--mbu-ok); }
.mmth-io-msg { font-size:11px; color:var(--mbu-text-weak); }
.mmth-list { flex:1 1 auto; overflow-y:auto; scrollbar-width:none; }
.mmth-list::-webkit-scrollbar { width:0; height:0; }
.mmth-row { display:flex; align-items:center; gap:4px; padding:4px 6px; border-top:1px solid var(--mbu-border); cursor:pointer; }
.mmth-row:first-child { border-top:none; }
.mmth-row:hover { background:var(--mbu-ok-bg); }
.mmth-row.mmth-cyc { background:var(--mbu-ok-bg); }
.mmth-row.mmth-drop-before { box-shadow:inset 0 2px 0 #2c7a51; }
.mmth-row.mmth-drop-after { box-shadow:inset 0 -2px 0 #2c7a51; }
.mmth-row.mmth-dragging { opacity:.45; }
.mmth-grab { flex:none; cursor:grab; color:var(--mbu-text-weak); font-size:12px; user-select:none; opacity:0; }
.mmth-row:hover .mmth-grab { opacity:1; }
.mmth-grab:active { cursor:grabbing; }
.mmth-txt { flex:1 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; color:var(--mbu-text); }
.mmth-rowacts { flex:none; display:flex; gap:1px; opacity:0; }
.mmth-row:hover .mmth-rowacts { opacity:1; }
.mmth-ra { cursor:pointer; border:none; background:none; color:var(--mbu-text-dim); font-size:11px; line-height:1; padding:1px 2px; border-radius:3px; }
.mmth-ra:hover { background:var(--mbu-ok-bg); color:var(--mbu-ok); }
.mmth-empty { padding:12px 8px; color:var(--mbu-text-weak); font-style:italic; text-align:center; }
.mmth-pop { position:fixed; z-index:2147483647; background:var(--mbu-bg); border:1px solid var(--mbu-border); border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); /* max β the config/syntax popup must clear any host modal (GT/ISRC live near max) */
padding:10px 12px; font:13px/1.45 -apple-system,Segoe UI,Arial,sans-serif; color:var(--mbu-text); width:280px; }
.mmth-cfg { width:360px; max-height:90vh; overflow:hidden; } /* #304 wider config window; cap height β the Babies list scrolls internally */
.mmth-cfg.mmth-cfg-wide { width:min(660px,94vw); } /* Fields tab: roomier so each custom-field row fits one line */
.mmth-pop h4 { margin:-10px -12px 8px; padding:6px 10px; font-size:13px; display:flex; align-items:center; gap:6px; background:var(--mbu-bg-raised); border-bottom:1px solid var(--mbu-border); border-radius:8px 8px 0 0; }
.mmth-tip { color:var(--mbu-text-weak); font-size:11px; margin:0 0 4px 22px; }
.mmth-pop h4 .mmth-ver { color:var(--mbu-text-weak); font-weight:400; font-size:11px; }
.mmth-pop h4 a { margin-left:auto; font-size:11px; color:var(--mbu-ok); text-decoration:none; font-weight:600; }
.mmth-pop h4 .mmth-h4ic { display:inline-flex; width:18px; height:18px; flex:none; } /* #308 vector mammoth */
.mmth-badge svg { width:19px; height:19px; } /* #308 vector mammoth */
.mmth-pop label { display:flex; align-items:center; gap:6px; margin:5px 0; cursor:pointer; }
.mmth-pop input[type="number"]:not(.mmth-cf-in) { width:46px; border:1px solid var(--mbu-border); border-radius:4px; padding:1px 4px; }
.mmth-pop select:not(.mmth-cf-in) { border:1px solid var(--mbu-border); border-radius:4px; padding:1px 4px; }
.mmth-pop code { background:var(--mbu-bg-raised); border-radius:3px; padding:0 3px; font-size:12px; }
.mmth-pop .mmth-syn { display:grid; grid-template-columns:auto 1fr; gap:3px 10px; margin:4px 0; }
.mmth-pop .mmth-sub { font-weight:600; font-size:12px; margin:8px 0 2px; }
/* While an MB dialog/popover is open, hide Mammoth's panel (incl. the minimized
badge) and its popovers β MB's dialogs carry no z-index, so our UI would float
on top of them. visibility keeps the docked panel's layout (no jump). #313
Uses mmthf-anydialog (set for ANY open dialog), not mmthf-dialog (blocking
only): a dialog that HOSTS babies is non-blocking so its own pins stay, but the
main panel/badge must still hide under it (else it floats over β #400). */
html.mmthf-anydialog .mmth-side, html.mmthf-anydialog .mmth-badge, html.mmthf-anydialog .mmth-pop { visibility:hidden !important; pointer-events:none !important; }
`;
(function () { const s = document.createElement('style'); s.textContent = css; (document.head || document.documentElement).appendChild(s); })();
// #268: only the release editor wants its edit-note .half-width column widened to
// full width. Tag it so the widening rule above is scoped to it and never disturbs
// the two-column layout of entity create/edit pages (artist, label, work, β¦).
if (/^\/release\/(?:add|[0-9a-f-]{36}\/edit)(?:[/?#]|$)/.test(location.pathname)) document.documentElement.classList.add('mmth-reledit');
// Show a toast near where the user is acting (the Mammoth panel / button they just
// clicked) instead of pinned to the top of the page, which reads as unrelated (#268
// follow-up). Falls back to top-centre when there's no recent Mammoth interaction.
let _toastPt = null;
document.addEventListener('pointerdown', e => { const t = e.target.closest && e.target.closest('.mmth-side, .mmth-pop, .mmth-wrap, .mmth-badge'); if (t) _toastPt = { x: e.clientX, y: e.clientY }; }, true);
// #563: the shared toast. Mammoth was the only script anchoring a toast to the
// click point, so that became the standard component's `at` option rather than
// being dropped β the 1.5s duration is kept too, it is deliberately quick.
function toast(msg) { return mbuToast(msg, { ms: 1500, at: _toastPt || undefined }); }
// #305: dismiss our popovers on the outside *click* (capture), NOT mousedown.
// Tearing the popover down on mousedown removed what was under the cursor, so the
// trailing mouseup/click fell through to whatever sat beneath it (e.g. Apollo's
// cover-art thumbnail under the field) and activated it β "cover art opened when
// selecting a label". Handling the click lets us swallow that exact event so the
// dismiss can never double as activating something below.
// ββ popovers (settings + syntax help) ββββββββββββββββββββββββββββββββββββββββ
let pop = null;
function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('click', onPopDown, true); document.removeEventListener('keydown', onPopKey, true); } }
function onPopDown(e) { if (pop && !pop.contains(e.target) && !e.target.closest('.mmth-pop-anchor')) { e.preventDefault(); e.stopPropagation(); closePop(); } }
function onPopKey(e) { if (pop && e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); closePop(); } } // #347: ESC closes the settings / syntax popup
function placePop(p, anchor) {
const W = p.offsetWidth, H = p.offsetHeight, vw = window.innerWidth, vh = window.innerHeight;
const r = anchor && anchor.getBoundingClientRect();
if (!r || (!r.width && !r.height)) {
// anchor gone/hidden (e.g. a baby pin that re-hid) β sensible centred fallback
p.style.left = Math.max(6, Math.round((vw - W) / 2)) + 'px'; p.style.top = '70px';
} else {
// #304: open just BELOW the trigger and clamp into the viewport, so the popover
// stays next to what opened it instead of jumping above (which clamped to the
// top-left corner when opened from a baby pin low on a tall page).
const left = Math.max(6, Math.min(vw - W - 6, r.left));
let top = r.bottom + 4; // below the trigger
if (top + H > vh - 6) { // would overflow the bottom
const above = r.top - H - 6; // try above if it fits, else clamp
top = above >= 6 ? above : (vh - H - 6);
}
// β¦and never below the viewport: an anchor already off screen (the page grew, or was
// scrolled, since it was clicked) put "above it" off screen too, out of reach.
top = Math.min(top, vh - H - 6);
p.style.left = left + 'px'; p.style.top = Math.max(6, top) + 'px';
}
setTimeout(() => { document.addEventListener('click', onPopDown, true); document.addEventListener('keydown', onPopKey, true); }, 0);
}
// #304: drag the popover by its header (so it can be moved out of the way).
function makeDraggable(popEl, handle) {
// A missing handle means "not draggable", not "throw". This threw for real:
// losing the drag affordance is a small thing, taking the rest of the caller
// down with it is not.
if (!handle) return;
handle.classList.add('mmth-draghandle');
let sx = 0, sy = 0, sl = 0, st = 0, on = false;
const move = e => {
if (!on) return;
let l = sl + (e.clientX - sx), t = st + (e.clientY - sy);
l = Math.max(6, Math.min(window.innerWidth - popEl.offsetWidth - 6, l));
t = Math.max(6, Math.min(window.innerHeight - popEl.offsetHeight - 6, t));
popEl.style.left = l + 'px'; popEl.style.top = t + 'px';
};
const up = () => { on = false; document.removeEventListener('mousemove', move, true); document.removeEventListener('mouseup', up, true); };
handle.addEventListener('mousedown', e => {
if (e.target.closest('a, button, input, select, textarea')) return; // don't hijack the Help link etc.
on = true; popEl.dataset.moved = '1'; const r = popEl.getBoundingClientRect(); sl = r.left; st = r.top; sx = e.clientX; sy = e.clientY;
e.preventDefault(); document.addEventListener('mousemove', move, true); document.addEventListener('mouseup', up, true);
});
}
// #304/#309: tabbed config window β Settings + Import / Export. `io` lets the
// caller scope import/export to a specific field (e.g. a baby field's values).
function openSettings(anchor, tab, io) {
closePop();
const p = document.createElement('div'); p.className = 'mmth-pop mmth-cfg';
p.innerHTML = `
${mbuCfgHeader({ script: 'mammoth', name: 'Mammoth', version: scriptVersion(), icon: MAMMOTH_SVG })}
Edit note settings
General
`;
document.body.appendChild(p); pop = p;
// #304: movable config window. The handle is the SHARED config header
// (.mbu-cfg-h) β #563 replaced the old
with it, and this line still asked
// for the
. It got null, makeDraggable threw on handle.classList, and
// every line of openSettings after this one β the tab wiring, all the option
// handlers, the close button β never ran. The window opened and did nothing.
makeDraggable(p, p.querySelector('.mbu-cfg-h, h4'));
// tab switching β only toggles the pane; position stays put (re-placing here made
// the window jump because the two tabs differ in height). #304
const tabs = [...p.querySelectorAll('.mmth-cfgtab')], panes = [...p.querySelectorAll('.mmth-cfgpane')];
// Fields (one row per field) and Import/Export (long "name " lines) need more horizontal room β
// widen the window on those tabs. Re-place only when the wide state actually flips, so it doesn't jump.
const showTab = name => {
const wasWide = p.classList.contains('mmth-cfg-wide');
tabs.forEach(t => t.classList.toggle('on', t.dataset.tab === name)); panes.forEach(pn => { pn.style.display = pn.dataset.pane === name ? '' : 'none'; });
const wide = name === 'fields' || name === 'io'; p.classList.toggle('mmth-cfg-wide', wide);
if (wide !== wasWide) placePop(p, anchor);
};
tabs.forEach(t => t.onclick = () => { showTab(t.dataset.tab); SET.lastTab = t.dataset.tab; persistSet(); }); // remember the tab across opens
// ββ Settings pane ββ
const scope = p.querySelector('.mmth-s-scope'); scope.checked = SET.scopePerResource === true;
scope.onchange = () => { SET.scopePerResource = scope.checked; persistSet(); useScope(); updateScopeChips(); render(); };
const help = p.querySelector('.mmth-s-help'); help.checked = !!SET.hideHelp;
const ins = p.querySelector('.mmth-s-ins'); ins.value = SET.defaultInsert;
const nl = p.querySelector('.mmth-s-nl'); nl.checked = SET.appendNewline !== false;
const sort = p.querySelector('.mmth-s-sort'); sort.value = SET.noteSort || 'manual';
const btnc = p.querySelector('.mmth-s-btnchars'); btnc.value = SET.btnChars || 24;
const rows = p.querySelector('.mmth-s-rows'); rows.value = SET.visibleRows;
const hist = p.querySelector('.mmth-s-hist'); hist.value = SET.historySize;
help.onchange = () => { SET.hideHelp = help.checked; saveSet(); };
ins.onchange = () => { SET.defaultInsert = ins.value; saveSet(); };
nl.onchange = () => { SET.appendNewline = nl.checked; saveSet(); };
sort.onchange = () => { SET.noteSort = sort.value; persistSet(); render(); };
btnc.onchange = () => { SET.btnChars = Math.max(4, Math.min(80, parseInt(btnc.value, 10) || 24)); btnc.value = SET.btnChars; saveSet(); babyMammoths.relabel(); };
rows.onchange = () => { SET.visibleRows = Math.max(1, Math.min(30, parseInt(rows.value, 10) || 6)); rows.value = SET.visibleRows; saveSet(); };
hist.onchange = () => { SET.historySize = Math.max(1, Math.min(50, parseInt(hist.value, 10) || 10)); hist.value = SET.historySize; saveSet(); recordHistory(''); };
const babies = p.querySelector('.mmth-s-babies'); babies.checked = SET.showBabies !== false;
babies.onchange = () => { SET.showBabies = babies.checked; persistSet(); babyMammoths.toggle(babies.checked); };
// ββ Fields pane ββ custom baby fields: a user-editable list of {match,label,key,dx,entity}. Persisted in
// SET; each edit re-scans the page (debounced) so pins appear/disappear live. Bad/empty selectors show a
// "bad selector" / match count so a typo is caught before it litters pins.
const cfList = p.querySelector('.mmth-cf-list'), cfTotal = p.querySelector('.mmth-cf-total');
const cfCount = sel => { try { return document.querySelectorAll(sel).length; } catch (e) { return -1; } };
let cfRefreshT = 0;
const cfApply = () => { persistSet(); clearTimeout(cfRefreshT); cfRefreshT = setTimeout(() => { try { babyMammoths.refresh(); } catch (e) {} }, 400); };
function renderFields() {
SET.customFields = SET.customFields || [];
cfList.textContent = '';
if (cfTotal) {
const n = SET.customFields.length, off = SET.customFields.filter(c => c && c.enable === false).length;
cfTotal.innerHTML = `${n} ${n === 1 ? 'field' : 'fields'}` + (off ? `, ${off} disabled` : '');
}
if (!SET.customFields.length) { const e = document.createElement('div'); e.className = 'mmth-cf-empty'; e.textContent = 'No custom fields yet.'; cfList.appendChild(e); }
SET.customFields.forEach((cf, i) => {
const row = document.createElement('div'); row.className = 'mmth-cf-row' + (cf.enable === false ? ' mmth-cf-off' : '');
const cnt = document.createElement('span'); cnt.className = 'mmth-cf-cnt';
const paint = () => { if (!cf.match) { cnt.textContent = ''; cnt.className = 'mmth-cf-cnt'; cnt.title = ''; return; } const n = cfCount(cf.match); cnt.textContent = n < 0 ? '!' : String(n); cnt.title = n < 0 ? 'invalid selector' : `matches ${n} field(s) now`; cnt.className = 'mmth-cf-cnt' + (n < 0 ? ' mmth-cf-bad' : n === 0 ? ' mmth-cf-zero' : ''); };
const mk = (ph, key, w, type) => {
const inp = document.createElement('input'); inp.type = type || 'text'; inp.placeholder = ph; inp.className = 'mmth-cf-in mmth-cf-' + key;
inp.value = cf[key] != null ? cf[key] : ''; if (w) inp.style.width = w;
inp.oninput = () => { cf[key] = type === 'number' ? (inp.value === '' ? '' : +inp.value) : inp.value; if (key === 'match') paint(); cfApply(); };
return inp;
};
const dvIn = mk('lvl', 'dv', '34px', 'number'); dvIn.title = 'Bar level (deltav) β attach the pinned-button bar in-flow after the Nth ancestor of the field (0 = floating; raise it so the bar pushes the UI below instead of overlapping)'; dvIn.min = '0';
const subCb = document.createElement('input'); subCb.type = 'checkbox'; subCb.checked = !!cf.submit; subCb.onchange = () => { cf.submit = subCb.checked; cfApply(); };
const subL = document.createElement('label'); subL.className = 'mmth-cf-sub'; subL.title = 'Submit the fieldβs form ~200ms after a value is recalled β commits a tag, runs a header search, etc. (like pressing Enter). Only enable it on fields whose form is safe to submit on recall.';
subL.append(subCb, Object.assign(document.createElement('span'), { textContent: 'β΅' }));
const tog = document.createElement('button'); tog.type = 'button'; tog.className = 'mmth-cf-tog'; const off0 = cf.enable === false; tog.textContent = off0 ? 'β' : 'β'; tog.title = off0 ? 'Field is off β click to enable' : 'Field is on β click to disable';
tog.onclick = () => { cf.enable = (cf.enable === false); renderFields(); cfApply(); };
const del = document.createElement('button'); del.type = 'button'; del.className = 'mmth-cf-del'; del.textContent = 'π'; del.title = 'Remove this field';
del.onclick = () => { SET.customFields.splice(i, 1); renderFields(); cfApply(); };
row.append(mk('CSS selector β comma-separate for several', 'match', '', 'text'), mk('Label', 'label', '84px'), mk('px', 'dx', '34px', 'number'), dvIn, subL, cnt, tog, del);
cfList.appendChild(row); paint();
});
}
p.querySelector('.mmth-cf-add').onclick = () => { SET.customFields = SET.customFields || []; SET.customFields.unshift({ match: '', label: '', dx: '', dv: '', submit: false }); renderFields(); const first = cfList.querySelector('.mmth-cf-row .mmth-cf-match'); if (first) first.focus(); };
p.querySelector('.mmth-cf-reset').onclick = () => { SET.customFields = SET.customFields || []; const have = new Set(SET.customFields.map(c => c && c.match)); const missing = BUILTIN_FIELDS.filter(b => !have.has(b.match)).map(b => ({ ...b })); if (missing.length) { SET.customFields = [...missing, ...SET.customFields]; renderFields(); cfApply(); } };
renderFields();
// JSON text mode β the same list as an editable/copy-pasteable JSON blob (friendly keys: selector /
// label / key / deltax). Doubles as export (copy the box) and import (paste + Apply).
const jsonTa = p.querySelector('.mmth-cf-json'), jsonMsg = p.querySelector('.mmth-cf-jsonmsg'), jsonRow = p.querySelector('.mmth-cf-jsonrow'), modeBtn = p.querySelector('.mmth-cf-mode'), addBtn = p.querySelector('.mmth-cf-add'), resetBtn = p.querySelector('.mmth-cf-reset');
const cfOut = cf => { const o = { selector: cf.match || '' }; if (cf.label) o.label = cf.label; if (cf.dx != null && cf.dx !== '') o.deltax = +cf.dx; if (cf.dv) o.deltav = +cf.dv; if (cf.submit) o.submit = true; if (cf.mbid) o.mbid = true; if (cf.enable === false) o.enable = false; return o; }; // key is derived from the label; `submit` submits the field's form after recall (row β΅ checkbox); `mbid` JSON-only; `enable:false` disables
const cfToJson = () => JSON.stringify((SET.customFields || []).map(cfOut), null, 2);
const cfFromJson = txt => {
const arr = JSON.parse(txt.replace(/,(\s*[}\]])/g, '$1')); // tolerate trailing commas
if (!Array.isArray(arr)) throw new Error('expected a JSON array');
return arr.filter(o => o && typeof o === 'object').map(o => ({
match: String(o.selector != null ? o.selector : (o.match || '')).trim(),
label: o.label ? String(o.label) : '',
dx: (o.deltax != null && o.deltax !== '') ? +o.deltax : ((o.dx != null && o.dx !== '') ? +o.dx : ''),
dv: (o.deltav != null && o.deltav !== '') ? +o.deltav : ((o.dv != null && o.dv !== '') ? +o.dv : ''),
submit: !!(o.submit || o.enter), mbid: !!o.mbid, enable: o.enable !== false, // legacy `enter` β submit
})).filter(c => c.match);
};
const applyJson = () => {
try { SET.customFields = cfFromJson(jsonTa.value); jsonMsg.textContent = `${SET.customFields.length} field(s) applied`; jsonMsg.classList.remove('mmth-cf-bad'); renderFields(); cfApply(); return true; }
catch (e) { jsonMsg.textContent = 'Invalid JSON: ' + (e.message || e); jsonMsg.classList.add('mmth-cf-bad'); return false; }
};
const setJsonMode = on => {
if (on) { const h = cfList.offsetHeight; if (h > 0) jsonTa.style.height = h + 'px'; } // match the visual list's height (measure while it's still shown)
cfList.style.display = addBtn.style.display = resetBtn.style.display = on ? 'none' : '';
jsonTa.style.display = jsonRow.style.display = on ? '' : 'none';
modeBtn.textContent = on ? 'β€ Visual' : '{ } JSON';
if (on) { jsonTa.value = cfToJson(); jsonMsg.textContent = ''; jsonMsg.classList.remove('mmth-cf-bad'); }
};
modeBtn.onclick = () => { const toJson = modeBtn.textContent.indexOf('JSON') >= 0; if (toJson) setJsonMode(true); else if (applyJson()) setJsonMode(false); };
p.querySelector('.mmth-cf-jsonapply').onclick = applyJson;
// ββ Import / Export pane ββ (#304/#309: scoped to `io` β edit-note notes by
// default, or a specific field's values when opened from a baby field)
const ctx = io || { items: () => DATA.saved.map(s => s.text), add: notes => addSavedNotes(notes), help: 'Import adds to your saved notes; Export copies them all to the clipboard.' };
const ioTa = p.querySelector('.mmth-io-ta'), ioMsg = p.querySelector('.mmth-io-msg');
const ioBlock = () => p.querySelector('input[name="mmth-iomode"]:checked').value === 'block';
p.querySelector('.mmth-io-help').textContent = ctx.help || '';
p.querySelector('.mmth-io-import').onclick = () => {
const notes = parseNotes(ioTa.value, ioBlock());
if (!notes.length) { ioMsg.textContent = 'Paste some notes first'; return; }
const added = ctx.add(notes);
ioMsg.textContent = `Added ${added} of ${notes.length}` + (added < notes.length ? ' (rest were duplicates)' : '');
if (added) ioTa.value = '';
};
p.querySelector('.mmth-io-export').onclick = async () => {
const items = ctx.items(); const text = items.join(ioBlock() ? '\n\n' : '\n'); ioTa.value = text; ioTa.focus(); ioTa.select();
let copied = false; try { await navigator.clipboard.writeText(text); copied = true; } catch (e) { try { copied = document.execCommand('copy'); } catch (x) {} }
ioMsg.textContent = `${items.length} item(s)` + (copied ? ' β copied to clipboard' : ' β select & copy');
};
// open on the explicitly-requested tab (baby field β 'io'), else the last tab used, else Settings
const TABS = ['settings', 'fields', 'io'];
showTab(TABS.includes(tab) ? tab : (TABS.includes(SET.lastTab) ? SET.lastTab : 'settings'));
placePop(p, anchor); // position once; tab switches no longer move it
}
function openSyntax(anchor) {
closePop();
const p = document.createElement('div'); p.className = 'mmth-pop';
p.innerHTML = `
''italic''italic'''bold'''boldedit #123456link to an editdoc:Pageor [Page_Name] β wiki doc link
URLs become links automatically. HTML is not supported.
Shortcuts
Ctrl/β Bbold the selection / wordCtrl/β Iitalicise the selection / wordCtrl/β β/βcycle saved notes
`;
document.body.appendChild(p); pop = p;
placePop(p, anchor);
}
// ββ sidebars (one per edit-note textarea) ββββββββββββββββββββββββββββββββββββ
const instances = [];
function applyHelp() { document.querySelectorAll('.editnote').forEach(en => mbuCls(en, 'mmth-hidehelp', !!SET.hideHelp)); }
const after = (e, el) => (e.clientY - el.getBoundingClientRect().top) > el.offsetHeight / 2;
const clearMarks = host => host && host.querySelectorAll('.mmth-drop-before,.mmth-drop-after').forEach(r => r.classList.remove('mmth-drop-before', 'mmth-drop-after'));
let _drag = null;
const MIN_PANEL = 300; // #304: keep the toolbar (incl. β) from clipping when narrow
function setSideWidth(side, w) { w = Math.max(MIN_PANEL, Math.min(640, Math.round(w))); side.style.flex = '0 0 ' + w + 'px'; side.style.maxWidth = w + 'px'; return w; }
// #263: never let the panel be wider than the field β cap it to half the row so
// the ratio is at most 1:1 (was up to ~1:10 in a narrow Art Station modal). The
// cap reads ONLY the wrap's width, which the container fixes and setting the
// panel never changes, so this can't oscillate (unlike a field-width-based cap,
// which would: shrinking the panel grows the field, re-raising the capβ¦).
function capPanel(wrap, vsep, side) {
if (SET.minimized) return; // panel is out of flow when minimized
const row = wrap.clientWidth - (vsep ? vsep.offsetWidth : 0); if (!(row > 0)) return;
const max = Math.floor(row / 2);
const want = Math.max(MIN_PANEL, Math.min(SET.sideWidth || 300, max));
if (Math.round(side.getBoundingClientRect().width) !== want) { side.style.flex = '0 0 ' + want + 'px'; side.style.maxWidth = want + 'px'; }
}
// ββ minimized mode (#265) βββββββββββββββββββββββββββββββββββββββββββββββββββββ
// A less-intrusive mode: the panel collapses to a small Mammoth badge in the
// field's top-right corner and floats back in on hover. Persisted, so it stays
// minimized across edit pages. WIDTH/position only β never touches the field's
// height, so it can't reintroduce the #245 growth loop.
function applyMinState(inst) {
const wrap = inst.ta && inst.ta.closest('.mmth-wrap'); if (!wrap) return;
const on = !!SET.minimized;
wrap.classList.toggle('mmth-min', on);
if (inst.minBtn) { inst.minBtn.textContent = on ? 'β€’' : 'β'; inst.minBtn.title = on ? 'Restore the panel' : 'Minimize to corner'; }
if (on) { try { inst.ta.style.minHeight = ''; } catch (x) {} } // drop the panel-height floor β panel is out of flow now
if (!on) { if (inst.unpin) inst.unpin(); if (inst.side) inst.side.classList.remove('mmth-open'); }
}
function setMinimized(on) { SET.minimized = !!on; persistSet(); instances.forEach(i => { applyMinState(i); if (i.recap) i.recap(); }); }
// drag the separator to resize the panel vs. the field (persisted)
function wireResize(vsep, side) {
let startX = 0, startW = 0, on = false;
vsep.addEventListener('pointerdown', e => { on = true; startX = e.clientX; startW = side.getBoundingClientRect().width; try { vsep.setPointerCapture(e.pointerId); } catch (x) {} vsep.classList.add('mmth-dragv'); document.body.style.userSelect = 'none'; e.preventDefault(); });
vsep.addEventListener('pointermove', e => { // panel is on the right β drag left widens it; cap at half the row (#263)
if (!on) return;
const wrap = side.parentNode, row = (wrap ? wrap.clientWidth : 0) - vsep.offsetWidth;
const max = row > 0 ? Math.floor(row / 2) : 640;
setSideWidth(side, Math.min(max, startW - (e.clientX - startX)));
});
const end = e => { if (!on) return; on = false; vsep.classList.remove('mmth-dragv'); document.body.style.userSelect = ''; SET.sideWidth = setSideWidth(side, side.getBoundingClientRect().width); saveSet(); try { vsep.releasePointerCapture(e.pointerId); } catch (x) {} };
vsep.addEventListener('pointerup', end); vsep.addEventListener('pointercancel', end);
}
function buildSide(ta) {
const side = document.createElement('div'); side.className = 'mmth-side';
setSideWidth(side, SET.sideWidth || 300);
const ft = document.createElement('div'); ft.className = 'mmth-ft'; // toolbar ON TOP (#212)
const filterRow = document.createElement('div'); filterRow.className = 'mmth-filterrow'; // #304 search (opt-in)
const list = document.createElement('div'); list.className = 'mmth-list';
const pinbar = document.createElement('div'); pinbar.className = 'mmth-pinbar'; // #304 pinned quick-buttons β placed BELOW the field (in injectAll)
side.appendChild(ft); side.appendChild(filterRow); side.appendChild(list);
const inst = { ta, list, side, pinbar, filterRow, view: 'saved', cycId: null, filter: '', viewItems: [] };
instances.push(inst);
// search box + N/total count (always shown)
const fInput = document.createElement('input'); fInput.type = 'text'; fInput.className = 'mmth-filter'; fInput.placeholder = 'Search notesβ¦';
fInput.addEventListener('input', () => { inst.filter = fInput.value; inst.cycId = null; renderInst(inst); });
fInput.addEventListener('keydown', e => {
if (e.key === 'Escape') { fInput.value = ''; inst.filter = ''; inst.cycId = null; renderInst(inst); ta.focus(); reMinIfRestored(); return; } // #304: backing out re-minimizes if we'd auto-restored
// #304: β/β move the highlighted match; Enter uses the highlighted one (or the first)
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
const items = inst.viewItems || []; if (!items.length) return;
e.preventDefault();
const dir = e.key === 'ArrowDown' ? 1 : -1;
let i = items.findIndex(x => x.id === inst.cycId);
if (i < 0) i = dir > 0 ? -1 : 0;
i = (i + dir + items.length) % items.length;
inst.cycId = items[i].id;
renderInst(inst);
const cur = inst.list.querySelector('.mmth-cyc'); // keep the selection visible within the list
if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect();
if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top);
else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); }
return;
}
if (e.key === 'Enter') {
e.preventDefault();
const items = inst.viewItems || [];
const it = items.find(x => x.id === inst.cycId) || items[0];
if (it) { applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (inst.view === 'saved') bumpUse(it.id); }
}
});
const fCount = document.createElement('span'); fCount.className = 'mmth-count'; inst.countEl = fCount;
filterRow.appendChild(fInput); filterRow.appendChild(fCount); inst.filterInput = fInput;
const fb = (glyph, title, cls, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-fb' + (cls ? ' ' + cls : ''); b.textContent = glyph; b.title = title; b.onclick = fn; return b; };
ft.appendChild(fb('οΌ', 'Save current edit note', '', () => { const v = (ta.value || '').trim(); if (!v) return toast('Edit note is empty'); toast(addSaved(v) ? 'Saved' : 'Already saved'); }));
const bSaved = fb('β ', 'Saved notes', 'mmth-grp', () => { inst.view = 'saved'; renderInst(inst); });
const bHist = fb('π', 'History (last used)', '', () => { inst.view = 'history'; renderInst(inst); });
ft.appendChild(bSaved); ft.appendChild(bHist);
ft.appendChild(fb('β', 'Clear the edit note', 'mmth-grp', () => { setValue(ta, ''); ta.focus(); }));
const sp = document.createElement('span'); sp.className = 'mmth-fb mmth-spacer'; ft.appendChild(sp);
// #309: which edit-note type these notes belong to (notes are kept separate per type)
const scopeChip = document.createElement('span'); scopeChip.className = 'mmth-scope'; scopeChip.textContent = scopeLabel(SCOPE);
scopeChip.title = 'Saved notes & history are kept separate per edit-note type β these are your "' + scopeLabel(SCOPE) + '" notes (#309)';
scopeChip.style.display = SET.scopePerResource ? '' : 'none'; // only shown when scoping is on
ft.appendChild(scopeChip);
inst.minBtn = fb('β', 'Minimize to corner', 'mmth-min-btn', () => { restoredFromMin = false; setMinimized(!SET.minimized); }); // #265: left of the ? button
ft.appendChild(inst.minBtn);
ft.appendChild(fb('?', 'Edit-note syntax', 'mmth-pop-anchor', e => openSyntax(e.currentTarget)));
ft.appendChild(fb('βοΈ', 'Settings', 'mmth-pop-anchor', e => openSettings(e.currentTarget)));
inst.tabs = { saved: bSaved, history: bHist };
ta.addEventListener('keydown', e => {
if (!(e.ctrlKey || e.metaKey)) return;
const k = e.key.toLowerCase();
// Ctrl/β+B / +I wrap the selection in MB edit-note bold / italic markup
if (k === 'b' || k === 'i') { e.preventDefault(); wrapSel(ta, k === 'b' ? "'''" : "''"); return; }
// Ctrl/β+β/β cycle through saved notes, replacing the field (focus stays here)
if (e.key !== 'ArrowUp' && e.key !== 'ArrowDown') return;
if (inst.view !== 'saved') { inst.view = 'saved'; inst.tabs && inst.tabs.saved.classList.add('on'); inst.tabs && inst.tabs.history.classList.remove('on'); renderInst(inst); }
// cycle through exactly what's shown (respects the current sort + filter) #304
const items = inst.viewItems; if (!items || !items.length) return;
e.preventDefault();
const dir = e.key === 'ArrowDown' ? 1 : -1;
let i = items.findIndex(x => x.id === inst.cycId);
if (i < 0) i = dir > 0 ? -1 : 0;
i = (i + dir + items.length) % items.length;
inst.cycId = items[i].id;
setValue(ta, items[i].text);
renderInst(inst);
// keep the highlighted item visible β scroll WITHIN the list only (not the page)
const cur = inst.list.querySelector('.mmth-cyc');
if (cur) { const lr = inst.list.getBoundingClientRect(), cr = cur.getBoundingClientRect();
if (cr.top < lr.top) inst.list.scrollTop -= (lr.top - cr.top);
else if (cr.bottom > lr.bottom) inst.list.scrollTop += (cr.bottom - lr.bottom); }
// setValue can trigger a React re-render on the release editor that steals
// focus; re-assert it now AND after the re-render so the editor stays focused.
const refocus = () => { try { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); } catch (x) {} };
refocus(); requestAnimationFrame(refocus); setTimeout(refocus, 0);
});
renderInst(inst);
return inst;
}
// #304: pinned saved notes shown as one-click quick-insert buttons BELOW the
// edit-note field (like baby-field bars) β always available, independent of view.
function renderPinbar(inst) {
const bar = inst.pinbar; bar.innerHTML = '';
const pinned = DATA.saved.filter(s => s.pinned);
if (!pinned.length) { bar.style.display = 'none'; return; }
bar.style.display = 'flex';
const n = Math.max(4, Math.min(80, SET.btnChars | 0 || 24));
const cap = t => { t = t.replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n) + 'β¦' : t; };
const dflt = SET.defaultInsert;
pinned.forEach(it => {
const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-segb'; b.textContent = cap(it.text); b.style.maxWidth = (n + 1) + 'ch';
b.title = it.text + `\n\n(click: ${dflt} Β· right-click: ${dflt === 'replace' ? 'append' : 'replace'} Β· Ctrl/β-click: replace + submit)`;
b.onclick = e => {
e.preventDefault();
// #328: Ctrl/β-click = replace the note and submit the edit (parity with Ctrl/β+Enter;
// shift was prone to text selection). Same as a list row.
if (e.ctrlKey || e.metaKey) { applyNote(inst.ta, it.text, true); bumpUse(it.id); const sb = findSubmitBtn(inst.ta); if (sb) sb.click(); return; }
applyNote(inst.ta, it.text, dflt === 'replace'); bumpUse(it.id);
};
b.oncontextmenu = e => { e.preventDefault(); applyNote(inst.ta, it.text, dflt !== 'replace'); bumpUse(it.id); };
bar.appendChild(b);
});
}
function renderInst(inst) {
const { ta, list } = inst;
list.style.maxHeight = (Math.max(1, Math.min(30, SET.visibleRows | 0 || 6)) * 26) + 'px'; // show N items, then scroll (#212)
list.innerHTML = '';
if (inst.tabs) { inst.tabs.saved.classList.toggle('on', inst.view === 'saved'); inst.tabs.history.classList.toggle('on', inst.view === 'history'); }
const saved = inst.view === 'saved';
renderPinbar(inst);
// the search box is always shown
inst.filterRow.style.display = 'flex';
// sort (Saved, from config) then search-filter on the full note text; show N/total
const all = saved ? sortedSaved() : DATA.history;
const q = (inst.filter || '').trim().toLowerCase();
const items = q ? all.filter(it => it.text.toLowerCase().includes(q)) : all;
inst.viewItems = items; // Ctrl+β/β and Enter-in-search use exactly what's shown
if (inst.countEl) inst.countEl.textContent = q ? (items.length + ' / ' + all.length) : (all.length ? String(all.length) : '');
// drag-reorder only makes sense in the unfiltered manual order
const manual = saved && (SET.noteSort || 'manual') === 'manual' && !q;
if (!items.length) { const e = document.createElement('div'); e.className = 'mmth-empty'; e.textContent = all.length ? 'No notes match the search' : (saved ? 'No saved notes β οΌ saves the current one' : 'No history yet'); list.appendChild(e); return; }
items.forEach((it) => {
const row = document.createElement('div'); row.className = 'mmth-row';
if (saved && it.pinned) row.classList.add('mmth-pinned');
if (it.id === inst.cycId) row.classList.add('mmth-cyc'); // arrow-key / cycle selection (both views) #304
const dflt = SET.defaultInsert;
row.title = it.text + `\n\n(click: ${dflt} Β· right-click: ${dflt === 'replace' ? 'append' : 'replace'} Β· Ctrl/β-click: set + submit)`;
const txt = document.createElement('span'); txt.className = 'mmth-txt'; txt.textContent = it.text.replace(/\s+/g, ' ').trim();
row.appendChild(txt);
// right-side hover actions: pin/unpin + delete (saved); save + remove (history)
const acts = document.createElement('div'); acts.className = 'mmth-rowacts';
const ra = (glyph, title, fn) => { const b = document.createElement('button'); b.type = 'button'; b.className = 'mmth-ra'; b.textContent = glyph; b.title = title; b.onclick = e => { e.stopPropagation(); fn(); }; acts.appendChild(b); };
if (saved) { ra(it.pinned ? 'β ' : 'β', it.pinned ? 'Unpin from quick buttons' : 'Pin as a quick button', () => togglePinNote(it.id)); ra('π', 'Delete', () => removeSaved(it.id)); }
else { ra('β ', 'Save (pin to Saved)', () => { if (addSaved(it.text)) toast('Saved'); }); ra('π', 'Remove', () => removeHistory(it.text)); }
row.appendChild(acts);
if (manual) {
const grab = document.createElement('span'); grab.className = 'mmth-grab'; grab.textContent = 'β Ώ'; grab.title = 'Drag to reorder'; grab.draggable = true;
grab.addEventListener('dragstart', e => { _drag = { id: it.id }; e.dataTransfer.effectAllowed = 'move'; try { e.dataTransfer.setData('text/plain', 'row'); } catch (x) {} row.classList.add('mmth-dragging'); });
grab.addEventListener('dragend', () => { row.classList.remove('mmth-dragging'); clearMarks(list); _drag = null; });
row.appendChild(grab);
}
row.onclick = e => {
// #289/#328: Ctrl/β-click sets the note (replace) AND submits the edit β parity
// with Ctrl/β+Enter (reuses findSubmitBtn); shift was prone to text selection.
if (e.ctrlKey || e.metaKey) { applyNote(ta, it.text, true); if (saved) bumpUse(it.id); const b = findSubmitBtn(ta); if (b) b.click(); return; }
applyNote(ta, it.text, SET.defaultInsert === 'replace'); if (saved) bumpUse(it.id);
};
row.oncontextmenu = e => { e.preventDefault(); applyNote(ta, it.text, SET.defaultInsert !== 'replace'); if (saved) bumpUse(it.id); };
if (manual) {
row.addEventListener('dragover', e => { if (!_drag) return; e.preventDefault(); e.dataTransfer.dropEffect = 'move'; clearMarks(list); row.classList.add(after(e, row) ? 'mmth-drop-after' : 'mmth-drop-before'); });
row.addEventListener('dragleave', () => row.classList.remove('mmth-drop-before', 'mmth-drop-after'));
row.addEventListener('drop', e => { if (!_drag) return; e.preventDefault(); reorder(_drag.id, it.id, !after(e, row)); clearMarks(list); _drag = null; });
}
list.appendChild(row);
});
}
function render() { instances.forEach(i => { if (i.list.isConnected) renderInst(i); }); }
// ββ attach βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function injectAll() {
applyHelp();
document.querySelectorAll('textarea.edit-note').forEach(ta => {
if (ta.dataset.mmth) return;
ta.dataset.mmth = '1';
const en = ta.closest('.editnote'); if (en) en.classList.add('mmth-on'); // hides the redundant inline "Edit note:" label (#212)
const wrap = document.createElement('div'); wrap.className = 'mmth-wrap';
ta.parentNode.insertBefore(wrap, ta);
// #304: the textarea and its pinned-quick-buttons bar share one column, so the
// bar always sits directly below the field and tracks its left edge / width,
// whatever the surrounding form layout does.
const fieldCol = document.createElement('div'); fieldCol.className = 'mmth-fieldcol';
wrap.appendChild(fieldCol); fieldCol.appendChild(ta);
// remember the textarea height the user sets with the native resize grip (vertical);
// the splitter (below) remembers the field/panel split (horizontal).
if (SET.taHeight) ta.style.height = SET.taHeight + 'px';
// Persist ONLY a deliberate user resize (height changes between mouse down
// and up on the field). The old ResizeObserver fired on any layout-driven
// size change too, so visiting a differently-sized edit page (e.g. the
// full-width release editor) silently overwrote the saved height.
let _downH = null;
ta.addEventListener('mousedown', () => { _downH = ta.offsetHeight; });
window.addEventListener('mouseup', () => {
if (_downH == null) return;
const h = ta.offsetHeight; const was = _downH; _downH = null;
if (h > 40 && h !== was && h !== SET.taHeight) { SET.taHeight = h; ta.style.height = h + 'px'; persistSet(); }
});
const vsep = document.createElement('div'); vsep.className = 'mmth-vsep'; vsep.title = 'Drag to resize'; wrap.appendChild(vsep); // resizable separator between field & panel (#212)
const inst = buildSide(ta); const side = inst.side; wrap.appendChild(side);
fieldCol.appendChild(inst.pinbar); // #304: pinned quick-buttons directly below the field, in its column
wireResize(vsep, side);
// #265 minimized mode: badge in the field's top-right corner; hover (or click
// to pin) floats the panel back in. mouseleave closes after a short grace.
const badge = document.createElement('div'); badge.className = 'mmth-badge'; badge.title = 'Mammoth β saved notes (click or hover)';
badge.innerHTML = MAMMOTH_SVG; // #308 vector, not the 𦣠emoji
wrap.appendChild(badge); inst.badge = badge;
// #288/#290: MB (and other scripts, e.g. jesus2099's MERGE HELPOR) insert
// edit-note error/warning
s as siblings of the textarea β which now lives
// inside our flex .mmth-wrap, so they'd be laid out BESIDE the field. Keep the
// wrap a clean row (field | sep | panel | badge) and relocate any foreign child
// OUT to normal flow: warnings above the wrap, validation/other notices below.
// (Doing this in JS instead of flex-wrap avoids the panel itself wrapping below
// the field in a narrow left-column layout β #290.)
// #304: the textarea now lives in .mmth-fieldcol, so MB inserts those
s into
// fieldCol (not wrap) β observe both, and treat fieldCol/ta/pinbar as ours.
const isOurs = el => el === ta || el === inst.pinbar || (el.classList && (el.classList.contains('mmth-fieldcol') || el.classList.contains('mmth-vsep') || el.classList.contains('mmth-side') || el.classList.contains('mmth-badge')));
const relocateForeign = node => {
if (!node || node.nodeType !== 1 || isOurs(node) || !wrap.parentNode) return;
const above = node.classList && node.classList.contains('error') && !node.classList.contains('invalid');
wrap.parentNode.insertBefore(node, above ? wrap : wrap.nextSibling);
};
[...wrap.children, ...fieldCol.children].forEach(relocateForeign);
const fObs = muts => muts.forEach(m => m.addedNodes.forEach(relocateForeign));
new MutationObserver(fObs).observe(wrap, { childList: true });
new MutationObserver(fObs).observe(fieldCol, { childList: true });
let closeT = null, pinned = false;
const openFloat = () => { clearTimeout(closeT); if (SET.minimized) side.classList.add('mmth-open'); };
const closeFloat = () => { clearTimeout(closeT); if (pinned) return; closeT = setTimeout(() => side.classList.remove('mmth-open'), 220); };
badge.addEventListener('mouseenter', openFloat);
badge.addEventListener('mouseleave', closeFloat);
side.addEventListener('mouseenter', openFloat);
side.addEventListener('mouseleave', closeFloat);
// click the badge to pin the panel open (so it survives mouse-out); click again to unpin
badge.addEventListener('click', () => { if (!SET.minimized) return; pinned = !pinned; pinned ? openFloat() : side.classList.remove('mmth-open'); });
inst.unpin = () => { pinned = false; };
applyMinState(inst);
// #263: keep the panel β€ half the row (never wider than the field). Driven by
// the WRAP's width only β stable, no feedback loop.
const cap = () => capPanel(wrap, vsep, side);
cap(); requestAnimationFrame(cap); setTimeout(cap, 200);
try { new ResizeObserver(cap).observe(wrap); } catch (x) {}
inst.recap = cap;
// The saved-notes panel's height (driven by the Items Shown setting) is the
// field's floor, so it's never shorter than the sidebar. With no user-saved
// height the field STARTS at exactly that height too β so its initial size
// tracks Items Shown β until the user drags the grip (which is remembered).
const syncFloor = () => { try {
// Minimized: the panel floats out of flow, so the field needs no floor β
// applying one here while the panel shows on hover would couple field
// height to panel height (the #245 loop). Clear it and bail.
if (SET.minimized) { if (ta.style.minHeight) ta.style.minHeight = ''; return; }
const h = side.offsetHeight; if (!(h > 0)) return;
if ((parseInt(ta.style.minHeight, 10) || 0) !== h) ta.style.minHeight = h + 'px';
if (!SET.taHeight && (parseInt(ta.style.height, 10) || 0) !== h) ta.style.height = h + 'px';
vsep.style.height = ta.offsetHeight + 'px'; // #304: separator spans only the field, not the field+pinbar column
} catch (x) {} };
syncFloor();
requestAnimationFrame(syncFloor); setTimeout(syncFloor, 150); setTimeout(syncFloor, 600); // catch the sidebar's final layout
try { new ResizeObserver(syncFloor).observe(side); new ResizeObserver(syncFloor).observe(ta); } catch (x) {}
});
}
// Toggle the "an MB dialog is open" flag so Mammoth's floating UI (main panel
// badge/pop + babies) hides under it β MB's dialogs/popovers carry no z-index
// and no backdrop, so they can't otherwise win the stack. Global (runs even
// when babies are off). #313
// #333/#397/#config: a dialog (.dialog.popover / .relationship-dialog) whose fields we pin must NOT be
// treated as blocking, or we'd hide the very baby it hosts. That was a hardcoded allowlist (artist-credit
// rows, then the "Task" field) β generalise it: a dialog blocks only if it contains NONE of our baby
// fields (`[data-mmthf]`, set by scan() on every pinned field, custom ones included). This is what let
// instrument-type relationship dialogs β bass/guitar/β¦ have no Task field β wrongly hide a custom
// "Credited as" baby while producer/mixer (which DO have Task) worked. Fields BEHIND a dialog are already
// hidden per-pin by fieldOnTop (elementFromPoint), so keeping non-blocking here can't float them over it.
const syncDialog = () => {
const dlgs = [...document.querySelectorAll('.dialog.popover, .relationship-dialog')];
const blocking = dlgs.some(d => !d.querySelector('[data-mmthf]'));
// #569: guarded. These fire from a document-wide subtree observer, so an
// unconditional toggle on re-triggers every other script's root
// watcher, which mutates, which brings us back here β the loop chaban
// measured at 12 root mutations a second on an idle page.
mbuCls(document.documentElement, 'mmthf-dialog', blocking); // baby pins: hide only when a dialog hosts none of ours
mbuCls(document.documentElement, 'mmthf-anydialog', dlgs.length > 0); // main panel/badge: hide under ANY open dialog (#400)
};
// #462: MB's jQuery-UI autocomplete menu (ul.ui-autocomplete) opens by toggling `display`
// β a style mutation the childList observer above never sees β so watch each menu's attrs
// directly and flag `mmthf-acopen` while any is visible, which hides overlapping babies.
const syncAc = () => {
const open = [...document.querySelectorAll('ul.ui-autocomplete')].some(u => u.offsetParent !== null && getComputedStyle(u).display !== 'none');
mbuCls(document.documentElement, 'mmthf-acopen', open);
};
const acObs = new MutationObserver(syncAc);
const watchAcMenus = () => { document.querySelectorAll('ul.ui-autocomplete').forEach(u => { if (!u._mmthfAc) { u._mmthfAc = 1; acObs.observe(u, { attributes: true, attributeFilter: ['style', 'class'] }); } }); syncAc(); };
// #623 (sweep, M1): this observer sees every node on every MusicBrainz page, and each
// mutation re-queried the edit notes, dialogs and autocomplete menus (a
// getComputedStyle per menu) β hundreds of times while a page renders in bursts. Now a
// burst runs it once: the first mutation schedules a run 60 ms later, the rest join
// it. A timer, not requestAnimationFrame, which never fires in a background tab β and
// background edit tabs (other scripts' "add in the background") need the defaults too.
let _mmthPending = 0;
const onPageMutation = () => { if (_mmthPending) return; _mmthPending = setTimeout(() => { _mmthPending = 0; injectAll(); syncDialog(); watchAcMenus(); }, 60); };
new MutationObserver(onPageMutation).observe(document.documentElement, { childList: true, subtree: true });
syncDialog(); watchAcMenus();
// #252 Ctrl/Cmd+Enter submits the edit. The submit control differs per page, so
// look in order: the release editor's "Enter edit" button, then the edit form's
// own submit, then a visible button labelled Enter edit / Submit / Finish. Only
// act when focus is in the edit-note field or Mammoth's panel (or nowhere), so it
// never hijacks Ctrl+Enter in some unrelated field.
const isVisible = b => !!(b && b.offsetParent !== null && !b.disabled);
function findSubmitBtn(ta) {
const re = document.getElementById('enter-edit');
if (isVisible(re)) return re;
const form = ta && ta.closest('form');
if (form) { const s = form.querySelector('button.submit, button[type="submit"], button.positive'); if (isVisible(s)) return s; }
return [...document.querySelectorAll('button')].find(b => isVisible(b) && /^\s*(enter edit|submit|finish)\b/i.test(b.textContent || '')) || null;
}
document.addEventListener('keydown', e => {
if (e.key !== 'Enter' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
const ta = document.querySelector('textarea.edit-note'); if (!ta) return;
const t = e.target;
if (t !== ta && !(t && t.closest && t.closest('.mmth-wrap, .mmth-side, .mmth-pop')) && t !== document.body) return;
const btn = findSubmitBtn(ta);
if (btn) { e.preventDefault(); btn.click(); }
});
// #304: Ctrl/Cmd+, focuses the note search box (the panel nearest the focused
// field, else the first visible one). When the panel is minimized, restore it
// first so the box is reachable. No-op when search is off.
document.addEventListener('keydown', e => {
if (e.key !== ',' || !(e.ctrlKey || e.metaKey) || e.altKey || e.shiftKey) return;
const onScreen = () => [...document.querySelectorAll('.mmth-filter')].filter(i => i.offsetParent !== null);
let visible = onScreen();
if (!visible.length) {
// search box is hidden because the panel is minimized β restore and retry
if (SET.minimized) { setMinimized(false); restoredFromMin = true; visible = onScreen(); }
if (!visible.length) return;
}
const wrap = e.target && e.target.closest && e.target.closest('.mmth-wrap');
const near = wrap && wrap.querySelector('.mmth-filter');
const target = (near && near.offsetParent !== null) ? near : visible[0];
e.preventDefault(); target.focus(); target.select();
});
injectAll();
if (SET.showBabies !== false) babyMammoths.start();
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
// BABY MAMMOTHS β field memory for arbitrary input controls
// Self-contained (own storage key, own CSS, own DOM), gated by SET.showBabies.
// start()/stop() let the β toggle add/remove it cleanly at runtime.
// ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
function createBabyMammoths() {
const FKEY = 'mammoth-fields:data'; // { [fieldKey]: [{ v, label, ts, pinned?, default? }] }
const MAX_PER_FIELD = 25;
// #296 follow-up β capture the selected ENTITY's MBID for autocomplete fields,
// so a saved Label/Artist resolves the real entity on recall (writeField pastes
// the MBID, which MB resolves) instead of being text-only. The gid comes from
// the live release editor model; unsafeWindow reaches the page's MB from the
// userscript sandbox. Falls back to text when nothing is selected / off-editor.
const PAGEWIN = (typeof unsafeWindow !== 'undefined' && unsafeWindow) || window;
const relEntity = () => { try { return PAGEWIN.MB.releaseEditor.rootField.release(); } catch (e) { return null; } };
const labelGid = el => { const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0); const r = relEntity(); const labs = r && r.labels && r.labels(); const L = labs && labs[i] && labs[i].label && labs[i].label(); return (L && L.gid) || null; };
const _unwrap = x => (typeof x === 'function' ? x() : x);
const artistGid = () => { // single-artist box only: exactly one real artist (names has a trailing blank)
const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit();
let ns = ac && ac.names; ns = _unwrap(ns); if (!ns) return null;
const real = [...ns].map(n => _unwrap(n.artist)).filter(a => a && a.gid);
return real.length === 1 ? real[0].gid : null;
};
// a specific row of the artist-credit editor bubble (ac-source-artist-)
const artistRowGid = el => {
const i = +((String(el.id).match(/-(\d+)$/) || [])[1] || 0);
const r = relEntity(); const ac = r && r.artistCredit && r.artistCredit();
const ns = _unwrap(ac && ac.names); const a = ns && ns[i] && _unwrap(ns[i].artist);
return (a && a.gid) || null;
};
// MBID capture for the built-in Label/Artist fields (`mbid:true`). Reads the release editor's model for
// the known label/artist inputs; a no-op on any other field (β text). The generic probe is gone; this is
// the specific labelGid/artistGid readers, exposed behind the flag only for the two fields where it works.
const builtinGid = el => {
const id = String(el.id || '');
if (/^label-\d+$/.test(id)) return labelGid(el);
if (id === 'ac-source-single-artist') return artistGid(el);
if (/^ac-source-artist-/.test(id)) return artistRowGid(el);
return null;
};
// Every baby field β built-in or user β comes from SET.customFields. The storage key is derived from the
// LABEL (namespaced 'cf:'), so any two same-label fields share one saved list. `mbid:true` (JSON only)
// wires the Label/Artist MBID reader; `submit:true` submits the field's form after a recall (see recallInto).
const customDefs = () => (SET.customFields || []).filter(c => c && c.match && c.enable !== false).map(c => { // enable:false β not scanned (no pin)
const key = String(c.label || '').trim() ? 'cf:' + String(c.label).trim() : null; // no label β keyFor derives one from the element
return { match: c.match, key, label: c.label || '',
dx: (c.dx != null && c.dx !== '') ? +c.dx : undefined, deltav: (c.dv | 0) || 0, submit: !!c.submit, gid: c.mbid ? builtinGid : null };
});
const loadF = () => { try { return JSON.parse(GM_getValue(FKEY, '{}') || '{}'); } catch (e) { return {}; } };
const saveF = () => { try { GM_setValue(FKEY, JSON.stringify(FDATA)); } catch (e) {} };
let FDATA = loadF();
const listFor = key => (FDATA[key] = FDATA[key] || []);
function rememberValue(key, rec) {
if (!rec || !rec.v) return false;
const a = listFor(key); const e = a.find(x => x.v === rec.v);
if (e) { e.label = rec.label || e.label; e.ts = Date.now(); } // already saved β keep it (and its β / default / order)
else { a.unshift({ v: rec.v, label: rec.label || rec.v, ts: Date.now() }); FDATA[key] = a.slice(0, MAX_PER_FIELD); }
saveF(); return true;
}
const forgetValue = (key, v) => { FDATA[key] = listFor(key).filter(x => x.v !== v); saveF(); };
// edit a saved value in place (keeps its β /default/order) β used to paste/append an MBID onto a value
function editValue(key, oldV, newV) {
newV = String(newV == null ? '' : newV).trim(); if (!newV) return false;
const a = listFor(key); const e = a.find(x => x.v === oldV); if (!e) return false;
if (newV !== oldV) { const other = a.findIndex(x => x.v === newV); if (other >= 0 && a[other] !== e) a.splice(other, 1); } // dedup a collision
e.v = newV; e.label = newV; e.ts = Date.now(); saveF(); return true;
}
const togglePin = (key, v) => { const e = listFor(key).find(x => x.v === v); if (e) { e.pinned = !e.pinned; saveF(); } };
function setDefault(key, v) { const a = listFor(key); const e = a.find(x => x.v === v); if (!e) return; const was = e.default; a.forEach(x => x.default = false); e.default = !was; saveF(); }
const defaultOf = key => listFor(key).find(x => x.default);
// drag-reorder a saved value relative to another (like the edit-note panel's β Ώ)
function reorder(key, srcV, tgtV, before) {
if (srcV === tgtV) return;
const a = listFor(key); const si = a.findIndex(x => x.v === srcV); if (si < 0) return;
const [it] = a.splice(si, 1);
let ti = a.findIndex(x => x.v === tgtV); if (ti < 0) { a.splice(si, 0, it); return; }
a.splice(before ? ti : ti + 1, 0, it); saveF();
}
// #304: caption = the full label, truncated with an ellipsis at the configured
// length (β "Button label length") β no longer just the first word.
const btnChars = () => Math.max(4, Math.min(80, SET.btnChars | 0 || 24));
// Strip an MBID (a bare GUID, or a full musicbrainz.org URL ending in one) from a value for DISPLAY only.
// The stored value + export keep it, because saving "name " lets an autocomplete resolve the entity
// straight away (no search / no Enter) β but the raw id is ugly, so hide it in the rows and the buttons.
const MMTH_MBID_RE = /\s*\(?(?:https?:\/\/(?:beta\.)?musicbrainz\.org\/[a-z-]+\/)?[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\)?\/?\s*/ig;
const cleanLabel = s => { const raw = String(s == null ? '' : s); const t = raw.replace(MMTH_MBID_RE, ' ').replace(/\s{2,}/g, ' ').trim(); return t || raw.trim(); };
const captionOf = it => { if (it.cap) return it.cap; const n = btnChars(); const t = cleanLabel(it.label || it.v); return t.length > n ? t.slice(0, n) + 'β¦' : t; };
const isSelect = el => el.tagName === 'SELECT';
const isAuto = el => el.classList.contains('ui-autocomplete-input') || el.classList.contains('lookup-performed');
function setNative(el, val) {
const proto = isSelect(el) ? HTMLSelectElement.prototype : el.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
const d = Object.getOwnPropertyDescriptor(proto, 'value'); if (d && d.set) d.set.call(el, val); else el.value = val;
}
function readField(el) {
if (isSelect(el)) { const o = el.options[el.selectedIndex]; return { v: el.value, label: o ? o.textContent.trim() : el.value }; }
const v = el.value || ''; return { v, label: v.trim() };
}
// What to STORE for the current value. Entity fields (p.gid) store the selected
// MBID as the value with the visible name as the label, so recall resolves the
// real entity; everything else (and unresolved/empty entity fields) stores text.
function captureField(p) {
const t = readField(p.el);
if (!t.v || !p.gid) return t;
let g; try { g = p.gid(p.el); } catch (e) {}
return g ? { v: g, label: t.label || g } : t;
}
function writeField(el, rec) {
if (isSelect(el)) {
let opt = [...el.options].find(o => o.value === rec.v);
if (!opt && rec.label) opt = [...el.options].find(o => o.textContent.trim() === rec.label.trim());
if (!opt) return false;
setNative(el, opt.value); el.dispatchEvent(new Event('change', { bubbles: true })); return true;
}
setNative(el, rec.v);
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
if (isAuto(el)) { el.dispatchEvent(new KeyboardEvent('keydown', { bubbles: true, key: 'a' })); el.dispatchEvent(new KeyboardEvent('keyup', { bubbles: true, key: 'a' })); }
return true;
}
// Submit the field's enclosing