User:DannyS712/AutoRollbackGlobal 2.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
// <nowiki>
// Quick script to automatically handle edits globally, including optionally rolling back edits and
// adding deletion tags
//
// See also [[User:WhitePhosphorus/js/AutoUndoGlobal.js]] and [[User:DannyS712/AutoRollbackGlobal.js]]
// @author DannyS712
/* jshint maxerr: 999 */
$(() => {
const AutoRollbackGlobal = {};
window.AutoRollbackGlobal = AutoRollbackGlobal;

AutoRollbackGlobal.init = function () {
	mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:WhitePhosphorus/js/eventsource-polyfill.js&action=raw&ctype=text/javascript');
	mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:WhitePhosphorus/js/ipaddr.js&action=raw&ctype=text/javascript');
	window.document.title = 'AutoRollbackGlobal';
	$( '#firstHeading' ).text( 'AutoRollbackGlobal' );
	mw.loader.using(
		[ 'mediawiki.api', 'mediawiki.ForeignApi', 'mediawiki.util', 'oojs-ui-core', 'oojs-ui-widgets', 'oojs-ui-windows' ],
		AutoRollbackGlobal.run
	);
	window.addEventListener(
		'beforeunload',
		function ( event ) {
			event.returnValue = 'Are you sure you want to close this tab?';
		},
		false
	);
};

AutoRollbackGlobal.elements = {};
AutoRollbackGlobal.run = function () {
	mw.util.addCSS( `
		.AutoRollbackGlobal-bad {
			color: #F00;
		}
		.AutoRollbackGlobal-good {
			color: #00D100;
		}
	` );
	
	var e = {};
	e.targetUser = new OO.ui.TextInputWidget( {
		value: mw.util.getParamValue( 'targetUser' )
	} );
	e.targetUserLayout = new OO.ui.FieldLayout( e.targetUser, { label: 'User' } );
	
	e.doRollback = new OO.ui.CheckboxInputWidget();
	e.doRollbackLayout = new OO.ui.FieldLayout( e.doRollback, { label: 'Try to rollback new edits' } );
	
	e.botRollback = new OO.ui.CheckboxInputWidget();
	e.botRollbackLayout = new OO.ui.FieldLayout( e.botRollback, { label: 'Mark rollbacks as bot edits' } );
	
	e.hideUsername = new OO.ui.CheckboxInputWidget();
	e.hideUsernameLayout = new OO.ui.FieldLayout( e.hideUsername, { label: 'Hide username in rollback summary (Used for grossly inappropriate username)' } );
	
	e.newPageAction = new OO.ui.RadioSelectInputWidget( {
		options: [
			{ data: 'nothing', label: 'Do nothing' },
			{ data: 'prependVandalism', label: 'Prepend {{delete|Vandalism}}' }
		]
	} );
	e.newPageActionLayout = new OO.ui.FieldLayout( e.newPageAction, { label: 'Action on new page creations' } );
	
	e.audioNotification = new OO.ui.CheckboxInputWidget();
	e.audioNotificationLayout = new OO.ui.FieldLayout( e.audioNotification, { label: 'Play a sound when anything happens (edit, page creation, or lock/block)' } );

	var submit = new OO.ui.ButtonInputWidget( { 
		label: 'Start [enter]',
		flags: [
			'primary',
			'destructive'
		]
	} );
	submit.on( 'click', function () {
		AutoRollbackGlobal.onSubmit();
	} );
	$( window ).on( 'keypress', function ( e ) {
		// press enter to start
		if ( e.which == 13 ) {
			submit.simulateLabelClick();
		}
	} );
	
	var fieldSet = new OO.ui.FieldsetLayout( { 
		label: 'IP ranges are fully supported. You can also use /regex/ (two backslashes are needed to escape characters).' +
				' Only the new edits will be shown after you click [start]'
	} );
	fieldSet.addItems( [
		e.targetUserLayout,
		e.doRollbackLayout,
		e.botRollbackLayout,
		e.hideUsernameLayout,
		e.newPageActionLayout,
		e.audioNotificationLayout,
		new OO.ui.FieldLayout( submit )
	] );

	var $results = $('<div>')
		.attr( 'id', 'autorollbackglobal-results' );
	$('#mw-content-text').empty().append(
		$( '<span>' ).append(
			'Source code: ',
			$( '<a>' ).attr( 'href', 'https://meta.wikimedia.org/wiki/User:DannyS712/AutoRollbackGlobal 2.js' ).text( '[[:m:User:DannyS712/AutoRollbackGlobal 2.js]]' )
		),
		fieldSet.$element,
		$( '<hr>' ),
		$results
	);
	
	AutoRollbackGlobal.elements = e;
};

AutoRollbackGlobal.isGloballyBlocked = function(change, user) {
	if ( change.wiki !== 'metawiki' ) {
		return false;
	}
	let range = null;
	try {
		range = ipaddr.parseCIDR(user);
	} catch (e) {}
	if ( ipaddr.isValid(user) || range !== null ) {
		// ip or ip range => check gblock
		if (change.title !== `User:${user}`) {
			return false;
		}
		return change.log_type === 'gblblock' && change.log_action === 'gblock2';
	} else {
		// registered => check glock
		if (change.title !== `User:${user}@global`) {
			return false;
		}
		return change.log_type === 'globalauth' && change.log_action === 'setstatus' &&
			typeof change.log_params === 'object' &&
			change.log_params[0] === 'locked' && change.log_params[1] === '(none)';
	}
};

AutoRollbackGlobal.onErrHandler = function () {
	// Shared error handler
	alert( 'Something went wrong' );
	console.log( arguments );
};

AutoRollbackGlobal.isMatch = function(user, pattern) {
	let slashIndex = pattern.indexOf('/', 1);
	if (pattern.startsWith('/') && slashIndex !== -1) {
		// check regex
		try {
			let body = pattern.slice(1, slashIndex);
			let flags = pattern.slice(slashIndex+1);
			let regex = new RegExp(body, flags);
			return regex.test(user);
		} catch (e) {}
	}
	if (ipaddr.isValid(user)) {
		try {
			// check accurately ip match
			// this is intended for IPv6 addresses as they may contain upper & lower cases
			// will throw an exception for ip ranges
			return ipaddr.parse(user).toNormalizedString() === ipaddr.parse(pattern).toNormalizedString();
		} catch (e) {}
		try {
			// check ip range
			let range = ipaddr.parseCIDR(pattern);
			return ipaddr.parse(user).match(range);
		} catch (e) {}
	}
	return user === pattern;
};

AutoRollbackGlobal.pageLink = function(wikiurl, page, title) {
	return `<a target="_blank" href="${wikiurl}?title=${page}">${(title || page)}</a>`;
};

AutoRollbackGlobal.addResult = function ( resultText, resultType ) {
	var $res = $( '<p>' ).html( resultText );
	
	resultType = resultType || '';
	if ( resultType === 'good' || resultType === 'bad' ) {
		$res.addClass( 'AutoRollbackGlobal-' + resultType );
	}
	$( '#autorollbackglobal-results' ).append( $res );
};

AutoRollbackGlobal.doWikiRollback = function ( wikiApi, rollbackToken, changeToRevert, customSummaryOrFalse, markBotEdits, indexUrl ) {
	wikiApi.post( {
		action: 'rollback',
		assert: 'user',
		title: changeToRevert.title,
		user: changeToRevert.user,
		summary: customSummaryOrFalse,
		markbot: markBotEdits,
		token: rollbackToken
	} ).done(
		function ( data ) {
			if ( data && data.rollback && data.rollback.revid ) {
				AutoRollbackGlobal.addResult(
					'Successfully rolledback on page ' +
						AutoRollbackGlobal.pageLink( indexUrl, changeToRevert.title ) +
						'@' + changeToRevert.wiki,
					'good'
				);
			} else {
				AutoRollbackGlobal.addResult(
					'Cannot rollback on page ' +
						AutoRollbackGlobal.pageLink( indexUrl, changeToRevert.title ) +
						'@' + changeToRevert.wiki + ' - more info in the console'
					,
					'bad'
				);
				console.log( data );
			}
		}
	).fail(
		function ( errcode ) {
			AutoRollbackGlobal.addResult(
				'Cannot rollback on page ' +
					AutoRollbackGlobal.pageLink( indexUrl, changeToRevert.title ) +
					'@' + changeToRevert.wiki +
					' - error code: `' + errcode + '`  - more info in the console'
				,
				'bad'
			);
			console.log( errcode );
		}
	);
};

AutoRollbackGlobal.doWikiNewPage = function ( wikiApi, csrfToken, changeToHandle, wayToHandle, indexUrl ) {
	// wayToHandle is what the user chose. Only reached if not `nothing`. Supported options:
	// `prependVandalism`
	var summaries = {
		prependVandalism: 'Prepending deletion tag'
	};
	var requestBody = {
		action: 'edit',
		assert: 'user',
		title: changeToHandle.title,
		summary: summaries[ wayToHandle ]
	};
	// hanlde specific choice
	if ( wayToHandle === 'prependVandalism' ) {
		requestBody.prependtext = '{{delete|Vandalism}}\n';
	}
	requestBody.token = csrfToken;
	
	wikiApi.post( requestBody ).done(
		function ( data ) {
			if ( data && data.edit && data.edit.result && data.edit.result === 'Success' ) {
				AutoRollbackGlobal.addResult(
					'Successfully handled new page creation for ' +
						AutoRollbackGlobal.pageLink( indexUrl, changeToHandle.title ) +
						'@' + changeToHandle.wiki,
					'good'
				);
			} else {
				AutoRollbackGlobal.addResult(
					'Error in handling new page creation for ' +
						AutoRollbackGlobal.pageLink( indexUrl, changeToHandle.title ) +
						'@' + changeToHandle.wiki + ' - more info in the console'
					,
					'bad'
				);
				console.log( data );
			}
		}
	).fail(
		function ( errcode ) {
			AutoRollbackGlobal.addResult(
				'Error in handling new page creation for ' +
					AutoRollbackGlobal.pageLink( indexUrl, changeToHandle.title ) +
					'@' + changeToHandle.wiki +
					' - error code: `' + errcode + '`  - more info in the console'
				,
				'bad'
			);
			console.log( data );
		}
	);
};

AutoRollbackGlobal.handleChange = function ( wikiApi, tokens, changeToHandle, params, indexUrl ) {
	if ( changeToHandle.type === 'edit' ) {
		if ( params.doRollback === true ) {
			AutoRollbackGlobal.doWikiRollback(
				wikiApi,
				tokens.rollback,
				changeToHandle,
				params.rollbackSummary,
				params.botRollback,
				indexUrl
			);
		} else {
			AutoRollbackGlobal.addResult(
				'Skipping edit at ' +
					AutoRollbackGlobal.pageLink( indexUrl, changeToHandle.title ) +
					'@' + changeToHandle.wiki
				,
				'bad'
			);
		}
	} else if ( changeToHandle.type === 'new' ) {
		if ( params.newPageAction !== 'nothing' ) {
			AutoRollbackGlobal.doWikiNewPage(
				wikiApi,
				tokens.csrf,
				changeToHandle,
				params.newPageAction,
				indexUrl
			);
		} else {
			AutoRollbackGlobal.addResult(
				'Skipping new page creation for ' +
					AutoRollbackGlobal.pageLink( indexUrl, changeToHandle.title ) +
					'@' + changeToHandle.wiki
				,
				'bad'
			);
		}
	}
};

AutoRollbackGlobal.onSubmit = function () {
	var e = AutoRollbackGlobal.elements;
	var params = {
		targetUser: e.targetUser.getValue(),
		doRollback: e.doRollback.isSelected(),
		botRollback: e.botRollback.isSelected(),
		newPageAction: e.newPageAction.getValue(),
		audioNotification: e.audioNotification.isSelected()
	};
	// `false` will be removed by mw.Api and thus the default will be used
	params.rollbackSummary = e.hideUsername.isSelected() ? 'Reverted edits by the previous user' : false;
	console.log( 'Form submitted with: ', params );
	var targetUser = params.targetUser;
	
	$( '#autorollbackglobal-results' ).empty();
	var $audioNotification = false;
	if ( params.audioNotification ) {
		$audioNotification = $( '<audio>' )
			.attr( 'id', 'autorollbackglobal-audio' )
			// [[:commons:File:Notification (Gravity Sound).wav]]
			.attr( 'src', 'https://upload.wikimedia.org/wikipedia/commons/1/10/Notification_%28Gravity_Sound%29.wav' );
		$( '#autorollbackglobal-results' ).append( $audioNotification );
	}
	function maybePlayNotification() {
		if ( $audioNotification ) {
			$audioNotification.trigger( 'play' );
		}
	}
	AutoRollbackGlobal.addResult( 'Handling new edits for: ' + targetUser );
	
	var urls = {};
	var apisByWiki = {};
	var tokensByWiki = {};
	new mw.Api().get( {
		action: 'sitematrix',
	} ).then( function ( data ) {
		if ( !data.sitematrix ) {
			console.log( data );
			return;
		}
		// meta, commons, etc.
		for ( let wiki of data.sitematrix.specials ) {
			urls[wiki.dbname] = wiki.url;
		}
		for ( let language of Object.values( data.sitematrix ) ) {
			if ( !language.site ) {
				continue;
			}
			for ( let wiki of language.site ) {
				urls[wiki.dbname] = wiki.url;
			}
		}
	});
	var eventSource = new window.EventSource('https://stream.wikimedia.org/v2/stream/recentchange');

	eventSource.onopen = function(event) {
		console.log('--- Opened connection.');
	};
	eventSource.onerror = function(event) {
		console.error('--- Encountered error', event);
	};

	eventSource.onmessage = function(event) {
		let change = JSON.parse(event.data);
		if ( AutoRollbackGlobal.isGloballyBlocked( change, targetUser ) ) {
			AutoRollbackGlobal.addResult( targetUser + ' is global (b)locked, stop updating live edits.' );
			maybePlayNotification();
			this.close();
			return;
		}
		if ( !AutoRollbackGlobal.isMatch( change.user, targetUser ) ) {
			return;
		}
		
		if ( !urls[change.wiki] ) {
			console.log(`Unknown wiki ${change.wiki}`);
			return;
		}

		console.log( change );
		if ( change.type !== 'edit' && change.type !== 'new' ) {
			return;
		}
		
		let indexUrl = urls[change.wiki] + mw.config.get('wgScript');
		var changeType = ( change.type === 'edit' ? 'New edit' : 'New page' );
		var line = changeType + ': revision id ' +
			AutoRollbackGlobal.pageLink( indexUrl, 'Special:Diff/' + change.revision.new, change.revision.new ) +
			', on page ' +
			AutoRollbackGlobal.pageLink( indexUrl, change.title ) +
			'@' +
			change.wiki;
		AutoRollbackGlobal.addResult( line );
		maybePlayNotification();
		
		if ( !apisByWiki[change.wiki] ) {
			let apiurl = urls[change.wiki] + mw.util.wikiScript('api');
			apisByWiki[change.wiki] = (change.wiki === mw.config.get('wgDBname') ? new mw.Api() : new mw.ForeignApi( apiurl ) );
		}
		let targetWikiApi = apisByWiki[change.wiki];
		
		// try to query the token on that foreign wiki
		if ( !tokensByWiki[change.wiki] ) {
			targetWikiApi.get( {
				action: 'query',
				meta: 'tokens',
				type: 'csrf|rollback'
			} ).done( function( data ) {
				if ( !(data && data.query && data.query.tokens && data.query.tokens.csrftoken && data.query.tokens.rollbacktoken ) ) {
					console.log(`Failed to query tokens on wiki ${change.wiki}: see following output`);
					console.log(data);
					AutoRollbackGlobal.addResult( 'Could not get tokens for wiki: ' + change.wiki, 'bad' );
					return;
				}
				tokensByWiki[change.wiki] = {
					csrf: data.query.tokens.csrftoken,
					rollback: data.query.tokens.rollbacktoken
				};
				AutoRollbackGlobal.handleChange( targetWikiApi, tokensByWiki[change.wiki], change, params, indexUrl );
			} ).fail( function( errCode ) {
				AutoRollbackGlobal.addResult(
					'Could not get tokens for wiki: ' + change.wiki + ' - error code `' + errCode + '`',
					'bad'
				);
			});
		} else {
			AutoRollbackGlobal.handleChange( targetWikiApi, tokensByWiki[change.wiki], change, params, indexUrl );
		}
	};
};

});

$(document).ready(() => {
	if ( mw.config.get( 'wgNamespaceNumber' ) === -1 ) {
		const page = mw.config.get( 'wgCanonicalSpecialPageName' );
		if ( page === 'Blankpage' ) {
			const page2 = mw.config.get( 'wgTitle' ).split( '/' );
			if ( page2[1] && page2[1] === 'AutoRollbackGlobal' ) {
				window.AutoRollbackGlobal.init();
			}
		} else if ( page === 'Contributions' ) {
			let links = $('#contentSub .mw-contributions-user-tools > .mw-changeslist-links');
			let targetUser = mw.config.get( 'wgRelevantUserName' );
			links.html( links.html() + '<span><a href="/wiki/Special:BlankPage/AutoRollbackGlobal?targetUser=' + targetUser + '">auto rollback global</a></span>');
		}
	}
});

// </nowiki>