// ==UserScript== // @name Mammoth // @namespace https://musicbrainz.org/ // @version 2026.6.16 // @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,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxMjggMTI4Ij4KICANCiAgPCEtLSBzaGFnZ3kgYm9keSArIGRvbWVkIGhlYWQgLS0+DQogIDxwYXRoIGQ9Ik0yOCA4MmMwLTEwIDQtMTcgMTEtMjEgMy0xNSAxNS0yNSAzMS0yNXMyNyAxMCAzMCAyNGM3IDMgMTAgOSAxMCAxN3YxMGMwIDQtMyA3LTcgN2gtN3YtOWgtOHY5SDY2di05aC04djloLTljLTUgMC05LTQtOS05di0zYy00LTItNy02LTctMTFaIiBmaWxsPSIjOGQ2NDQyIi8+DQogIDwhLS0gZWFyIC0tPg0KICA8cGF0aCBkPSJNNzQgNDZjMTEtNyAyMi0zIDIyIDgtOS01LTE2LTMtMjAgM1oiIGZpbGw9IiM3YTU0MzYiLz4NCiAgPCEtLSB0cnVuayAtLT4NCiAgPHBhdGggZD0iTTQyIDY0Yy05IDYtMTMgMTktNyAzMSAzIDYgMTEgMyA5LTMtMi04IDAtMTUgNy0xOVoiIGZpbGw9IiM4ZDY0NDIiLz4NCiAgPCEtLSB0dXNrIC0tPg0KICA8cGF0aCBkPSJNNDYgODZjLTkgNS0xOCAyLTIyLTcgNyA3IDE1IDYgMjAtMVoiIGZpbGw9IiNmZmYiIHN0cm9rZT0iIzZmNGEyZSIgc3Ryb2tlLXdpZHRoPSIyLjUiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz4NCiAgPCEtLSBleWUgLS0+DQogIDxjaXJjbGUgY3g9IjU0IiBjeT0iNTYiIHI9IjQiIGZpbGw9IiMyYTIwMTciLz4NCjwvc3ZnPg0K // @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 }; // defaultInsert: 'replace' | 'append' const VERSION = '2026.6.16.121500'; // 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 (its 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 only. min-width:0 lets the editnote fieldset (min-content by default) take that width so margin:auto can center it. */ .half-width:has(> .editnote.mmth-on), .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; } .mmth-wrap { display:flex; gap:0; align-items:stretch; width:100%; max-width:1040px; margin:6px auto; box-sizing:border-box; } .mmth-wrap > textarea.edit-note { flex:1 1 auto; width:auto !important; min-width:0; } .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); })(); function toast(msg) { const t = document.createElement('div'); t.className = 'mmth-toast'; t.textContent = msg; document.body.appendChild(t); 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