Skip to content

Instantly share code, notes, and snippets.

@cg505
Created November 29, 2025 01:12
Show Gist options
  • Select an option

  • Save cg505/893e62e18e2224c717ac90cb1b78df57 to your computer and use it in GitHub Desktop.

Select an option

Save cg505/893e62e18e2224c717ac90cb1b78df57 to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name MusicBrainz: Batch Group Relationship Edits
// @namespace http://tampermonkey.net/
// @version 1.0
// @description Groups "Add relationship" edits within 5 minutes with the same editor and edit noted.
// @author Gemini + cg505
// @match *://musicbrainz.org/*/edits*
// @match *://beta.musicbrainz.org/*/edits*
// @grant GM_addStyle
// ==/UserScript==
(function() {
'use strict';
GM_addStyle(`
.mb-batch-hidden-container {
display: none;
}
.mb-batch-hidden-container.bgre-open {
display: block;
margin: 20px 0;
border: 2px solid gray;
padding: 0px 20px;
background: white;
border-radius: 0 0 10px 10px;
}
`);
// --- Helper: Date Tolerance (5 minutes) ---
function parseDate(dateStr) {
const d = new Date(dateStr);
return isNaN(d.getTime()) ? null : d.getTime();
}
function isTimeClose(timeA, timeB) {
if (timeA === timeB) return true;
const dateA = parseDate(timeA);
const dateB = parseDate(timeB);
if (!dateA || !dateB) return timeA === timeB;
return Math.abs(dateA - dateB) <= 300000; // 5 minutes
}
// --- Main Logic ---
function init() {
const edits = Array.from(document.querySelectorAll('div.edit-list'));
if (edits.length === 0) return;
let currentBatch = [];
for (let i = 0; i < edits.length; i++) {
const editEl = edits[i];
const data = extractEditData(editEl);
let isMatch = false;
if (currentBatch.length > 0) {
const prevData = currentBatch[0].data;
// Match Logic:
// 1. Is Relationship
// 2. Same Editor
// 3. Same Note Content
// 4. Same Status Text
// 5. Time within 5 minutes
if (prevData.isRelationship && data.isRelationship &&
prevData.editorName && prevData.editorName === data.editorName &&
prevData.noteText === data.noteText &&
prevData.statusText === data.statusText &&
prevData.timestamp && isTimeClose(prevData.timestamp, data.timestamp)) {
isMatch = true;
}
}
if (isMatch) {
currentBatch.push({ el: editEl, data: data });
} else {
if (currentBatch.length > 1) {
renderBatch(currentBatch);
}
if (data.isRelationship) {
currentBatch = [{ el: editEl, data: data }];
} else {
currentBatch = [];
}
}
}
if (currentBatch.length > 1) {
renderBatch(currentBatch);
}
}
function extractEditData(editEl) {
const header = editEl.querySelector('.edit-header');
const isRelationship = header && header.classList.contains('add-relationship');
if (!isRelationship) return { isRelationship: false };
// --- Header Data ---
// We capture the exact subheader HTML from the header to reuse it
const headerSubheaderEl = header.querySelector('.subheader');
const headerSubheaderHtml = headerSubheaderEl ? headerSubheaderEl.innerHTML : '';
// Target the <bdi> tag to get just the username, ignoring the timestamp
const nameEl = headerSubheaderEl ? headerSubheaderEl.querySelector('bdi') : null;
const editorName = nameEl ? nameEl.textContent.trim() : null;
// --- Note Data ---
// Used for comparison and for the bottom note section
const dateEl = editEl.querySelector('.edit-notes .date');
const noteTextEl = editEl.querySelector('.edit-note-text');
// --- Status Data ---
const voteCountEl = editEl.querySelector('.vote-count');
const expirationEl = editEl.querySelector('.edit-expiration');
const timestamp = expirationEl ? expirationEl.textContent.trim().replace(new RegExp('^Closed: '), '') : null;
// --- Content Data ---
const detailsTd = editEl.querySelector('.edit-details table.details td');
// --- ID ---
const editIdInput = header.parentNode.querySelector('input[name*="edit_id"]');
const editId = editIdInput ? editIdInput.value : '0';
return {
isRelationship: true,
editorName: editorName,
timestamp: timestamp,
noteText: noteTextEl ? noteTextEl.textContent.trim() : '',
// HTML chunks for rendering
headerSubheaderHtml: headerSubheaderHtml,
detailsHtml: detailsTd ? detailsTd.innerHTML : '',
headerClasses: header.className,
statusText: voteCountEl ? voteCountEl.textContent.trim() : '',
statusHtml: voteCountEl ? voteCountEl.innerHTML : '',
expirationHtml: expirationEl ? expirationEl.innerHTML : '',
editId: editId
};
}
function renderBatch(batch) {
const firstEdit = batch[0].el;
const parent = firstEdit.parentNode;
const meta = batch[0].data;
const count = batch.length;
// Create Main Container
const container = document.createElement('div');
container.className = 'edit-list';
// 1. Create HEADER
const header = document.createElement('div');
header.className = meta.headerClasses;
header.innerHTML = `
<div class="edit-description">
<table>
<tr>
<td>
<div class="my-vote"><strong>Batch: </strong>${count} edits</div>
</td>
<td class="vote-count">${meta.statusHtml}</td>
</tr>
<tr>
<td class="edit-expiration" colSpan="2">${meta.expirationHtml}</td>
</tr>
</table>
</div>
<h2>
Batch: ${count} "Add relationship" edits
</h2>
<p class="subheader">
${meta.headerSubheaderHtml}
</p>
`;
// 2. Create ACTIONS (Expand/Hide Button)
const actions = document.createElement('div');
actions.className = 'edit-actions c';
actions.innerHTML = `
<div class="cancel-edit buttons">
<button class="positive batch-toggle-btn" type="button">Expand</button>
</div>
`;
// 3. Create DETAILS TABLE
const detailsDiv = document.createElement('div');
detailsDiv.className = 'edit-details';
const table = document.createElement('table');
table.className = 'details';
let tableHtml = '';
batch.forEach(item => {
const thContent = `<a href="/edit/${item.data.editId}">Edit #${item.data.editId}</a> - Relationship:`;
tableHtml += `
<tr>
<th>${thContent}</th>
<td>${item.data.detailsHtml}</td>
</tr>
`;
});
table.innerHTML = tableHtml;
detailsDiv.appendChild(table);
// 4. Prepare SHARED EDIT NOTE
let noteClone = null;
const originalNoteContainer = firstEdit.querySelector('.edit-notes');
if (originalNoteContainer) {
noteClone = originalNoteContainer.cloneNode(true);
noteClone.querySelectorAll('[id]').forEach(el => el.removeAttribute('id'));
}
// 5. Create separator
const separatorDiv = document.createElement('div');
separatorDiv.className = 'seperator';
// 6. Create HIDDEN CONTAINER
const hiddenContainer = document.createElement('div');
hiddenContainer.className = 'mb-batch-hidden-container';
// --- ASSEMBLY ORDER ---
parent.insertBefore(container, firstEdit);
container.appendChild(header);
container.appendChild(actions);
container.appendChild(detailsDiv);
if (noteClone) {
container.appendChild(noteClone);
}
container.appendChild(separatorDiv);
container.appendChild(hiddenContainer);
// Move original edits
batch.forEach(item => {
hiddenContainer.appendChild(item.el);
});
// --- Interaction ---
const toggleBtn = actions.querySelector('.batch-toggle-btn');
const toggleLogic = (e) => {
if(e) {
e.preventDefault();
e.stopPropagation();
}
const isOpen = hiddenContainer.classList.toggle('bgre-open');
toggleBtn.textContent = isOpen ? "Hide" : "Expand";
};
toggleBtn.addEventListener('click', toggleLogic);
header.addEventListener('click', toggleLogic);
}
init();
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment