MusicBrainz: Align Columns in Merge Edits

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();
})();