Click buttons across tabs

Clicks specified buttons across tabs using the Broadcast Channel API and closes tabs after successful submission.

// ==UserScript==
// @name        Click buttons across tabs
// @namespace   https://musicbrainz.org/user/chaban
// @version     3.0
// @tag         ai-created
// @description Clicks specified buttons across tabs using the Broadcast Channel API and closes tabs after successful submission.
// @author      chaban
// @license     MIT
// @match       *://*.musicbrainz.org/*
// @match       *://magicisrc.kepstin.ca/*
// @match       *://magicisrc-beta.kepstin.ca/*
// @run-at      document-start
// @grant       GM.info
// @grant       GM_registerMenuCommand
// @grant       GM_unregisterMenuCommand
// @grant       GM.getValue
// @grant       GM.setValue
// @grant       window.close
// ==/UserScript==

(async function () {
    'use strict';

    const scriptName = GM.info.script.name;
    console.log(`%c[${scriptName}] Script initialization started on ${location.href}`, 'font-weight: bold;');

    /**
     * @typedef {Object} SiteConfig
     * @property {string|string[]} hostnames
     * @property {string|string[]} paths
     * @property {string} buttonSelector
     * @property {string} [channelName]
     * @property {string} [messageTrigger]
     * @property {string} [menuCommandName]
     * @property {(RegExp|string)[]} [successUrlPatterns]
     * @property {boolean} [shouldCloseAfterSuccess=false]
     * @property {boolean} [autoClick=false]
     * @property {boolean} [disableDelay=false]
     */

    const siteConfigurations = [
        {
            hostnames: ['musicbrainz.org'],
            paths: ['/edit-relationships'],
            buttonSelector: '.rel-editor > button',
            autoClick: true,
            disableDelay: true,
            successUrlPatterns: [],
            shouldCloseAfterSuccess: false,
        },
        {
            hostnames: ['musicbrainz.org'],
            paths: ['/edit', '/edit-relationships', '/add-cover-art'],
            channelName: 'mb_edit_channel',
            messageTrigger: 'submit-edit',
            buttonSelector: 'button.submit.positive[type="submit"]',
            menuCommandName: 'MusicBrainz: Submit Edit (All Tabs)',
            successUrlPatterns: [/^https?:\/\/(?:beta\.)?musicbrainz\.org\/(?!collection\/)[^/]+\/[a-f0-9\-]{36}(?:\/cover-art)?\/?$/],
            shouldCloseAfterSuccess: true,
        },
        {
            hostnames: ['magicisrc.kepstin.ca', 'magicisrc-beta.kepstin.ca'],
            paths: ['/'],
            channelName: 'magicisrc_submit_channel',
            messageTrigger: 'submit-isrcs',
            buttonSelector: '[onclick^="doSubmitISRCs"]',
            menuCommandName: 'MagicISRC: Submit ISRCs (All Tabs)',
            successUrlPatterns: [/\?.*submit=1/],
            shouldCloseAfterSuccess: true,
        },
    ];

    const SUBMISSION_TRIGGERED_FLAG = 'broadcastChannelSubmissionState';
    const GLOBAL_CLOSE_TAB_CHANNEL_NAME = 'global_close_tab_channel';
    const GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER = 'close-this-tab';
    const GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME = 'Global: Close This Tab (All Tabs)';

    const DELAY_MODE_SETTING = 'mb_button_clicker_delayMode';
    const STATIC_MAX_DELAY_SETTING = 'mb_button_clicker_staticMaxDelay';
    const DISABLE_AUTO_CLOSE_SETTING = 'mb_button_clicker_disableAutoClose';
    const MAGICISRC_ENABLE_AUTO_RELOAD = 'magicisrc_enableAutoReload';

    let registeredMenuCommandIDs = [];

    /**
     * Executes a callback when the DOM is ready, or immediately if it's already loaded.
     * @param {Function} callback The function to execute.
     */
    function onDOMLoaded(callback) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', callback);
        } else {
            callback();
        }
    }

    /**
     * Finds all site configurations that are active for the current page URL path.
     * This is used to determine which buttons to listen for on the current page.
     * @returns {SiteConfig[]} An array of active configurations.
     */
    function getActiveConfigs() {
        const currentHostname = location.hostname;
        const currentPathname = location.pathname;
        return siteConfigurations.filter(config => {
            const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames];
            const paths = Array.isArray(config.paths) ? config.paths : [config.paths];
            const hostnameMatches = hostnames.some(h => currentHostname.includes(h));
            const pathMatches = paths.some(p => currentPathname.endsWith(p));
            return hostnameMatches && pathMatches;
        });
    }

    /**
     * Waits for a button to appear and become enabled, then clicks it after a configured delay.
     * @param {string} selector - The CSS selector for the button.
     * @param {boolean} disableDelay - If true, clicks immediately.
     * @returns {Promise<boolean>} Resolves to true if clicked, false otherwise.
     */
    async function waitForButtonAndClick(selector, disableDelay) {
        return new Promise(resolve => {
            const checkAndClick = async (obs) => {
                const btn = document.querySelector(selector);
                if (btn && !btn.disabled) {
                    console.log(`%c[${scriptName}] Button "${selector}" found and enabled. Clicking.`, 'color: green; font-weight: bold;');
                    const delayMs = await getCalculatedDelay(disableDelay);
                    setTimeout(() => btn.click(), delayMs);
                    if (obs) obs.disconnect();
                    resolve(true);
                    return true;
                }
                return false;
            };

            onDOMLoaded(() => {
                checkAndClick(null).then(clicked => {
                    if (clicked) return;
                    const observer = new MutationObserver((_, obs) => checkAndClick(obs));
                    observer.observe(document.body, { childList: true, subtree: true, attributes: true });
                });
            });
        });
    }

    /**
     * Checks if the current URL matches a success pattern for a given configuration.
     * @param {SiteConfig} config - The site configuration.
     * @param {boolean} [quiet=false] - If true, suppresses console logs.
     * @returns {boolean}
     */
    function isSubmissionSuccessful(config, quiet = false) {
        if (!config?.successUrlPatterns?.length) return false;
        const url = location.href;
        const isSuccess = config.successUrlPatterns.some(pattern =>
            (typeof pattern === 'string' ? url.includes(pattern) : pattern.test(url))
        );
        if (isSuccess && !quiet) {
            console.log(`[${scriptName}] URL "${url}" matches success pattern.`);
        }
        return isSuccess;
    }

    /**
     * Checks if a submission was successful and closes the tab if configured to do so.
     * This now also includes checking for the "no changes" banner on MusicBrainz.
     * @param {SiteConfig} config - The site configuration for success checking.
     */
    async function checkAndCloseOnSuccess(config) {
        if (!config || sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) !== 'true') return;

        const noChangesBanner = document.querySelector('.banner.warning-header');
        const isNoOpSubmission = noChangesBanner?.textContent.includes(
            'The data you have submitted does not make any changes to the data already present.'
        );

        if (isSubmissionSuccessful(config) || isNoOpSubmission) {
            if (isNoOpSubmission) {
                console.log(`[${scriptName}] Detected 'no changes' banner. Treating as success.`);
            }
            console.log(`[${scriptName}] Submission successful. Clearing flag.`);
            sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);

            const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);
            if (disableAutoClose) {
                console.info(`%c[${scriptName}] Auto-closing is DISABLED by user setting.`, 'color: orange;');
            } else {
                console.log(`%c[${scriptName}] Closing tab.`, 'color: green; font-weight: bold;');
                setTimeout(() => window.close(), 200);
            }
        }
    }

    /**
     * Sets up listeners and handlers specific to MagicISRC pages.
     */
    function setupMagicISRC() {
        if (!location.hostname.includes('magicisrc')) return;

        console.log(`[${scriptName}] MagicISRC page detected. Setting up special handlers.`);

        const script = document.createElement('script');
        script.textContent = `
            (() => {
                const post = (type, data) => window.postMessage({ source: '${scriptName}', type, ...data }, location.origin);
                const origFetch = window.fetch;
                window.fetch = (...args) => origFetch(...args).catch(err => { post('FETCH_ERROR'); throw err; });
                window.renderError = () => { post('RENDER_ERROR'); };
            })();
        `;
        document.documentElement.appendChild(script);
        script.remove();

        window.addEventListener('message', async (event) => {
            if (event.origin !== location.origin || event.data?.source !== scriptName) return;
            if (event.data.type === 'FETCH_ERROR' || event.data.type === 'RENDER_ERROR') {
                console.warn(`%c[${scriptName}] Intercepted MagicISRC error: ${event.data.type}`, 'color: red;');
                const enableReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true);
                if (enableReload) {
                    const delay = await getCalculatedDelay();
                    console.log(`Reloading in ${delay / 1000}s.`);
                    setTimeout(() => location.reload(), delay);
                }
            }
        });

        if (sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG) === 'true') {
            const config = getActiveConfigs().find(c => c.channelName === 'magicisrc_submit_channel');
            if (config) {
                waitForButtonAndClick(config.buttonSelector, config.disableDelay);
            }
        }
    }

    /**
     * Calculates a random click delay based on user settings.
     * @param {boolean} [disable=false] - If true, forces a 0ms delay.
     * @returns {Promise<number>} Delay in milliseconds.
     */
    async function getCalculatedDelay(disable = false) {
        if (disable) return 0;
        const delayMode = await GM.getValue(DELAY_MODE_SETTING, 'dynamic');
        if (delayMode === 'static') {
            const max = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6);
            return Math.floor(Math.random() * (max + 1)) * 1000;
        }
        const cores = Math.max(1, navigator.hardwareConcurrency || 1);
        const max = Math.max(0, Math.round(120 / cores));
        return Math.floor(Math.random() * (max + 1)) * 1000;
    }

    /**
     * Registers all userscript menu commands.
     */
    async function setupMenuCommands() {
        for (const commandId of registeredMenuCommandIDs) {
            try {
                GM_unregisterMenuCommand(commandId);
            } catch (e) {
                // ignore
            }
        }
        registeredMenuCommandIDs = [];

        const registerCommand = (name, func) => {
            const id = GM_registerMenuCommand(name, func);
            registeredMenuCommandIDs.push(id);
        };

        const delayMode = await GM.getValue(DELAY_MODE_SETTING, 'dynamic');
        const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);
        const enableMagicISRCReload = await GM.getValue(MAGICISRC_ENABLE_AUTO_RELOAD, true);

        registerCommand(`Delay Mode: ${delayMode === 'dynamic' ? 'DYNAMIC' : 'STATIC'}`, async () => {
            await GM.setValue(DELAY_MODE_SETTING, delayMode === 'dynamic' ? 'static' : 'dynamic');
            await setupMenuCommands();
        });

        if (delayMode === 'static') {
            const currentStaticMax = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6);
            registerCommand(`Set Static Max Delay (Current: ${currentStaticMax}s)`, async () => {
                const newValue = prompt(`Enter new max delay in seconds:`, currentStaticMax);
                if (newValue !== null && !isNaN(newValue) && parseFloat(newValue) >= 0) {
                    await GM.setValue(STATIC_MAX_DELAY_SETTING, parseFloat(newValue));
                    await setupMenuCommands();
                }
            });
        }

        registerCommand(`Auto Close Tabs: ${disableAutoClose ? 'DISABLED' : 'ENABLED'}`, async () => {
            await GM.setValue(DISABLE_AUTO_CLOSE_SETTING, !disableAutoClose);
            await setupMenuCommands();
        });

        registerCommand(`MagicISRC Auto Reload: ${enableMagicISRCReload ? 'ENABLED' : 'ENABLED'}`, async () => {
            await GM.setValue(MAGICISRC_ENABLE_AUTO_RELOAD, !enableMagicISRCReload);
            await setupMenuCommands();
        });

        const activeConfigs = getActiveConfigs();
        const configsForMenu = activeConfigs.filter(c => !c.autoClick && c.menuCommandName);

        for (const config of configsForMenu) {
            registerCommand(config.menuCommandName, () => {
                const channel = new BroadcastChannel(config.channelName);
                channel.postMessage(config.messageTrigger);
                channel.close();
            });
        }

        const successConfig = siteConfigurations.find(c =>
            (Array.isArray(c.hostnames) ? c.hostnames.some(h => location.hostname.includes(h)) : location.hostname.includes(c.hostnames))
            && c.shouldCloseAfterSuccess && isSubmissionSuccessful(c, true)
        );
        if (successConfig) {
            registerCommand(GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME, () => {
                const channel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME);
                channel.postMessage(GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER);
                channel.close();
            });
        }

        console.log(`[${scriptName}] Menu commands updated. Total: ${registeredMenuCommandIDs.length}`);
    }

    /**
     * Sets up listeners for broadcast channel messages to click buttons.
     * @param {SiteConfig[]} configs - The active configurations for the current page.
     */
    function setupConfigListeners(configs) {
        for (const config of configs) {
            if (config.autoClick) {
                console.log(`[${scriptName}] Setting up auto-click for "${config.buttonSelector}".`);
                waitForButtonAndClick(config.buttonSelector, config.disableDelay);
            } else if (config.channelName) {
                const channel = new BroadcastChannel(config.channelName);
                channel.onmessage = (event) => {
                    if (event.data === config.messageTrigger) {
                        console.log(`[${scriptName}] Received trigger "${event.data}". Clicking button.`);
                        sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, 'true');
                        waitForButtonAndClick(config.buttonSelector, config.disableDelay);
                    }
                };
            }
        }
    }

    /**
     * Sets up listeners for URL changes to check for submission success.
     * @param {SiteConfig} config - The active configuration that should be checked for success.
     */
    function setupSuccessHandling(config) {
        if (!config) return;

        const runCheck = () => checkAndCloseOnSuccess(config);

        if (isSubmissionSuccessful(config, true)) {
            console.log(`[${scriptName}] Success URL detected at page start, checking immediately.`);
            runCheck();
        } else {
            console.log(`[${scriptName}] Deferring success check until DOM is loaded.`);
            onDOMLoaded(runCheck);
        }

        const originalPushState = history.pushState;
        history.pushState = function(...args) {
            originalPushState.apply(this, args);
            runCheck();
        };

        const originalReplaceState = history.replaceState;
        history.replaceState = function(...args) {
            originalReplaceState.apply(this, args);
            runCheck();
        };

        window.addEventListener('popstate', runCheck);

        if (isSubmissionSuccessful(config)) {
            const closeChannel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME);
            closeChannel.onmessage = (event) => {
                if (event.data === GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER) {
                    setTimeout(() => window.close(), 50);
                }
            };
        }
    }

    /**
     * Main script entry point.
     */
    async function main() {
        await setupMenuCommands();

        const activeConfigs = getActiveConfigs();
        if (activeConfigs.length > 0) {
            setupConfigListeners(activeConfigs);
        }

        const successConfig = siteConfigurations.find(c =>
            (Array.isArray(c.hostnames) ? c.hostnames.some(h => location.hostname.includes(h)) : location.hostname.includes(c.hostnames))
            && c.shouldCloseAfterSuccess
        );

        setupMagicISRC();
        setupSuccessHandling(successConfig);

        console.log(`%c[${scriptName}] Initialization finished.`, 'font-weight: bold;');
    }

    main();

})();