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)
 * @rev 2 (2015-06-07)
 * @author Rillke, 2012-2015
 * <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
	// https://bitbucket.org/magnusmanske/commons-delinquent/src/master/demon.php
	var getFileRegEx = function (title, prefix) {
		prefix = prefix || '[\\n\\[\\:\\=\\>\\|]\\s*';
		return new RegExp('(' + prefix + ')[' + mw.RegExp.escape(title[0].toUpperCase() + title[0].toLowerCase()) + ']' + mw.RegExp.escape(
			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 wdToken, fetchingWdToken;
	var getWDToken = function(cb) {
		if (wdToken) return cb(wdToken);

		var h = mw.hook('commons.libglobalreplace.wdtoken.fetched').add(cb);
		if (fetchingWdToken) return;

		fetchingWdToken = true;
		doCORSReq({
			'action': 'query',
			'meta': 'tokens',
			'type': 'csrf'
		}, 'www.wikidata.org', function(result) {
			h.fire( result.query.tokens.csrftoken );
		}, function() {
			throw new Error("Error fetching csrf token from Wikidata.");
		});
	};

	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 incrementAndUpdate = 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;
		}
	};

	/**
	 *  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: mw.libs.commons.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 added by [[:c:GR|global replace]]</small> -- ~~~~",
					editType: 'appendtext',
					watchlist: 'nochange',
					minor: true,
					summary: summary
				};
			}
			mw.loader.using(['ext.gadget.libAPI', 'mediawiki.user'], function () {
				if ( !mw.user.isAnon() ) {
					edit.assert = 'user';
				}
				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 editNow = function( edit ) {
				mw.loader.using(['mediawiki.user'], function() {
					if ( !mw.user.isAnon() ) {
						edit.assert = 'user';
					}
					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 summary = '([[c:GR|GR]]) ' + sr.replace(/\[\[(.+)\]\]/, '[[c:$1]]')
					+ ' [[File:' + of + ']] → [[File:' + nf + ']] '
					+ fr.replace(/\[\[(.+?)\]\]/g, '[[c:$1]]');

			var edit = {
				action: 'edit',
				summary: summary,
				minor: true,
				nocreate: true,
				watchlist: 'nochange'
			};

			var wdEdit = {
				action: 'wbsetclaimvalue',
				snaktype: 'value',
				summary: summary
			};

			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 replacementCount = 0,
					isWD = (wiki === 'www.wikidata.org' && pg.contentmodel === 'wikibase-item'),
					oldText, newText;

				oldText = pg.revisions[0]['*'];

				if ( isWD ) {
					try {
						newText = JSON.parse(oldText);
						$.each(newText.claims, function(propId, propClaims) {
							$.each(propClaims, function(idx, claim) {
								if (claim.type !== 'statement') return;
								if (!claim.mainsnak
									|| !claim.mainsnak.datavalue
									|| typeof claim.mainsnak.datavalue.value !== 'string'
								) return;
								if (sanitizeFileName(claim.mainsnak.datavalue.value) === sanitizeFileName(of)) {
									replacementCount++;
									if (replacementCount > 1) {
										incrementAndUpdate($prog);
									}
									getWDToken(function(token) {
										$.extend(wdEdit, {
											claim: claim.id,
											baserevid: pg.lastrevid,
											value: JSON.stringify(nf),
											token: token
										});
										editNow( wdEdit );
									});
								}
							});
						});
						if (!replacementCount) {
							$prog.notify("Nothing suitable for replacement found on " + pg.title);
							$prog.notify("Using CommonsDelinker");
							return commonsDelinker(of, nf, sr, fr, $prog);
						}
					} catch (noMatterWhat) {
						$prog.notify("Issue replacing usage on WD entry " + pg.title);
						$prog.notify("Using CommonsDelinker");
						return commonsDelinker(of, nf, sr, fr, $prog);
					}
				} else {
					if (!pg.edittoken || '+\\' === pg.edittoken) {
						$prog.notify("No token for " + wiki);
						return commonsDelinker(of, nf, sr, fr, $prog);
					}
					newText = mw.libs.wikiDOM.nowikiEscaper(oldText).secureReplace(re, '$1' + nf).getText();
					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
					});
					editNow( edit );
				}
			});
		};
		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) {
			var runReplacements = function () {
				doCORSReq({
					action: 'query',
					prop: 'info|revisions',
					rvprop: 'content|timestamp',
					intoken: 'edit',
					titles: titles.join('|').replace(/_/g, ' ')
				}, wiki, gotPagesContents, getPageContentsFailed, 'POST');
			};

			// Now, it's possible that the wiki has a local file with the new name,
			// a so-called "shadow".
			// In this case the replacement is most likely undesired.
			var gotLocalImages = function (r) {
				if (r && r.query && r.query.allimages && r.query.allimages.length) {
					// Skip this wiki
					$prog.notify("Skipping " + wiki + " because there is a shadow file with the same target name.");
					$prog.remaining -= titles.length;
					updateReplaceStatus($prog);
				} else {
					runReplacements();
				}
			};
			doCORSReq({
				action: 'query',
				list: 'allimages',
				aifrom: nf,
				aito: nf
			}, wiki, gotLocalImages, runReplacements, '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 $.trim(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 {
				localReplace(getFileRegEx(of, '(?:[\\n\\[\\=\\>\\|]|[\\n\\[\\=\\>\\|][Ff]ile\\:)\\s*'), localUsage, of, nf, sr, fr, $prog);
				globalReplace(getFileRegEx(of), 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
			$prog.CDtimeout = setTimeout(function () {
				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>