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.

Fra 04.07.2025. Se den seneste versjonen.

// ==UserScript==
// @name        Click buttons across tabs
// @namespace   https://musicbrainz.org/user/chaban
// @version     2.4
// @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} buttonSelector - The CSS selector string for the button to be clicked.
     * @property {string} [channelName] - The name of the BroadcastChannel to use. Omit if autoClick is true.
     * @property {string} [messageTrigger] - The message data that triggers the button click. Omit if autoClick is true.
     * @property {string} [menuCommandName] - The name to display in the Tampermonkey/Greasemonkey menu. Omit if autoClick is true.
     * @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()`.
     * @property {boolean} [autoClick=false] - If true, the button will be clicked automatically when found, without needing a broadcast message.
     * @property {boolean} [disableDelay=false] - If true, this button click will happen immediately (0 delay).
     */

    /**
     * Configuration for different websites and their button click settings.
     * Order matters for `autoClick` rules and how `configForSuccessCheck` is found.
     * @type {SiteConfig[]}
     */
    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 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);

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

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

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


        const currentHostname = location.hostname;
        const currentPathname = location.pathname;

        const configsForMenuCommands = siteConfigurations.filter(config => {
            if (config.autoClick) return false;
            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;
        });

        for (const config of configsForMenuCommands) {
            if (config.menuCommandName) {
                registerCommand(config.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(config.channelName, config.messageTrigger);
                });
                console.log(`[${scriptName}] Registered menu command "${config.menuCommandName}".`);
            }
        }

        const configForSuccessCheck = siteConfigurations.find(config => {
            const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames];
            const hostnameMatches = hostnames.some(hostname => currentHostname.endsWith(hostname));
            return hostnameMatches && 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 configForSuccessCheck = null;

        const applicableConfigs = siteConfigurations.filter(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;
        });

        configForSuccessCheck = siteConfigurations.find(config => {
            const hostnames = Array.isArray(config.hostnames) ? config.hostnames : [config.hostnames];
            const hostnameMatches = hostnames.some(hostname => currentHostname.endsWith(hostname));
            return hostnameMatches && config.shouldCloseAfterSuccess;
        });


        await getCurrentClickDelayRange();

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


        for (const config of applicableConfigs) {
            if (config.autoClick) {
                console.log(`[${scriptName}] Setting up AUTOMATIC button-clicking logic for a rule.`);
                const findAndClickButton = async () => {
                    const btn = document.querySelector(config.buttonSelector);

                    if (btn && !btn.disabled) {
                        let delayMs = 0;
                        if (!config.disableDelay) {
                            const { min: currentMinDelay, max: currentMaxDelay } = await getCurrentClickDelayRange();
                            const delaySeconds = Math.floor(Math.random() * (currentMaxDelay - currentMinDelay + 1)) + currentMinDelay;
                            delayMs = delaySeconds * 1000;
                        } else {
                             console.log(`[${scriptName}] Delay disabled for this action as per config.`);
                        }

                        console.log(`[${scriptName}] Automatically clicking button "${btn.textContent.trim()}" in ${delayMs / 1000} seconds.`);
                        setTimeout(() => {
                            btn.click();
                        }, delayMs);
                        return true;
                    }
                    return false;
                };

                if (await findAndClickButton()) {
                    console.log(`[${scriptName}] Automatic button clicked on page load.`);
                } else {
                    const observer = new MutationObserver(async (mutations, obs) => {
                        if (await findAndClickButton()) {
                            obs.disconnect();
                            console.log(`[${scriptName}] Automatic button found and clicked via observer.`);
                        }
                    });

                    observer.observe(document.body, { childList: true, subtree: true });
                    console.log(`[${scriptName}] MutationObserver active for automatic button click.`);
                }

            } else {
                if (!config.channelName || !config.messageTrigger) {
                    console.warn(`[${scriptName}] Skipping manual button-clicking setup for a config without channelName or messageTrigger.`, config);
                    continue;
                }
                console.log(`[${scriptName}] Setting up BROADCAST CHANNEL button-clicking logic for "${config.channelName}".`);
                try {
                    const channel = new BroadcastChannel(config.channelName);
                    channel.addEventListener('message', async (event) => {
                        if (event.data === config.messageTrigger) {
                            console.log(`[${scriptName}] Received trigger message "${event.data}".`);
                            const btn = document.querySelector(config.buttonSelector);

                            if (btn) {
                                const submissionState = { triggered: true };
                                sessionStorage.setItem(SUBMISSION_TRIGGERED_FLAG, JSON.stringify(submissionState));
                                console.log(`[${scriptName}] Stored submission state: ${JSON.stringify(submissionState)}.`);

                                let delayMs = 0;
                                if (!config.disableDelay) {
                                    const { min: currentMinDelay, max: currentMaxDelay } = await getCurrentClickDelayRange();
                                    const delaySeconds = Math.floor(Math.random() * (currentMaxDelay - currentMinDelay + 1)) + currentMinDelay;
                                    delayMs = delaySeconds * 1000;
                                } else {
                                     console.log(`[${scriptName}] Delay disabled for this action as per config.`);
                                }

                                console.log(`[${scriptName}] Attempting to click button "${btn.textContent.trim()}" in ${delayMs / 1000} seconds.`);
                                setTimeout(() => {
                                    btn.click();
                                }, delayMs);
                            } else {
                                console.warn(`[${scriptName}] Button for selector "${JSON.stringify(config.buttonSelector)}" not found.`);
                            }
                        }
                    });
                    console.log(`[${scriptName}] Listener active for button clicks on channel "${config.channelName}".`);
                } catch (error) {
                    console.error(`[${scriptName}] Error initializing BroadcastChannel:`, error);
                }
            }
        }

        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.`);

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

})();