// ==UserScript==
// @name MusicBrainz: Align Columns in Merge Edits
// @namespace https://musicbrainz.org/user/chaban
// @version 2.2.1
// @tag ai-created
// @description Aligns columns in merge edit tables for easier comparison.
// @author chaban
// @license MIT
// @match *://*.musicbrainz.org/edit/*
// @match *://*.musicbrainz.org/search/edits*
// @match *://*.musicbrainz.org/*/*/edits
// @match *://*.musicbrainz.org/*/*/open_edits
// @match *://*.musicbrainz.org/user/*/edits*
// @grant GM.getValue
// @grant GM.setValue
// @grant GM.registerMenuCommand
// @grant GM.unregisterMenuCommand
// @run-at document-idle
// ==/UserScript==
(function() {
'use strict';
// --- CONFIGURATION ---
// Set to true to enable performance and status logging in the console.
const DEBUG = false;
// -------------------
const SCRIPT_NAME = GM.info.script.name;
const CONTEXT_SELECTOR = 'table[class^="details merge-"]';
const CONTENT_SIZED_COLUMNS = new Set([
'AcoustIDs', 'Attributes', 'Begin', 'Code', 'End', 'Gender', 'ISRCs',
'ISWC', 'Length', 'Lyrics languages', 'Releases', 'Type', 'Year',
'Ordering type', 'Date', 'Time'
]);
const MUTATION_OBSERVER_CONFIG = {
childList: true,
subtree: true,
characterData: true,
};
const COLLAPSE_EMPTY_COLUMNS_KEY = 'collapse-empty-columns';
const TOGGLE_COLLAPSE_COMMAND = 'Collapse Empty Columns';
/**
* Helper function for performance timing, respects the DEBUG flag.
*/
function time(name, func) {
if (DEBUG) console.time(`[${SCRIPT_NAME}] ${name}`);
const result = func();
if (DEBUG) console.timeEnd(`[${SCRIPT_NAME}] ${name}`);
return result;
}
class TableAligner {
#contextElement;
#tables;
#styleElement;
#observer;
#observedNodes;
#timeoutId = null;
#uniqueId;
#options;
constructor(contextElement, options) {
this.#contextElement = contextElement;
this.#tables = Array.from(contextElement.querySelectorAll('.tbl'));
this.#options = options;
if (this.#tables.length < 2) return;
this.#uniqueId = `mb-align-${Math.random().toString(36).substring(2, 9)}`;
this.#contextElement.dataset.alignId = this.#uniqueId;
this.#styleElement = document.createElement('style');
document.head.appendChild(this.#styleElement);
this.#widenTableContainer();
this.#setupObserver();
// Initial alignment
if (window.requestIdleCallback) {
requestIdleCallback(() => this.align(), { timeout: 500 });
} else {
setTimeout(() => this.align(), 200);
}
}
align() {
clearTimeout(this.#timeoutId);
this.#timeoutId = setTimeout(() => this.#runAlignment(), 150);
}
updateOptions(newOptions) {
this.#options = newOptions;
this.align();
}
#runAlignment() {
if (this.#tables.some(table => !document.body.contains(table))) {
console.warn(`[${SCRIPT_NAME}] Tables no longer in DOM. Disconnecting observer for ${this.#uniqueId}.`);
this.disconnect();
return;
}
if (DEBUG) console.log(`%c[${SCRIPT_NAME}] Running alignment for ${this.#uniqueId}...`, 'font-weight: bold; color: blue;');
this.#observer.disconnect();
try {
time('Total Alignment', () => {
this.#resetStyles();
const headerMaps = this.#getHeaderMaps();
if (headerMaps.some(headers => headers.length === 0)) return;
let collapsedColumns = new Set();
if (this.#options.collapseEmpty) {
collapsedColumns = this.#findCollapsedColumns(headerMaps);
}
const columnWidths = this.#calculateColumnWidths(headerMaps, collapsedColumns);
if (DEBUG) console.log(`[${SCRIPT_NAME}] Calculated column widths:`, columnWidths);
this.#applyColumnStyles(columnWidths, headerMaps, collapsedColumns);
});
} catch (error) {
console.error(`[${SCRIPT_NAME}] Error during alignment for ${this.#uniqueId}:`, error);
} finally {
this.#reconnectObserver();
}
if (DEBUG) console.log(`%c[${SCRIPT_NAME}] Alignment finished for ${this.#uniqueId}.`, 'font-weight: bold; color: blue;');
}
#widenTableContainer() {
this.#contextElement.querySelectorAll('tbody > tr').forEach(row => {
const header = row.querySelector('th');
if (header && ['Merge:', 'Into:'].includes(header.textContent.trim())) {
header.style.display = 'none';
const dataCell = row.querySelector('td');
if (dataCell) {
dataCell.colSpan = 2;
}
}
});
}
#resetStyles() {
this.#styleElement.textContent = '';
this.#tables.forEach(table => {
table.style.cssText = '';
Array.from(table.rows).forEach(row => row.style.height = '');
});
}
#getHeaderMaps() {
return this.#tables.map(table =>
Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim())
);
}
/**
* Checks if a table cell is visually empty, ignoring script tags.
* @param {HTMLTableCellElement} cell The cell to check.
* @returns {boolean} True if the cell is visually empty.
*/
#isCellVisuallyEmpty(cell) {
const cellClone = cell.cloneNode(true);
cellClone.querySelectorAll('script').forEach(script => script.remove());
return cellClone.textContent.trim() === '';
}
#findCollapsedColumns(headerMaps) {
const collapsedColumns = new Set();
const allHeaderNames = [...new Set(headerMaps.flat())];
if (DEBUG) console.log(`[${SCRIPT_NAME}] All headers found for ${this.#uniqueId}:`, allHeaderNames);
for (const headerName of allHeaderNames) {
let isCompletelyEmpty = true;
for (const table of this.#tables) {
const tableIndex = this.#tables.indexOf(table);
const columnIndex = headerMaps[tableIndex].indexOf(headerName);
if (columnIndex === -1) continue;
const cells = table.querySelectorAll(`tbody td:nth-child(${columnIndex + 1})`);
if (Array.from(cells).some(cell => !this.#isCellVisuallyEmpty(cell))) {
isCompletelyEmpty = false;
if (DEBUG) {
const nonEmptyCell = Array.from(cells).find(cell => !this.#isCellVisuallyEmpty(cell));
console.log(`[${SCRIPT_NAME}] Found content in column "${headerName}":`, nonEmptyCell.textContent, nonEmptyCell);
}
break;
}
}
if (isCompletelyEmpty) {
if (DEBUG) console.log(`[${SCRIPT_NAME}] Column "${headerName}" is completely empty and will be collapsed.`);
collapsedColumns.add(headerName);
}
}
return collapsedColumns;
}
#calculateColumnWidths(headerMaps, collapsedColumns) {
const columnWidths = new Map();
// Store original styles to restore them later
const originalStyles = new Map();
// Temporarily reset table styles for accurate content measurement
this.#tables.forEach(table => {
originalStyles.set(table, table.style.cssText);
table.style.cssText = 'table-layout: auto; width: auto;';
});
// Measure header widths
this.#tables.forEach((table, tableIndex) => {
const currentHeaders = headerMaps[tableIndex];
table.querySelectorAll('thead th').forEach((th, thIndex) => {
const headerName = currentHeaders[thIndex];
if (!headerName || collapsedColumns.has(headerName)) return;
const headerWidth = th.scrollWidth;
columnWidths.set(headerName, Math.max(columnWidths.get(headerName) || 0, headerWidth));
});
});
// Measure cell content widths
this.#tables.forEach((table, tableIndex) => {
const currentHeaders = headerMaps[tableIndex];
Array.from(table.rows).forEach(row => {
Array.from(row.cells).forEach((cell, cellIndex) => {
const headerName = currentHeaders?.[cellIndex];
if (!headerName || collapsedColumns.has(headerName) || getComputedStyle(cell).display === 'none') {
return;
}
// Store original cell style to restore it later
const originalCellStyle = cell.style.cssText;
// Temporarily apply nowrap to measure content width accurately
cell.style.whiteSpace = 'nowrap';
cell.style.width = 'auto';
let cellWidth;
if (headerName === 'ISRCs') {
// Specifically for ISRC column, measure the longest visible part
const isrcElements = cell.querySelectorAll('.isrc > a > code');
const isrcWidths = Array.from(isrcElements).map(el => el.scrollWidth);
cellWidth = Math.max(...isrcWidths, 0);
} else {
cellWidth = cell.scrollWidth;
}
// Restore original cell style
cell.style.cssText = originalCellStyle;
const currentMaxWidth = columnWidths.get(headerName) || 0;
if (cellWidth > currentMaxWidth) {
columnWidths.set(headerName, cellWidth);
}
});
});
});
// Restore original table styles
this.#tables.forEach(table => {
table.style.cssText = originalStyles.get(table);
});
return columnWidths;
}
#applyColumnStyles(columnWidths, headerMaps, collapsedColumns) {
const containerWidth = this.#contextElement.clientWidth;
let rigidWidthTotal = 0;
let flexibleIdealTotal = 0;
const allVisibleHeaders = [...columnWidths.keys()].filter(name => !collapsedColumns.has(name));
for (const name of allVisibleHeaders) {
const idealWidth = columnWidths.get(name) || 0;
if (CONTENT_SIZED_COLUMNS.has(name)) {
rigidWidthTotal += idealWidth;
} else {
flexibleIdealTotal += idealWidth;
}
}
const useProportional = rigidWidthTotal >= containerWidth && flexibleIdealTotal > 0;
const cssRules = [];
const selectorPrefix = `[data-align-id="${this.#uniqueId}"] .tbl`;
for (const headerName of [...new Set(headerMaps.flat())]) {
const indices = [...new Set(headerMaps.flatMap((map, tableIndex) =>
map.reduce((acc, name, idx) => {
if (name === headerName && this.#tables[tableIndex].querySelector(`thead th:nth-child(${idx + 1})`)) {
acc.push(idx + 1);
}
return acc;
}, [])
))];
if (indices.length === 0) continue;
const columnSelectors = indices.map(i => `${selectorPrefix} th:nth-child(${i}), ${selectorPrefix} td:nth-child(${i})`).join(',\n');
if (collapsedColumns.has(headerName)) {
cssRules.push(`${columnSelectors} { display: none; }`);
} else {
const idealWidth = columnWidths.get(headerName) || 0;
let widthStyle;
if (useProportional) {
const grandTotalIdealWidth = rigidWidthTotal + flexibleIdealTotal;
const percentage = grandTotalIdealWidth > 0 ? (idealWidth / grandTotalIdealWidth) * 100 : 0;
widthStyle = `width: ${percentage}%;`;
} else if (CONTENT_SIZED_COLUMNS.has(headerName)) {
widthStyle = `width: ${idealWidth}px;`;
} else {
const percentageOfFlexible = flexibleIdealTotal > 0 ? (idealWidth / flexibleIdealTotal) * 100 : 0;
widthStyle = `width: calc((100% - ${rigidWidthTotal}px) * ${percentageOfFlexible / 100});`;
}
cssRules.push(`${columnSelectors} { ${widthStyle} }`);
}
}
cssRules.push(`${selectorPrefix} th { overflow: hidden; text-overflow: ellipsis; }`);
this.#styleElement.textContent = cssRules.join('\n');
this.#tables.forEach(table => {
table.style.tableLayout = 'fixed';
table.style.width = '100%';
});
}
#setupObserver() {
this.#observer = new MutationObserver(() => {
if (DEBUG) console.log(`[${SCRIPT_NAME}] Mutation detected, queueing realignment for ${this.#uniqueId}.`);
this.align();
});
this.#observedNodes = this.#tables.map(t => t.querySelector('tbody')).filter(Boolean);
this.#reconnectObserver();
}
#reconnectObserver() {
this.#observedNodes.forEach(tbody => {
if (document.body.contains(tbody)) {
this.#observer.observe(tbody, MUTATION_OBSERVER_CONFIG);
}
});
}
disconnect() {
this.#observer.disconnect();
clearTimeout(this.#timeoutId);
this.#styleElement.remove();
}
}
async function init() {
let collapseEmpty = await GM.getValue(COLLAPSE_EMPTY_COLUMNS_KEY, true);
const aligners = [];
let commandId;
const registerCommand = () => {
if (commandId) {
GM.unregisterMenuCommand(commandId);
}
const commandText = `${TOGGLE_COLLAPSE_COMMAND}: ${collapseEmpty ? 'ON' : 'OFF'}`;
commandId = GM.registerMenuCommand(commandText, async () => {
collapseEmpty = !collapseEmpty;
await GM.setValue(COLLAPSE_EMPTY_COLUMNS_KEY, collapseEmpty);
aligners.forEach(aligner => aligner.updateOptions({ collapseEmpty }));
registerCommand();
});
};
registerCommand();
document.querySelectorAll(CONTEXT_SELECTOR).forEach(context => {
aligners.push(new TableAligner(context, { collapseEmpty }));
});
}
init();
})();