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