// ==UserScript== // @name Mammoth // @namespace https://musicbrainz.org/ // @version 2026.6.24 // @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 // @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. (function () { 'use strict'; const KEY = 'mammoth:data'; const SKEY = 'mammoth:settings'; const DEFAULTS = { historySize: 10, hideHelp: false, defaultInsert: 'replace', visibleRows: 6, sideWidth: 300, appendNewline: true, minimized: false }; // defaultInsert: 'replace' | 'append' const VERSION = '2026.6.23.221528'; // keep in sync with @version (fallback when GM_info is unavailable) const HELP_URL = 'https://github.com/majkinetor/musicbrainz-userscripts/blob/main/userscripts/mammoth/README.md'; const SYNTAX_URL = 'https://musicbrainz.org/doc/Edit_Note'; const scriptVersion = () => { try { return GM_info.script.version || VERSION; } catch (e) { return VERSION; } }; const loadData = () => { try { return Object.assign({ saved: [], history: [] }, JSON.parse(GM_getValue(KEY, '{}') || '{}')); } catch (e) { return { saved: [], history: [] }; } }; const saveData = () => { try { GM_setValue(KEY, JSON.stringify(DATA)); } catch (e) {} render(); }; 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(); }; let DATA = loadData(); let SET = loadSet(); const uid = () => 'n' + Math.random().toString(36).slice(2, 9); // โโ 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(); } // โโ 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 })); } 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'); 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) {} } // wrap the selection in `marker`; with no selection, wrap the word the caret // is in (or just drop the markers if the caret isn't inside a word). function wrapSel(ta, marker) { const v = ta.value; 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); setValue(ta, v.slice(0, s) + marker + sel + marker + v.slice(e)); ta.focus(); const caret = sel ? s + marker.length * 2 + sel.length : s + marker.length; try { ta.setSelectionRange(sel ? s + marker.length : caret, sel ? s + marker.length + sel.length : 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 โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ const 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; } .mmth-wrap > textarea.edit-note { flex:1 1 auto; width:auto !important; min-width:0; } /* #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 #cfd9d3; border-radius:7px; background:#fbfdfc; box-shadow:0 1px 3px rgba(0,0,0,.12); font-size:15px; line-height:1; user-select:none; } .mmth-badge:hover { background:#eaf5ee; border-color:#5aa67e; } .mmth-min > .mmth-badge { display:flex; } .mmth-vsep { flex:none; width:9px; align-self:stretch; cursor:col-resize; position:relative; } .mmth-vsep::before { content:''; position:absolute; left:4px; top:0; bottom:0; width:1px; background:#d7e0db; } .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 #cfd9d3; border-radius:8px; background:#fbfdfc; 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 #e7eee9; background:#f1f6f3; } .mmth-fb { cursor:pointer; border:none; background:none; font-size:13px; line-height:1; padding:3px 6px; border-radius:5px; color:#566; } .mmth-fb:hover { background:#dcefe2; } .mmth-fb.on { background:#cfe9d8; color:#1f5c3d; } .mmth-fb.mmth-spacer { flex:1; pointer-events:none; } .mmth-fb.mmth-grp { margin-left:10px; } .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 #f0f4f2; cursor:pointer; } .mmth-row:first-child { border-top:none; } .mmth-row:hover { background:#eaf5ee; } .mmth-row.mmth-cyc { background:#d9efe1; } .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:#b7c2bb; 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:#293330; } .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:#7d8a82; font-size:11px; line-height:1; padding:1px 2px; border-radius:3px; } .mmth-ra:hover { background:#cfe9d8; color:#1f5c3d; } .mmth-empty { padding:12px 8px; color:#9aa6a0; font-style:italic; text-align:center; } .mmth-pop { position:fixed; z-index:99999; background:#fff; border:1px solid #c7d3cc; border-radius:8px; box-shadow:0 8px 26px rgba(20,50,35,.2); padding:10px 12px; font:13px/1.45 -apple-system,Segoe UI,Arial,sans-serif; color:#222; width:280px; } .mmth-pop h4 { margin:-10px -12px 8px; padding:6px 10px; font-size:13px; display:flex; align-items:center; gap:6px; background:#f1f6f3; border-bottom:1px solid #e7eee9; border-radius:8px 8px 0 0; } .mmth-tip { color:#8a978f; font-size:11px; margin:0 0 4px 22px; } .mmth-pop h4 .mmth-ver { color:#8a978f; font-weight:400; font-size:11px; } .mmth-pop h4 a { margin-left:auto; font-size:11px; color:#2c7a51; text-decoration:none; font-weight:600; } .mmth-pop label { display:flex; align-items:center; gap:6px; margin:5px 0; cursor:pointer; } .mmth-pop input[type="number"] { width:46px; border:1px solid #d7e0db; border-radius:4px; padding:1px 4px; } .mmth-pop select { border:1px solid #d7e0db; border-radius:4px; padding:1px 4px; } .mmth-pop code { background:#f1f4f2; 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; } .mmth-toast { position:fixed; z-index:100000; background:#2c3a33; color:#fff; padding:6px 12px; border-radius:6px; font:13px sans-serif; box-shadow:0 4px 14px rgba(0,0,0,.25); left:50%; top:14px; transform:translateX(-50%); } `; (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); function toast(msg) { const t = document.createElement('div'); t.className = 'mmth-toast'; t.textContent = msg; document.body.appendChild(t); if (_toastPt) { // anchor just above the click point, clamped into the viewport const w = t.offsetWidth, h = t.offsetHeight; const left = Math.max(6, Math.min(window.innerWidth - w - 6, _toastPt.x - w / 2)); const top = Math.max(6, Math.min(window.innerHeight - h - 6, _toastPt.y - h - 10)); t.style.left = left + 'px'; t.style.top = top + 'px'; t.style.transform = 'none'; } setTimeout(() => t.remove(), 1500); } // โโ popovers (settings + syntax help) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ let pop = null; function closePop() { if (pop) { pop.remove(); pop = null; document.removeEventListener('mousedown', onPopDown, true); } } function onPopDown(e) { if (pop && !pop.contains(e.target) && !e.target.closest('.mmth-pop-anchor')) closePop(); } function placePop(p, anchor) { const r = anchor.getBoundingClientRect(); p.style.top = Math.max(6, r.top - p.offsetHeight - 6) + 'px'; p.style.left = Math.max(6, Math.min(window.innerWidth - p.offsetWidth - 6, r.right - p.offsetWidth)) + 'px'; setTimeout(() => document.addEventListener('mousedown', onPopDown, true), 0); } function openSettings(anchor) { closePop(); const p = document.createElement('div'); p.className = 'mmth-pop'; p.innerHTML = `
''italic''italic
'''bold'''bold
edit #123456link to an edit
doc:Pageor [Page_Name] โ wiki doc link
Ctrl/โ Bbold the selection / word
Ctrl/โ Iitalicise the selection / word
Ctrl/โ โ/โcycle saved notes
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.) const isOurs = el => el === ta || (el.classList && (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].forEach(relocateForeign); new MutationObserver(muts => muts.forEach(m => m.addedNodes.forEach(relocateForeign))).observe(wrap, { 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'; } catch (x) {} }; syncFloor(); requestAnimationFrame(syncFloor); setTimeout(syncFloor, 150); setTimeout(syncFloor, 600); // catch the sidebar's final layout try { new ResizeObserver(syncFloor).observe(side); } catch (x) {} }); } new MutationObserver(() => injectAll()).observe(document.documentElement, { childList: true, subtree: true }); // #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(); } }); injectAll(); })();