Greasy Fork is available in English.

Click buttons across tabs

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

As of 14.06.2025. See ბოლო ვერსია.

// ==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();

})();