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