MusicBrainz: Align Columns in Merge Edits

Aligns columns in merge edit tables for easier comparison.

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         MusicBrainz: Align Columns in Merge Edits
// @namespace    https://musicbrainz.org/user/chaban
// @version      2.4.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*
// @icon         https://musicbrainz.org/static/images/favicons/android-chrome-512x512.png
// @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,
    };

    function time(name, func) {
        const startTime = performance.now();
        if (DEBUG) console.time(`[${SCRIPT_NAME}] ${name}`);
        func();
        const endTime = performance.now();
        if (DEBUG) console.timeEnd(`[${SCRIPT_NAME}] ${name}`);
        return endTime - startTime;
    }

    function debounce(func, wait) {
        let timeout;
        return function executedFunction(...args) {
            const later = () => {
                clearTimeout(timeout);
                func(...args);
            };
            clearTimeout(timeout);
            timeout = setTimeout(later, wait);
        };
    }

    class ReactiveConfig {
        #options = {};
        #listeners = {};
        #configDefinition;

        constructor(configDefinition) {
            this.#configDefinition = configDefinition;
            configDefinition.forEach(opt => {
                this.#listeners[opt.name] = [];
            });
        }

        async load() {
            await Promise.all(
                this.#configDefinition.map(async (opt) => {
                    this.#options[opt.name] = await GM.getValue(opt.key, opt.defaultValue);
                })
            );
        }

        get(optionName) {
            return this.#options[optionName];
        }

        getAll() {

            return { ...this.#options };

        }
        async update(optionName, newValue) {
            if (this.#options[optionName] === newValue) return;

            this.#options[optionName] = newValue;
            const config = this.#configDefinition.find(opt => opt.name === optionName);
            if (config) {
                await GM.setValue(config.key, newValue);
            }
            this.#listeners[optionName]?.forEach(callback => callback(newValue));
        }

        subscribe(optionName, callback) {
            this.#listeners[optionName]?.push(callback);
            callback(this.#options[optionName]);
        }
    }

    class TableAligner {
        #contextElement;
        #tables;
        #styleElement;
        #observer;
        #config;
        #uniqueId;
        #observedNodes;
        #scheduler;

        constructor(contextElement, config, scheduler) {
            this.#contextElement = contextElement;
            this.#config = config;
            this.#scheduler = scheduler;
            this.#tables = Array.from(contextElement.querySelectorAll('.tbl'));
            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.#setupObserver();
            this.#subscribeToConfigChanges();
        }

        runAlignment() {
            if (this.#tables.some(table => !document.body.contains(table))) {
                this.disconnect();
                return 0;
            }

            if (DEBUG) console.log(`%c[${SCRIPT_NAME}] Running alignment for ${this.#uniqueId}...`, 'font-weight: bold; color: royalblue;');
            this.#observer.disconnect();

            let duration = 0;
            try {
                duration = time(`Alignment for ${this.#uniqueId}`, () => {
                    this.#resetStyles();
                    const headerMaps = this.#getHeaderMaps();
                    if (headerMaps.some(h => h.length === 0)) return;

                    const collapsedColumns = this.#findCollapsedColumns(headerMaps);
                    const columnWidths = this.#calculateColumnWidths(headerMaps, collapsedColumns);
                    if (DEBUG) console.log(`[${SCRIPT_NAME}] Calculated column widths for ${this.#uniqueId}:`, columnWidths);
                    this.#applyColumnStyles(columnWidths, headerMaps, collapsedColumns);
                });
            } catch (error) {
                console.error(`[${SCRIPT_NAME}] Error during alignment for ${this.#uniqueId}:`, error);
            } finally {
                this.#reconnectObserver();
            }
            return duration;
        }

        #calculateColumnWidths(headerMaps, collapsedColumns) {
            const columnWidths = new Map();
            const originalStyles = new Map();
            const tempStyleElement = document.createElement('style');
            document.head.appendChild(tempStyleElement);

            try {
                const tempSelector = `[data-align-id="${this.#uniqueId}"] .tbl th, [data-align-id="${this.#uniqueId}"] .tbl td`;
                tempStyleElement.textContent = `${tempSelector} { white-space: nowrap !important; }`;
                this.#tables.forEach(t => {
                    originalStyles.set(t, t.style.cssText);
                    t.style.cssText = 'table-layout: auto; width: 1px;';
                });
                this.#contextElement.offsetHeight;
                this.#tables.forEach((table, tableIndex) => {
                    const currentHeaders = headerMaps[tableIndex];
                    table.querySelectorAll('thead th, tbody td').forEach(cell => {
                        const headerName = currentHeaders?.[cell.cellIndex];
                        if (!headerName || collapsedColumns.has(headerName)) return;
                        const width = cell.getBoundingClientRect().width;
                        columnWidths.set(headerName, Math.max(columnWidths.get(headerName) || 0, width));
                    });
                });
            } finally {
                tempStyleElement.remove();
                this.#tables.forEach(t => {
                    t.style.cssText = originalStyles.get(t);
                });
            }
            return columnWidths;
        }

        #subscribeToConfigChanges() {
            this.#config.subscribe('collapseEmpty', () => this.#scheduler(this));
            this.#config.subscribe('widenTableContainer', (shouldWiden) => {
                this.#updateContainerStyle(shouldWiden);
                this.#scheduler(this);
            });
            if (DEBUG) console.log(`[${SCRIPT_NAME}] TableAligner instance ${this.#uniqueId} subscribed to config changes.`);
        }

        #updateContainerStyle(shouldWiden) {
            this.#contextElement.querySelectorAll('tbody > tr').forEach(row => {
                const header = row.querySelector('th');
                if (header && ['Merge:', 'Into:'].includes(header.textContent.trim())) {
                    const dataCell = row.querySelector('td');
                    header.style.display = shouldWiden ? 'none' : '';
                    if (dataCell) dataCell.colSpan = shouldWiden ? 2 : 1;
                }
            });
        }

        #findCollapsedColumns(headerMaps) {
            if (!this.#config.get('collapseEmpty')) {
                return new Set();
            }
            const collapsedColumns = new Set();
            const allHeaderNames = [...new Set(headerMaps.flat())];
            for (const headerName of allHeaderNames) {
                const isCompletelyEmpty = this.#tables.every(table => {
                    const tableIndex = this.#tables.indexOf(table);
                    const colIndex = headerMaps[tableIndex].indexOf(headerName);
                    if (colIndex === -1) return true;
                    const cells = Array.from(table.querySelectorAll(`tbody td:nth-child(${colIndex + 1})`));
                    return !cells.some(cell => !this.#isCellVisuallyEmpty(cell));
                });
                if (isCompletelyEmpty) {
                    collapsedColumns.add(headerName);
                }
            }
            return collapsedColumns;
        }

        #resetStyles() {
            this.#styleElement.textContent = '';
            this.#tables.forEach(t => {
                t.style.cssText = '';
                Array.from(t.rows).forEach(r => r.style.height = '');
            });
        }

        #getHeaderMaps() {
            return this.#tables.map(t => Array.from(t.querySelectorAll('thead th')).map(th => th.textContent.trim()));
        }

        #isCellVisuallyEmpty(c) {
            const cl = c.cloneNode(true);
            cl.querySelectorAll('script').forEach(s => s.remove());
            return cl.textContent.trim() === '';
        }

        #applyColumnStyles(columnWidths, headerMaps, collapsedColumns) {
            const containerWidth = this.#contextElement.clientWidth;
            let rigidWidthTotal = 0;
            let flexibleIdealTotal = 0;
            const allVisibleHeaders = [...columnWidths.keys()].filter(n => !collapsedColumns.has(n));
            for (const n of allVisibleHeaders) {
                const w = columnWidths.get(n) || 0;
                if (CONTENT_SIZED_COLUMNS.has(n)) {
                    rigidWidthTotal += w;
                } else {
                    flexibleIdealTotal += w;
                }
            }
            const useProportional = rigidWidthTotal >= containerWidth && flexibleIdealTotal > 0;
            const cssRules = [];
            const p = `[data-align-id="${this.#uniqueId}"] .tbl`;
            for (const n of [...new Set(headerMaps.flat())]) {
                const indices = [...new Set(headerMaps.flatMap((m, ti) => m.reduce((a, name, i) => {
                    if (name === n && this.#tables[ti].querySelector(`thead th:nth-child(${i + 1})`)) a.push(i + 1);
                    return a;
                }, [])))];
                if (indices.length === 0) continue;
                const s = indices.map(i => `${p} th:nth-child(${i}), ${p} td:nth-child(${i})`).join(',\n');
                if (collapsedColumns.has(n)) {
                    cssRules.push(`${s} { display: none; }`);
                } else {
                    const w = columnWidths.get(n) || 0;
                    let ws;
                    if (useProportional) {
                        const t = rigidWidthTotal + flexibleIdealTotal;
                        ws = `width: ${t > 0 ? (w / t) * 100 : 0}%;`;
                    } else if (CONTENT_SIZED_COLUMNS.has(n)) {
                        ws = `width: ${w}px;`;
                    } else {
                        const pct = flexibleIdealTotal > 0 ? (w / flexibleIdealTotal) * 100 : 0;
                        ws = `width: calc((100% - ${rigidWidthTotal}px) * ${pct / 100});`;
                    }
                    cssRules.push(`${s} { ${ws} }`);
                }
            }
            cssRules.push(`${p} th { overflow: hidden; text-overflow: ellipsis; }`);
            this.#styleElement.textContent = cssRules.join('\n');
            this.#tables.forEach(t => {
                t.style.tableLayout = 'fixed';
                t.style.width = '100%';
            });
        }

        #setupObserver() {
            this.#observer = new MutationObserver(() => this.#scheduler(this));
            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() {
            if (this.#observer) this.#observer.disconnect();
            if (this.#styleElement) this.#styleElement.remove();
        }
    }

    async function init() {
        const OPTIONS_CONFIG = [
            { name: 'collapseEmpty', key: 'collapse-empty-columns', text: 'Collapse Empty Columns', defaultValue: true },
            { name: 'widenTableContainer', key: 'widen-table-container', text: 'Widen Table Container', defaultValue: true },
        ];

        const config = new ReactiveConfig(OPTIONS_CONFIG);
        await config.load();
        if (DEBUG) console.log(`[${SCRIPT_NAME}] Initial configuration loaded:`, config.getAll());

        const dirtyAligners = new Set();
        const runDirtyAlignments = debounce(() => {
            if (dirtyAligners.size === 0) return;
            const taskCount = dirtyAligners.size;
            if (DEBUG) console.log(`%c[${SCRIPT_NAME}] Scheduler dispatching ${taskCount} alignment tasks...`, 'font-weight: bold; color: darkgreen;');

            let completedTasks = 0;
            let totalCpuTime = 0;

            dirtyAligners.forEach(aligner => {
                setTimeout(() => {
                    const executionTime = aligner.runAlignment();
                    if (typeof executionTime === 'number') {
                        totalCpuTime += executionTime;
                    }
                    completedTasks++;

                    if (completedTasks === taskCount) {
                        if (DEBUG) console.log(`%c[${SCRIPT_NAME}] Batch of ${taskCount} tasks finished. Total CPU time: ${totalCpuTime.toFixed(2)} ms`, 'font-weight: bold; color: darkgreen;');
                    }
                }, 0);
            });

            dirtyAligners.clear();
        }, 250);


        const scheduleAlignment = (aligner) => {
            dirtyAligners.add(aligner);
            runDirtyAlignments();
        };

        document.querySelectorAll(CONTEXT_SELECTOR).forEach(context => {
            const aligner = new TableAligner(context, config, scheduleAlignment);
            if (aligner.runAlignment) {
                scheduleAlignment(aligner);
            }
        });

        const commandIds = {};
        const registerAllCommands = async () => {
            for (const id of Object.values(commandIds)) await GM.unregisterMenuCommand(id);
            for (const opt of OPTIONS_CONFIG) {
                const commandText = `${opt.text}: ${config.get(opt.name) ? 'ON' : 'OFF'}`;
                commandIds[opt.key] = await GM.registerMenuCommand(commandText, async () => {
                    await config.update(opt.name, !config.get(opt.name));
                    await registerAllCommands();
                });
            }
        };
        await registerAllCommands();
    }
    init().catch(err => console.error(`[${SCRIPT_NAME}] Initialization failed:`, err));

})();