Click buttons across tabs

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

// ==UserScript==
// @name        Click buttons across tabs
// @namespace   https://musicbrainz.org/user/chaban
// @version     2.1
// @tag         ai-created
// @description Clicks specified buttons across tabs using the BroadcastChannel 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 - A single hostname string or an array of hostname strings.
     * @property {string|string[]} paths - A single path string or an array of path strings (must match ending of pathname).
     * @property {string} channelName - The name of the BroadcastChannel to use for this site's button click.
     * @property {string} messageTrigger - The message data that triggers the button click.
     * @property {string} buttonSelector - The CSS selector for the button to be clicked.
     * @property {string} menuCommandName - The name to display in the Tampermonkey/Greasemonkey menu for the click action.
     * @property {(RegExp|string)[]} [successUrlPatterns] - An array of RegExp or string patterns to match against the URL to indicate a successful submission.
     * @property {boolean} [shouldCloseAfterSuccess=false] - Whether to attempt closing the tab after a successful submission. Note: Browser security heavily restricts `window.close()`.
     */

    /**
     * Configuration for different websites and their button click settings.
     * @type {SiteConfig[]}
     */
    const siteConfigurations = [
        {
            hostnames: ['musicbrainz.org'],
            paths: [
                '/edit',
                '/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\/[^/]+\/[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)';

    // --- Constants for click delay calculation ---
    const MIN_CLICK_DELAY_SECONDS = 0;
    const DELAY_SCALING_FACTOR = 120;
    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';
    let registeredMenuCommandIDs = [];

    /**
     * Sends a message to the specified BroadcastChannel.
     * @param {string} channelName
     * @param {string} message
     */
    function sendMessageToChannel(channelName, message) {
        try {
            new BroadcastChannel(channelName).postMessage(message);
            console.log(`[${scriptName}] Sent message "${message}" to channel "${channelName}".`);
        } catch (error) {
            console.error(`[${scriptName}] Error sending message to channel "${channelName}":`, error);
        }
    }

    /**
     * Checks if the current page indicates a successful submission based on the given config's URL patterns.
     * @param {SiteConfig} config - The site configuration.
     * @param {boolean} [quiet=false] - If true, suppresses console logs for matches/no matches.
     * @returns {boolean}
     */
    function isSubmissionSuccessful(config, quiet = false) {
        if (!config.successUrlPatterns || config.successUrlPatterns.length === 0) {
            return false;
        }

        for (const pattern of config.successUrlPatterns) {
            const matchResult = (typeof pattern === 'string') ? location.href.includes(pattern) : pattern.test(location.href);
            if (matchResult) {
                if (!quiet) {
                    console.log(`[${scriptName}] URL "${location.href}" matches success pattern "${pattern}".`);
                }
                return true;
            }
        }
        if (!quiet) {
            console.log(`[${scriptName}] URL "${location.href}" does not match any success pattern.`);
        }
        return false;
    }

    /**
     * Attempts to close the current tab.
     * @param {number} delayMs - Optional delay in milliseconds before attempting to close.
     * @returns {void}
     */
    function attemptCloseTab(delayMs = 200) {
        console.log(`[${scriptName}] Attempting to close tab in ${delayMs}ms.`);
        setTimeout(() => {
            try {
                window.close();
                console.log(`[${scriptName}] Successfully called window.close() after delay.`);
            } catch (e) {
                console.warn(`[${scriptName}] Failed to close tab automatically via window.close(). This is expected due to browser security restrictions.`, e);
            }
        }, delayMs);
    }

    /**
     * Checks the current page against the success criteria for the relevant site,
     * and closes the tab if successful, unless auto-close is disabled.
     * @param {SiteConfig} currentConfigForSuccessCheck - The site config relevant to the current hostname.
     * @returns {Promise<void>}
     */
    async function checkAndCloseIfSuccessful(currentConfigForSuccessCheck) {
        const storedSubmissionState = sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG);
        if (!storedSubmissionState) {
            return;
        }

        let submissionData;
        try {
            submissionData = JSON.parse(storedSubmissionState);
        } catch (e) {
            console.error(`[${scriptName}] Error parsing submission state from sessionStorage:`, e);
            sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);
            return;
        }

        const { triggered } = submissionData;
        const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);

        if (triggered && currentConfigForSuccessCheck.shouldCloseAfterSuccess) {
            console.log(`[${scriptName}] Checking for submission success on "${location.href}".`);
            if (isSubmissionSuccessful(currentConfigForSuccessCheck)) {
                if (disableAutoClose) {
                    console.info(`%c[${scriptName}] Submission successful, but automatic tab closing is DISABLED by user setting.`, 'color: orange; font-weight: bold;');
                    sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);
                } else {
                    console.log(`%c[${scriptName}] Submission successful. Closing tab.`, 'color: green; font-weight: bold;');
                    sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);
                    attemptCloseTab();
                }
            } else {
                console.info(`%c[${scriptName}] Submission was triggered, but URL does not match success criteria.`, 'color: orange;');
            }
        } else {
            console.log(`[${scriptName}] Not checking for success: triggered is false, or closing is not configured for this site.`);
        }
    }

    /**
     * Retrieves the current delay mode from storage.
     * @returns {Promise<string>} 'dynamic' or 'static'. Defaults to 'dynamic'.
     */
    async function getDelayMode() {
        return await GM.getValue(DELAY_MODE_SETTING, 'dynamic');
    }

    /**
     * Determines and returns the current minimum and maximum click delays based on settings.
     * @returns {Promise<{min: number, max: number}>}
     */
    async function getCurrentClickDelayRange() {
        const delayMode = await getDelayMode();

        let currentMinDelay;
        let currentMaxDelay;
        let delayModeLog;

        if (delayMode === 'static') {
            currentMinDelay = 0;
            currentMaxDelay = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6);
            delayModeLog = `Delays set to STATIC (random 0 to ${currentMaxDelay}s).`;
        } else {
            const logicalCores = Math.max(1, navigator.hardwareConcurrency || 1);
            currentMinDelay = MIN_CLICK_DELAY_SECONDS;
            currentMaxDelay = Math.max(currentMinDelay, Math.round(DELAY_SCALING_FACTOR / logicalCores));
            delayModeLog = `Delays set to DYNAMIC (based on ${logicalCores} cores): ${currentMinDelay} to ${currentMaxDelay} seconds.`;
        }

        console.log(`[${scriptName}] ${delayModeLog}`);
        return { min: currentMinDelay, max: currentMaxDelay };
    }


    /**
     * Sets up menu commands for the userscript.
     * This function is responsible for clearing and re-registering ALL commands
     * to ensure dynamic display names and conditional commands work correctly.
     */
    async function setupMenuCommands() {
        registeredMenuCommandIDs.forEach(id => {
            try {
                GM_unregisterMenuCommand(id);
            } catch (e) {
              }
        });
        registeredMenuCommandIDs = [];

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

        const delayMode = await getDelayMode();
        const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);

        // --- 1. Delay Mode Toggle Command ---
        if (delayMode === 'dynamic') {
            registerCommand('Delay Mode: DYNAMIC', async () => {
                await GM.setValue(DELAY_MODE_SETTING, 'static');
                console.log(`[${scriptName}] Delay mode switched to: STATIC.`);
                await getCurrentClickDelayRange();
                await setupMenuCommands();
            });
        } else {
            registerCommand('Delay Mode: STATIC', async () => {
                await GM.setValue(DELAY_MODE_SETTING, 'dynamic');
                console.log(`[${scriptName}] Delay mode switched to: DYNAMIC.`);
                await getCurrentClickDelayRange();
                await setupMenuCommands();
            });

            const currentStaticMaxValue = await GM.getValue(STATIC_MAX_DELAY_SETTING, 6);

            // --- 2. Set Static Max Delay Command (only in static mode) ---
            registerCommand(`Set Static Max Delay (Current: ${currentStaticMaxValue}s)`, async () => {
                let newValue = prompt(`Enter maximum STATIC delay in seconds (Current: ${currentStaticMaxValue}). A random delay between 0 and this value (inclusive) will be used.`);
                if (newValue === null) return;

                newValue = parseFloat(newValue);
                if (isNaN(newValue) || newValue < 0) {
                    console.warn(`[${scriptName}] Invalid input for static max delay: "${newValue}". Please enter a non-negative number.`);
                    alert("Invalid input. Please enter a non-negative number.");
                    return;
                }
                const actualNewValue = Math.floor(newValue);
                await GM.setValue(STATIC_MAX_DELAY_SETTING, actualNewValue);
                console.log(`[${scriptName}] Static maximum delay set to ${actualNewValue} seconds.`);
                await getCurrentClickDelayRange();
                await setupMenuCommands();
            });
        }

        // --- 3. Auto Close Tabs Toggle Command ---
        registerCommand(`Auto Close Tabs: ${disableAutoClose ? 'DISABLED' : 'ENABLED'}`, async () => {
            const currentState = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);
            const newState = !currentState;
            await GM.setValue(DISABLE_AUTO_CLOSE_SETTING, newState);
            console.log(`[${scriptName}] Automatic tab closing is now ${newState ? 'DISABLED' : 'ENABLED'}.`);
            await setupMenuCommands();
        });


        // --- 4. Register site-specific button click commands ---
        const currentHostname = location.hostname;
        const currentPathname = location.pathname;

        const configForButtonClicking = siteConfigurations.find(config => {
            const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames];
            const hostnameMatches = hostnames.some(hostname => currentHostname.endsWith(hostname));
            if (hostnameMatches) {
                const paths = Array.isArray(config.paths) ? config.paths : [config.paths];
                return paths.some(pathPattern => currentPathname.endsWith(pathPattern));
            }
            return false;
        });

        if (configForButtonClicking && configForButtonClicking.menuCommandName) {
            registerCommand(configForButtonClicking.menuCommandName, () => {
                const submissionState = { triggered: true };
                sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, JSON.stringify(submissionState));
                console.log(`[${scriptName}] Menu command triggered. Stored submission state: ${JSON.stringify(submissionState)}.`);
                sendMessageToChannel(configForButtonClicking.channelName, configForButtonClicking.messageTrigger);
            });
            console.log(`[${scriptName}] Registered menu command "${configForButtonClicking.menuCommandName}".`);
        }

        // --- 5. Register Global close tab command ---
        const configForSuccessCheck = siteConfigurations.find(config => {
            const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames];
            return hostnames.some(hostname => currentHostname.endsWith(hostname)) && config.shouldCloseAfterSuccess;
        });

        if (configForSuccessCheck && isSubmissionSuccessful(configForSuccessCheck, true)) {
            registerCommand(GLOBAL_CLOSE_TAB_MENU_COMMAND_NAME, () => {
                console.log(`[${scriptName}] Global close menu command triggered.`);
                sendMessageToChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME, GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER);
            });
            console.log(`[${scriptName}] Registered global close menu command.`);
        }

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


    /**
     * Main initialization function for the userscript.
     * This function focuses on setting up event listeners and core logic.
     */
    async function initializeScript() {
        const currentHostname = location.hostname;
        const currentPathname = location.pathname;

        let configForButtonClicking = null;
        let configForSuccessCheck = null;

        // --- Find relevant configurations ---
        for (const config of siteConfigurations) {
            const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames];
            const hostnameMatches = hostnames.some(hostname => currentHostname.endsWith(hostname));

            if (hostnameMatches) {
                const paths = Array.isArray(config.paths) ? config.paths : [config.paths];
                const pathMatchesForClicking = paths.some(pathPattern => currentPathname.endsWith(pathPattern));

                if (pathMatchesForClicking) {
                    configForButtonClicking = config;
                    console.log(`[${scriptName}] Found button-clicking config: ${config.channelName}`);
                }

                if (config.shouldCloseAfterSuccess) {
                    configForSuccessCheck = config;
                    console.log(`[${scriptName}] Identified success-check config for this hostname: ${config.channelName}`);
                }
            }
        }

        // --- Get and log current delay range ---
        await getCurrentClickDelayRange();

        // --- Log current auto-close state ---
        const disableAutoClose = await GM.getValue(DISABLE_AUTO_CLOSE_SETTING, false);
        console.log(`[${scriptName}] Automatic tab closing is currently ${disableAutoClose ? 'DISABLED' : 'ENABLED'}.`);


        // --- Part 1: Setup BroadcastChannel listener for button clicks ---
        if (configForButtonClicking) {
            console.log(`[${scriptName}] Setting up button-clicking logic for "${configForButtonClicking.channelName}".`);
            try {
                const channel = new BroadcastChannel(configForButtonClicking.channelName);
                channel.addEventListener('message', async (event) => {
                    if (event.data === configForButtonClicking.messageTrigger) {
                        console.log(`[${scriptName}] Received trigger message "${event.data}".`);
                        const btn = document.querySelector(configForButtonClicking.buttonSelector);
                        if (btn) {
                            const submissionState = { triggered: true };
                            sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, JSON.stringify(submissionState));
                            console.log(`[${scriptName}] Stored submission state: ${JSON.stringify(submissionState)}.`);

                            const { min: currentMinDelay, max: currentMaxDelay } = await getCurrentClickDelayRange();
                            const delaySeconds = Math.floor(Math.random() * (currentMaxDelay - currentMinDelay + 1)) + currentMinDelay;
                            const delayMs = delaySeconds * 1000;

                            console.log(`[${scriptName}] Attempting to click button in ${delaySeconds} seconds with selector "${configForButtonClicking.buttonSelector}".`);
                            setTimeout(() => {
                                btn.click();
                            }, delayMs);

                        } else {
                            console.warn(`[${scriptName}] Button with selector "${configForButtonClicking.buttonSelector}" not found.`);
                        }
                    }
                });
                console.log(`[${scriptName}] Listener active for button clicks on channel "${configForButtonClicking.channelName}".`);
            } catch (error) {
                console.error(`[${scriptName}] Error initializing BroadcastChannel:`, error);
            }
        } else {
            console.log(`[${scriptName}] No button-clicking config found for this page.`);
        }

        // --- Part 2: Immediate check for pending submission success & URL change monitoring ---
        if (configForSuccessCheck) {
            await checkAndCloseIfSuccessful(configForSuccessCheck);

            let lastUrl = location.href;
            const urlChangedHandler = async () => {
                if (location.href !== lastUrl) {
                    console.log(`%c[${scriptName}] URL changed from "${lastUrl}" to "${location.href}". Re-checking for success.`, 'color: purple; font-weight: bold;');
                    lastUrl = location.href;
                    await checkAndCloseIfSuccessful(configForSuccessCheck);
                }
            };

            window.addEventListener('popstate', urlChangedHandler);
            const originalPushState = history.pushState;
            history.pushState = function() {
                originalPushState.apply(this, arguments);
                urlChangedHandler();
            };
            const originalReplaceState = history.replaceState;
            history.replaceState = function() {
                originalReplaceState.apply(this, arguments);
                urlChangedHandler();
            };
            console.log(`[${scriptName}] URL change listeners activated.`);

            // --- Part 3: Setup GLOBAL BroadcastChannel listener for closing tabs ---
            if (isSubmissionSuccessful(configForSuccessCheck)) {
                console.log(`[${scriptName}] Current URL matches a success pattern. Setting up global close listener.`);
                try {
                    const globalCloseChannel = new BroadcastChannel(GLOBAL_CLOSE_TAB_CHANNEL_NAME);
                    globalCloseChannel.addEventListener('message', (event) => {
                        if (event.data === GLOBAL_CLOSE_TAB_MESSAGE_TRIGGER) {
                            console.log(`[${scriptName}] Received global close request.`);
                            attemptCloseTab(50);
                        }
                    });
                    console.log(`[${scriptName}] Global close channel listener active.`);
                } catch (error) {
                    console.error(`[${scriptName}] Error initializing global BroadcastChannel:`, error);
                }
            } else {
                console.log(`[${scriptName}] Current URL does NOT match a success pattern. Global close listener skipped.`);
            }

        } else {
            console.log(`[${scriptName}] No success-check config found for this hostname. Not monitoring for closure.`);
            sessionStorage.removeItem(SUBMISSION_TRIGGERED_FLAG);
        }

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

    await setupMenuCommands();
    await initializeScript();

})();