User:NatigKrolik/Gadget-GlobalReplace.js

Note: After saving, you have to bypass your browser's cache to see the changes. Internet Explorer: press Ctrl-F5, Mozilla: hold down Shift while clicking Reload (or press Ctrl-Shift-R), Opera/Konqueror: press F5, Safari: hold down Shift + Alt while clicking Reload, Chrome: hold down Shift while clicking Reload.
/**
 * [[MediaWiki:Gadget-GlobalReplace.js]]
 * Replaces a file on all wikis, including Wikimedia Commons
 * Uses either CORS under the current user account
 * or deputes the task to Commons Delinker
 *
 * The method used is determined by
 * -Browser capabilities (CORS required)
 * -The usage count: More than the given number
 *                   aren't attempted to be replaced 
 *                   under the user account
 *
 * It adds only one public method to the mw.libs - object:
 * @example
 *      var $jQuery_Deferred_Object;
 *      $jQuery_Deferred_Object = mw.libs.globalReplace(oldFile, newFile, shortReason, fullReason);
 *      $jQuery_Deferred_Object.done(function() { alert("Good news! " + oldFile + " has been replaced by " + newFile + "!") });
 * 
 * Internal stuff:
 * Since we don't use instances of classes, we have to pass around all the parameters
 *
 * TODO: I18n (progress messages) when Krinkle is ready with Gadgets 2.0 :-)
 *
 * @rev 1 (2012-11-26)
 * @author Rillke, 2012
 * <nowiki>
 */
// List the global variables for jsHint-Validation. Please make sure that it passes http://jshint.com/
// Scheme: globalVariable:allowOverwriting[, globalVariable:allowOverwriting][, globalVariable:allowOverwriting]
/*global jQuery:false, mediaWiki:false*/

// Set jsHint-options. You should not set forin or undef to false if your script does not validate.
/*jshint forin:true, noarg:true, noempty:true, eqeqeq:true, bitwise:true, strict:true, undef:true, curly:false, browser:true*/

