Welcome to the DFO World Wiki. With many major updates since the release of DFO, many items are missing. Visit Item Database Project to learn more.
Please remember to click "show preview" before saving the page.
Thanks for the updated logo snafuPop!

MediaWiki:Gadget-libGlobalReplace.js

From DFO World Wiki
Jump to: navigation, search

Note: After saving, 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: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Go to Menu → Settings (Opera → Preferences on a Mac) and then to Privacy & security → Clear browsing data → Cached images and files.
/**
 * [[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;
		}
	};

	/**
	 *  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 by [[MediaWiki:Gadget-GlobalReplace.js]]</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 edit = {
				action: 'edit',
				summary: '([[c:GR|GR]]) ' + sr.replace(/\[\[(.+)\]\]/, '[[c:$1]]')
					+ ' [[File:' + of + ']] → [[File:' + nf + ']] '
					+ fr.replace(/\[\[(.+)\]\]/g, '[[c:$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
				});

				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 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 {
				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
			$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>