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 12.06.2025. Se den seneste versjonen.

// ==UserScript==
// @name        Click buttons across tabs
// @namespace   https://musicbrainz.org/user/chaban
// @version     2.0
// @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       window.close
// ==/UserScript==

(function () {
    'use strict';

    const scriptName = GM.info.script.name;

    /**
     * @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)';

    /**
     * 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.
     * @returns {boolean}
     */
    function isSubmissionSuccessful(config) {
        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) {
                console.log(`[${scriptName}] URL "${location.href}" matches success pattern "${pattern}".`);
                return true;
            }
        }
        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.
     * @param {SiteConfig} currentConfigForSuccessCheck - The site config relevant to the current hostname.
     */
    function checkAndCloseIfSuccessful(currentConfigForSuccessCheck) {
        const storedSubmissionState = sessionStorage.getItem(SUBMISSION_TRIGGERED_FLAG);
        if (!storedSubmissionState) {
            return; // No pending submission
        }

        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;

        if (triggered && currentConfigForSuccessCheck.shouldCloseAfterSuccess) {
            console.log(`[${scriptName}] Checking for submission success on "${location.href}".`);
            if (isSubmissionSuccessful(currentConfigForSuccessCheck)) {
                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.`);
        }
    }


    /**
     * Main initialization function for the userscript.
     */
    function initializeScript() {
        console.log(`%c[${scriptName}] Script initialized on ${location.href}`, 'color: blue; font-weight: bold;');
        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}`);
                }
            }
        }


        // --- Part 1: Setup BroadcastChannel listener and GM_registerMenuCommand 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', (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)}.`);
                            btn.click();
                        } 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);
            }

            if (typeof GM_registerMenuCommand !== 'undefined' && configForButtonClicking.menuCommandName) {
                GM_registerMenuCommand(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}".`);
            }
        } 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) {
            // Immediate check on page load (due to @run-at document-start)
            checkAndCloseIfSuccessful(configForSuccessCheck);

            // Setup URL change monitoring for pages that might update via history API
            let lastUrl = location.href;
            const urlChangedHandler = () => {
                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;
                    checkAndCloseIfSuccessful(configForSuccessCheck);
                }
            };

            // Listen for popstate events
            window.addEventListener('popstate', urlChangedHandler);

            // Monkey-patch history API methods
            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 and GM_registerMenuCommand for closing tabs ---
            if (isSubmissionSuccessful(configForSuccessCheck)) {
                console.log(`[${scriptName}] Current URL matches a success pattern. Setting up global close functionality.`);
                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 close BroadcastChannel:`, error);
                }

                if (typeof GM_registerMenuCommand !== 'undefined') {
                    GM_registerMenuCommand(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.`);
                }
            } else {
                console.log(`[${scriptName}] Current URL does NOT match a success pattern. Global close functionality skipped.`);
            }

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

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

    initializeScript();
})();