( function ( $, mw ) {
"use strict";

// Config
// When this number is exceeded or reached, use CommonsDelinker
// This number must not be higher than 50
// (can't query more than 50 titles at once)
var usageThreshold = 45;

// Internal stuff
var CORSsupported = false;

/**
 * TODO: Outsource to library as I often use them OR does jQuery provide something like that?
**/
var getObjLen = function(obj) {
	var x, i = 0;
	for (x in obj) {
		if (obj.hasOwnProperty(x)) {
			i++;
		}
	}
	return i;
};
var firstItem = function(o) { 
	for (var i in o) { 
		if (o.hasOwnProperty(i)) { 
			return o[i]; 
		}
	}
};

// TODO: Keep in sync with CommonsDelinker source
// http://svn.wikimedia.org/viewvc/pywikipedia/trunk/pywikipedia/commonsdelinker/delinker.py?revision=9053&view=markup#l172
var getFileRegEx = function(title) {
	return new RegExp('([\\n\\[\\:\\=\\>\\|]\\s*)[' + $.escapeRE(title.charAt(0).toUpperCase()) + $.escapeRE(title.charAt(0).toLowerCase()) + ']' + $.escapeRE(title.slice(1)).replace(/ /g, '[ _]'), 'g');
};

var queryGET = function(params, cb, errCb) {
	mw.loader.using(['ext.gadget.libAPI'], function() {
		mw.libs.commons.api.query(params, {
			method: 'GET',
			cache: true,
			cb: cb,
			errCb: errCb
		});
	});
};

var testCORS = function(done) {
	if (CORSsupported) return done();
	doCORSReq({
		action: 'query',
		meta: 'userinfo'
	}, 'www.mediawiki.org', function(data, textStatus, jqXHR) {
		if (!data.query.userinfo.id) {
			CORSsupported = 'CORS supported but not logged-in';
		} else {
			CORSsupported = 'OK';
		}
		done();
	}, function(jqXHR, textStatus, errorThrown) {
		CORSsupported = 'CORS not supported: ' +  textStatus + ' \nError: ' + errorThrown;
		done();
	});
};

var doCORSReq = function(params, wiki, cb, errCb, method) {
	$.support.cors = true;
	// Format parameter first!
	var newParams = {
		format: 'json',
		origin: document.location.protocol + '//' + document.location.hostname
	};
	params = $.extend(newParams, params);
	$.ajax({
		'url': '//' + wiki + '/w/api.php',
		'data': params,
		'xhrFields': {
			'withCredentials': true
		},
		'type': method || 'GET',
		'success': function(r) {
			cb(r, wiki);
		},
		'error': errCb,
		'dataType': 'json'
	});
};

var updateReplaceStatus = function($prog) {
	// If we are using CommonsDelinker (CD),
	// it will mark this progress object
	// as resolved as soon as the requst was placed in the queue; 
	// Don't know whether we should
	// stop replacement under user account
	// when we request CD to do our job; but see no
	// pressing need to
	if (0 === $prog.remaining && !$prog.usingCD) {
		$prog.resolve("All usages replaced");
		// Kill the timer: Everything worked in time!
		if ($prog.CDtimeout) clearTimeout($prog.CDtimeout);
	}
	$prog.notify("Replacing usage - " + Math.round(($prog.total-$prog.remaining) * 100 / $prog.total) + "% \nDo not close this window until the task is completed.");
};
var decrementAndUpdate = function($prog) {
	$prog.remaining--;
	updateReplaceStatus($prog);
};
var checkPage = function($prog, pg, wiki, cb) {
	if (!pg.revisions) {
		$prog.notify("No page text for " + pg.title + " - " + wiki + " - private wiki or out of date?");
		if (cb && $.isFunction(cb)) cb();
		decrementAndUpdate($prog);
		return false;
	} else {
		return true;
	}
};
var compareTexts = function($prog, oldT, newT, title, wiki) {
	if (oldT === newT) {
		$prog.notify("No changes at " + title + " - " + wiki + " - template use?");
		decrementAndUpdate($prog);
		return false;
	} else {
		return true;
	}
};

/**
 *  TODO: Outsource this method, as it can be useful elsewhere
 *  @param {string} title A valid page title.
 *  @return {string} The title of the associated talk page. 
**/
var getTalkPageFromTitle = function(title) {
	var rens = /^(.+)\:/,
		pref = title.match(rens),
		nsid = -1,
		newPref;
		
	if (pref) {
		pref = pref[1].toLowerCase().replace(/ /g, '_');
	} else {
		pref = '';
	}
	nsid = mw.config.get('wgNamespaceIds')[pref];
	// If it was not a talk page, increment namespace id
	if (0 !== nsid % 2) nsid++;
	newPref = mw.config.get('wgFormattedNamespaces')[nsid] + ':';
	if (pref) {
		title = title.replace(/^.+\:/, newPref);
	} else {
		title = newPref + title;
	}
	return title;
};

/**
 *  Replace usage at Wikimedia Commons.
**/
var localReplace = function(re, localUsage, of, nf, sr, fr, $prog) {
	$.each(localUsage, function(id, pg) {
		if (!checkPage($prog, pg, 'Commons')) return;
	
		var isEditable = true,
			summary = sr + ' [[File:' + of + ']] → [[File:' + nf + ']] ' + fr,
			edit;
			
		$.each(pg.protection, function(i, pr) {
			if ('edit' === pr.type) {
				if ($.inArray(pr.level, mw.config.get('wgUserGroups')) === -1) isEditable = false;
				return false;
			}
		});
		
		if (isEditable) {
			var oldText = pg.revisions[0]['*'],
				nwe1 = mw.libs.wikiDOM.nowikiEscaper(pg.revisions[0]['*']),
				newText = nwe1.secureReplace(re, '$1' + nf).getText();
			
			if (!compareTexts($prog, oldText, newText, pg.title, "Commons")) return;
			
			edit = {
				cb: function() {
					decrementAndUpdate($prog);
				},
				errCb: function() {
					decrementAndUpdate($prog);
					$prog.notify("Unable to update " + pg.title);
					$prog.notify("Using CommonsDelinker");
					commonsDelinker(of, nf, sr, fr, $prog);
				},
				title: pg.title,
				text: newText,
				editType: 'text',
				watchlist: 'nochange',
				minor: true,
				summary: summary,
				basetimestamp: pg.revisions[0].timestamp
			};
		} else {
			// If page is protected, post a request to the talk page
			edit = {
				cb: function() {
					decrementAndUpdate($prog);
				},
				errCb: function() {
					decrementAndUpdate($prog);
				},
				title: getTalkPageFromTitle(pg.title),
				text: "== Please replace [[:File:" + of + "]] ==\n{{edit request}}\nThis page is protected while posting this message. " + 
					"Please replace <code>[[:File:" + of + "]]</code> with <code>[[:File:" + nf + "]]</code> because " + sr + " " + fr + "\nThank you. " + 
					"<small>Message by [[MediaWiki:Gadget-GlobalReplace.js]]</small> -- ~~~~",
				editType: 'appendtext',
				watchlist: 'nochange',
				minor: true,
				summary: summary
			};
		}
		mw.loader.using(['ext.gadget.libAPI'], function() {
			mw.libs.commons.api.editPage(edit);
		});
	});
};
/**
 *  Replace usage in other wikis.
 *  It's not uncommon that edits fail due to title blacklist, abuse filter,
 *  captcha, server timeouts, protected pages etc. but in this case 
 *  we kindly ask CommonsDelinker whether it will do the remaining ones for us.
**/
var globalReplace = function(re, globalUsage, of, nf, sr, fr, $prog) {
	var guWiki = {};
	// First we have to compile a list of pages per wiki
	$.each(globalUsage, function(i, gu) {
		if (!(gu.wiki in guWiki)) {
			guWiki[gu.wiki] = [gu.title];
		} else {
			guWiki[gu.wiki].push(gu.title);
		}
	});
	
	var gotPagesContents = function(result, wiki) {
		$prog.notify("Got page contents for " + wiki + ". Updating them now.");
		
		var edit = {
			action: 'edit',
			summary: sr.replace(/\[\[(.+)\]\]/, '[[:commons:$1]]') + ' [[File:' + of + ']] → [[File:' + nf + ']] ' + fr.replace(/\[\[(.+)\]\]/g, '[[:commons:$1]]'),
			minor: true,
			nocreate: true,
			watchlist: 'nochange'
		};
		
		var _onErr = function(r) {
			decrementAndUpdate($prog);
			$prog.notify("Unable to update page at " + wiki);
			$prog.notify("Using CommonsDelinker");
			commonsDelinker(of, nf, sr, fr, $prog);
		};
		
		// TODO: Work around protection
		$.each(result.query.pages, function(id, pg) {
			if (!checkPage($prog, pg, wiki, function() {
				// Perhaps it's a private wiki and CommonsDelinker has access?
				commonsDelinker(of, nf, sr, fr, $prog);
			})) return;
		
			var oldText = pg.revisions[0]['*'],
				newText = mw.libs.wikiDOM.nowikiEscaper(oldText).secureReplace(re, '$1' + nf).getText();
				
			if (!pg.edittoken || '+\\' === pg.edittoken) {
				$prog.notify("No token for " + wiki);
				commonsDelinker(of, nf, sr, fr, $prog);
				return;
			}
			if (!compareTexts($prog, oldText, newText, pg.title, wiki)) return;
			$.extend(edit, {
				title: pg.title,
				starttimestamp: pg.starttimestamp,
				basetimestamp: pg.revisions[0].timestamp,
				text: newText,
				token: pg.edittoken
			});
			doCORSReq(edit, wiki, function(r) {
				if (r.error || (r.edit && (r.edit.spamblacklist || 'Success' !== r.edit.result))) {
					// ERROR
					_onErr(r);
				} else {
					// SUCCESS
					decrementAndUpdate($prog);
				}
			}, _onErr, 'POST');
		});
	};
	var getPageContentsFailed = function(text, wiki) {
		$prog.notify("Unable to get information from " + wiki);
		$prog.notify("Using CommonsDelinker");
		commonsDelinker(of, nf, sr, fr, $prog);
	};
	
	// Then send out the queries to the wikis
	$.each(guWiki, function(wiki, titles) {
		doCORSReq({
			action: 'query',
			prop: 'info|revisions',
			rvprop: 'content|timestamp',
			intoken: 'edit',
			titles: titles.join('|').replace(/_/g, ' ')
		}, wiki, gotPagesContents, getPageContentsFailed, 'POST');
	});
};

/**
 *  Asks CommonsDelinker to replace a file.
**/
var commonsDelinker = function(of, nf, sr, fr, $prog) {
	// Don't ask CommonsDelinker multiple times 
	// to replace the same file
	if ($prog.usingCD) return;
	
	if ($prog.dontUseCD) 
		return $prog.reject("Unable replacing all usages. Usually CD would now have been instructed but you wished not to do so.");

	// Tell other processes that we're now using the delinker
	// So they don't stop us by resolving the progress
	$prog.usingCD = true;
	
	mw.libs.globalReplaceDelinker(of, nf, sr + ' ' + fr, function() {
		$prog.resolve("CommonsDelinker has been instructed to replace " + of + " with " + nf);
	}, function(t) {
		$prog.reject("Error while asking CommonsDelinker to replace " + of + " with " + nf + " Reason: " + t);
	});
};

var sanitizeFileName = function(fn) {
	return fn.replace(/_/g, ' ').replace(/^(?:File|Image)\:/, '');
};

/**
 * @param {string} of Old file name. The old file name will be replaced with the new file name.
 * @param {string} nf New file name.
 * @param {string} sr Short reason like "file renamed". Will be prefixed to the edit summary.
 * @param {string} fr Full reason like "file renamed because it was offending". Will be appended to the edit summary.
 * @param {$.Deferred} $prog Deferred object reflecting the current progress.
**/
var replace = function(of, nf, sr, fr, $prog) {
	var pending = 0,
		localResult,
		globalResult;
		
	of = sanitizeFileName(of);
	nf = sanitizeFileName(nf);

	var _queryLocal = function(result) {
		pending--;
		if (result) localResult = result;
		if (pending > 0) return;
		_selectMethod();
	};
	var _queryGlobal = function(result) {
		pending--;
		if (result) globalResult = result;
		if (pending > 0) return;
		_selectMethod();
	};
	var _selectMethod = function() {
		var globalUsage = firstItem(globalResult.query.pages).globalusage,
			globalUsageCount = globalUsage.length,
			localUsage = localResult.query ? localResult.query.pages : {},
			usageCount = getObjLen(localUsage) + globalUsageCount;
			
		$prog.remaining = usageCount;
		$prog.total = usageCount;
		if (0 === usageCount) {
			$prog.resolve("File was not in use. Nothing replaced.");
		} else if ((usageCount >= usageThreshold || (CORSsupported !== 'OK' && globalUsageCount)) && !$prog.dontUseCD) {
			commonsDelinker(of, nf, sr, fr, $prog);
			$prog.notify("Instructing CommonsDelinker to replace this file");
		} else {
			var re = getFileRegEx(of);
			localReplace(re, localUsage, of, nf, sr, fr, $prog);
			globalReplace(re, globalUsage, of, nf, sr, fr, $prog);
			$prog.notify("Replacing usage immediately using your user account. Do not close this window until the process completed.");
		}
		// Finally, set a timeout that will instruct CommonsDelinker if it takes too long
		setTimeout(function() {
			$prog.CDtimeout = commonsDelinker(of, nf, sr, fr, $prog);
		}, 60000);
	};

	$prog.notify("Query usage and selecting replace-method");
	pending++;
	queryGET({
		action: 'query',
		generator: 'imageusage',
		giufilterredir: 'nonredirects',
		giulimit: usageThreshold,
		prop: 'info|revisions',
		inprop: 'protection',
		rvprop: 'content|timestamp',
		giutitle: 'File:' + of
	}, _queryLocal);
	pending++;
	queryGET({
		action: 'query',
		prop: 'globalusage',
		guprop: '',
		gulimit: usageThreshold,
		gufilterlocal: 1,
		titles: 'File:' + of
	}, _queryGlobal);
	
	pending++;
	testCORS(function() {
		pending--;
		if (pending > 0) return;
		_selectMethod();
	});
};

// Expose globally
/**
 * @param {string} oldFile Old file name. The old file name will be replaced with the new file name. 
 *                         Can be in any format (both "File:Abc def.png" and "Abc_def.png" work)
 * @param {string} newFile New file name.
 *                         Can be in any format (both "File:Abc def.png" and "Abc_def.png" work)
 *
 * @param {string} shortReason Short reason like "file renamed". Will be prefixed to the edit summary.
 * @param {string} fullReason Full reason like "file renamed because it was offending". Will be appended to the edit summary.
 * @param {boolean} dontUseDelinker Prevents usage of CommonsDelinker (only provided for debugging/scripting)
 * @return {$.Deferred} $prog jQuery deferred-object reflecting the current progress. See http://api.jquery.com/category/deferred-object/ for more info.
 * @examle See this gadget's introduction.
**/
mw.libs.globalReplace = function(oldFile, newFile, shortReason, fullReason, dontUseDelinker) {
	var $progress = $.Deferred();
	$progress.pendingQueries = 0;
	$progress.dontUseCD = dontUseDelinker;
	var args = Array.prototype.slice.call(arguments, 0);
	// Delete "dontUseDelinker"
	if (args.length > 4) args.pop();
	// Add progress
	args.push($progress);
	replace.apply(this, args);
	return $progress;
};
mw.libs.globalReplaceDelinker = function(oldFile, newFile, reason, cb, errCb) {
	var userGroups = mw.config.get('wgUserGroups'),
		isSysop = $.inArray('sysop', userGroups) !== -1;

	oldFile = sanitizeFileName(oldFile);
	newFile = sanitizeFileName(newFile);

	reason = reason.replace(/\{/g, '&#123;').replace(/\}/g, '&#125;').replace(/\=/g, '&#61;');
	var edit = {
		cb: cb,
		errCb: errCb,
		title: 'User:CommonsDelinker/commands',
		text: '\n{{universal replace|' + oldFile + '|' + newFile + '|reason=' + reason + '}}',
		editType: 'appendtext',
		watchlist: 'nochange',
		summary: 'universal replace: [[:File:' + oldFile + ']] → [[:File:' + newFile + ']]'
	};
	if (!isSysop) {
		edit.title = 'User:CommonsDelinker/commands/filemovers';
	}
	mw.loader.using(['ext.gadget.libAPI'], function() {
		mw.libs.commons.api.editPage(edit);
	});
};

}( jQuery, mediaWiki ));
//</nowiki>