MusicBrainz: Align Columns in "Merge Recordings"

Aligns the columns in "Merge Recordings" edits for easier comparison.

// ==UserScript==
// @name        MusicBrainz: Align Columns in "Merge Recordings"
// @namespace   https://musicbrainz.org/user/chaban
// @version     1.0.1
// @tag         ai-created
// @description Aligns the columns in "Merge Recordings" edits 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       none
// @run-at      document-end
// ==/UserScript==

(function() {
    'use strict';

    const CONTEXT_SELECTOR = 'table.details.merge-recordings';
    const RIGID_COLUMNS = ['ISRCs', 'Length', 'AcoustIDs'];

    const resetStyles = (tables) => {
        tables.forEach(table => {
            table.style.cssText = '';
            table.querySelectorAll('thead th').forEach(th => th.style.width = '');
            Array.from(table.rows).forEach(row => row.style.height = '');
        });
    };

    const calculateColumnWidths = (tables, headerMaps) => {
        const allHeaderNames = [...new Set(headerMaps.flat())];
        const columnWidths = new Map(allHeaderNames.map(name => [name, 0]));
        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) {
                        const currentMaxWidth = columnWidths.get(headerName) || 0;
                        const cellWidth = cell.scrollWidth + 8;
                        if (cellWidth > currentMaxWidth) {
                            columnWidths.set(headerName, cellWidth);
                        }
                    }
                });
            });
        });
        return columnWidths;
    };

    const applyColumnWidths = (tables, columnWidths, headerMaps) => {
        tables.forEach((table, tableIndex) => {
            table.style.tableLayout = 'fixed';
            table.style.width = '100%';
            const headers = table.querySelectorAll('thead th');
            const currentHeaderNames = headerMaps[tableIndex];

            let totalRigidWidth = 0;
            const flexibleColumnCount = currentHeaderNames.filter(name => !RIGID_COLUMNS.includes(name)).length;

            headers.forEach((header, index) => {
                const headerName = currentHeaderNames?.[index];
                if (headerName && RIGID_COLUMNS.includes(headerName)) {
                    const width = columnWidths.get(headerName);
                    if (width > 0) {
                        header.style.width = `${width}px`;
                        totalRigidWidth += width;
                    }
                }
            });

            if (flexibleColumnCount > 0) {
                const remainingWidth = `calc(${100 / flexibleColumnCount}% - ${totalRigidWidth / flexibleColumnCount}px)`;
                headers.forEach((header, index) => {
                    const headerName = currentHeaderNames?.[index];
                    if (headerName && !RIGID_COLUMNS.includes(headerName)) {
                        header.style.width = remainingWidth;
                    }
                });
            }
        });
    };

    const alignRowHeights = (tables) => {
        requestAnimationFrame(() => {
            const maxRows = Math.max(...tables.map(table => table.rows.length));
            for (let i = 0; i < maxRows; i++) {
                const correspondingRows = tables.map(table => table.rows[i]).filter(Boolean);
                if (correspondingRows.length < 2) continue;

                correspondingRows.forEach(row => row.style.height = 'auto');
                const maxHeight = Math.max(...correspondingRows.map(row => row.getBoundingClientRect().height));
                correspondingRows.forEach(row => {
                    row.style.height = `${maxHeight}px`;
                });
            }
        });
    };

    const alignTables = (tables) => {
        if (!tables || tables.length < 2) return;
        resetStyles(tables);
        const headerMaps = tables.map(table =>
            Array.from(table.querySelectorAll('thead th')).map(th => th.textContent.trim())
        );
        if (headerMaps.some(headers => headers.length === 0)) return;
        const columnWidths = calculateColumnWidths(tables, headerMaps);
        applyColumnWidths(tables, columnWidths, headerMaps);
        alignRowHeights(tables);
    };

    const main = () => {
        const contexts = document.querySelectorAll(CONTEXT_SELECTOR);

        contexts.forEach(context => {
            const tables = Array.from(context.querySelectorAll('.tbl'));
            if (tables.length >= 2) {
                alignTables(tables);

                const observer = new MutationObserver(() => {
                    clearTimeout(context._alignTimeout);
                    context._alignTimeout = setTimeout(() => alignTables(tables), 150);
                });

                tables.forEach(table => {
                    observer.observe(table.querySelector('tbody'), {
                        childList: true,
                        subtree: true
                    });
                });
            }
        });
    };

    window.addEventListener('load', main);

})();