您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Aligns columns in merge edit tables for easier comparison.
// ==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(); })();