Created
November 29, 2025 01:12
-
-
Save cg505/893e62e18e2224c717ac90cb1b78df57 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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