MediaWiki:Gadget-libCat.js

From wikishia

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.
/**
 * Library for batch-editing categories
 * NOUI: This is a library; it does not create a User Interface 
 *
 * It has several dependencies, most notably to gadget-libAPI 
 * (which prevents too many simultaneous requests, see [[:mw:API:Etiquette]])
 * and to gadget-libWikiDOM, a wikitext parser
 * 
 *
 * @rev 1 (2013-05-04)
 * @author Rillke, 2013
 * @license This software is quadruple licensed. You may use it under the terms of GPL v.3, LGPL v.3, CC-By-SA 3.0, GFDL 1.2
 */
// 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, smarttabs:true*/

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

	mw.messages.set({
		'cbe-summary-remove': "$1: Removing from $2",
		'cbe-summary-add': "$1: Adding $2",
		'cbe-summary-move': "$1: Moving from $2 to $3",
		'cbe-summary-copy': "$1: Copying from $2 to $3"
	});

	var nsNumber = mw.config.get('wgNamespaceNumber'),
		formattedNS = mw.config.get('wgFormattedNamespaces'),
		nsIDs = mw.config.get('wgNamespaceIds'),
		_msg = function(/*params*/) {
			var args = Array.prototype.slice.call(arguments, 0);
			args[0] = 'cbe-' + args[0];
			args[args.length] = 'libCat';
			return mw.message.apply(this, args).parse();
		};


	/**
	 *  Representing a "category"
	 *  
	 *  @param {mw.libs.Cat} o
	 *  @param {internal.cfg} c Configuration associated with this category(-change)
	 *  @param {string} s name of the category
	 *                  with namespace assumed
	 *                  only important to be full pagename if category carries a duplicate ns like "Category:Category:Xyz"
	 *  @constructor
	 */
	function Category(o, c, s) {
		this.parent = o;
		this.title = s.replace(o.prefixRegExp, '');
		this.name = internal.prefixNamespace(c, this.title);
		this.markup = internal.markupify(c, this.name);
	}

	Category.fn = Category.prototype = $.extend(Category.prototype, {
		getTitle: function() {
			return this.title;
		},
		getName: function() {
			return this.name;
		},
		getMarkup: function() {
			return this.markup;
		},
		getRegExp: function() {
			return this.parent.fullRegExp(this.getTitle());
		}
	});

	/**
	 *  These methods will be used to augment the list of
	 *  categories to add or remove
	 *  Modifying Array.prototype is considered evil by some
	 *  JS experts
	 */
	var listAugmentationMethods = {
		joinCats: function(delim) {
			return $.map(this, function(cat) {
				return cat.getMarkup();
			}).join(delim);
		},
		cloneCats: function() {
			var c = this.slice(0);
			c.join = internal.joinCats;
			return c;
		}
	};

	var internal = {
		cfg: {
			tool: 'libCat',
			fallbackCat: 'Category',
			nsCat: 14,
			moveAddIfNotExist: false,
			removeDupes: true,
			addDupes: false,
			editArgs: {}, // This way you can pass arguments like "bot"
			failConditions: {
				failedEditRatio: 1
			},
			retryConditions: {
				editConflict: true,
				count: 3
			},
			summary: {
				// Whether to append an automatically generated edit summary;
				// if false, only adds summary if it is not specified
				auto: true,
				copyFrom: (14 === nsNumber) ? mw.config.get('wgPageName').replace(/_/g, ' ') : ''
			}
		},
		/**
		 * Returns true if s is a string, otherwise false
		 */
		isString: function(s) {
			return 'string' === typeof s;
		},
		/**
		 * Returns true if x is undefined, otherwise false
		 */
		isUndefined: function(x) {
			return 'undefined' === typeof x;
		},
		/**
		 * getLocalizedRegex: Copyright by [[User:Lupo]]
		 * Taken from HotCat and slightly altered
		 */
		getLocalizedRegex: function(namespaceNumber, fallback) {
			var wikiTextBlank = '[\\t _\\xA0\\u1680\\u180E\\u2000-\\u200A\\u2028\\u2029\\u202F\\u205F\\u3000]+',
				wikiTextBlankRE = new RegExp(wikiTextBlank, 'g');

			var createRegexStr = function(name) {
				if (!name || name.length === 0) return "";
				var regex_name = "";
				for (var i = 0; i < name.length; i++) {
					var initial = name.substr(i, 1);
					var ll = initial.toLowerCase();
					var ul = initial.toUpperCase();
					if (ll === ul) {
						regex_name += initial;
					} else {
						regex_name += '[' + ll + ul + ']';
					}
				}
				return regex_name.replace(/([\\\^\$\.\?\*\+\(\)])/g, '\\$1').replace(wikiTextBlankRE, wikiTextBlank);
			};

			fallback = fallback.toLowerCase();

			var canonical = formattedNS[namespaceNumber].toLowerCase(),
				RegexString = createRegexStr(canonical);

			if (fallback && canonical !== fallback) RegexString += '|' + createRegexStr(fallback);
			for (var catName in nsIDs) {
				if (nsIDs.hasOwnProperty(catName)) {
					if (this.isString(catName) && nsIDs[catName] === namespaceNumber &&
						catName.toLowerCase() !== canonical && catName.toLowerCase() !== fallback) {
							RegexString += '|' + createRegexStr(catName);
					}
				}
			}
			return ('(?:' + RegexString + ')');
		},
		/**
		 *  Normalize category-lists and augment them
		 *  
		 *  @param {mw.libs.Cat} o instance of the libCat object
		 *  @param {Object} p parameters - contains categories to remove, add, the titles, edit summary etc. (batch)
		 *  @param {internal.cfg} c configuration for this batch
		 *  @param {string} l type of list to process ['add'|'remove']
		 *  
		 *  @return {Object} the processed parameters
		 */
		processCatList: function(o, p, c, l) {
			if (this.isString(p[l])) p[l] = [p[l]];
			if (!$.isArray(p[l])) p[l] = [];
			p[l] = $.map(p[l], function(catname) {
				return new Category(o, c, catname.replace(o.prefixRegExp, ''));
			});
			$.extend(p[l], listAugmentationMethods);
		},
		/**
		 *  Add edit summary if appropriate
		 *  
		 *  @param {Object} p parameters - contains categories to remove, add, the titles, edit summary etc. (batch)
		 *  @param {internal.cfg} c configuration for this batch
		 *  @param {string} s edit summary to [possibly] add to the provided summary
		 *  
		 *  @return {Object} the processed parameters
		 */
		addSummary: function(p, c, s) {
			if (p.summary && !c.summary.auto) return;
			p.summary = p.summary || '';
			p.summary = $.trim(p.summary + ' ' + s);
		},
		prefixNamespace: function(c, s) {
			return [formattedNS[c.nsCat], ':', s].join('');
		},
		/**
		 *  Returns Wiki-Markup that can be used to categorize into the provided category catName
		 *  In parser-language: Turn a plain text into a link.
		 *  
		 *  @param {string} catName Full name of the category
		 *  
		 *  @return {string}
		 */
		markupify: function(c, catName) {
			return ['[[', catName, ']]'].join('');
		},
		/**
		 *  Normalize input
		 *  and determine what kind of operation we'll run
		 *  
		 *  @param {mw.libs.Cat} o instance of the libCat object
		 *  @param {Object} p parameters - contains categories to remove, add, the titles, edit summary etc. (batch)
		 *  @param {internal.cfg} c configuration for this batch
		 *  
		 *  @return {Object} the processed parameters
		 */
		processArgs: function(o, p, c) {
			// Remove "Category:" from the set of rules 
			// and ensure data is of type Array
			internal.processCatList(o, p, c, 'remove');
			internal.processCatList(o, p, c, 'add');

			// Create auto-summary
			if (1 === p.remove.length && 1 === p.add.length) {
				internal.addSummary(p, c, _msg('summary-move', c.tool, p.remove[0].getMarkup(), p.add[0].getMarkup()));
				p.type = 'move';
			} else if (c.summary.copyFrom && 0 === p.remove.length) {
				internal.addSummary(p, c, _msg('summary-copy', c.tool, c.currentPageCat.getMarkup(), p.add[0].getMarkup()));
				p.type = 'copy';
			} else if (0 === p.remove.length && p.add.length > 0) {
				internal.addSummary(p, c, _msg('summary-add', c.tool, p.add.join(', ')));
				p.type = 'add';
			} else if (0 === p.add.length && p.remove.length > 0) {
				internal.addSummary(p, c, _msg('summary-remove', c.tool, p.remove.join(', ')));
				p.type = 'remove';
			}

			if (internal.isString(p.titles)) p.titles = p.titles.split('|');
			if (!$.isArray(p.titles)) throw new Error('libCat: Invalid arguments supplied. params.titles must be of type Array or String');
			return p;
		}
	};

	var init = function(cfg, o) {
		o.localizedRegex = internal.getLocalizedRegex(cfg.nsCat, cfg.fallbackCat);
		o.prefixRegExp = o.getPrefixRegExp();
		cfg.currentPageCat = new Category(o, cfg, cfg.summary.copyFrom);
	};

	mw.libs.Cat = function(cfg) {
		this.cfg = cfg;
	};

	mw.libs.Cat.prototype = $.extend(true, mw.libs.Cat.prototype, {
		/**
		 * Batch edits categories according to the specified
		 * parameters
		 *
		 * @example
		 *     var params = {
		 *        remove: [],  // Array or string containing categories to be removed
		 *        add: [],
		 *        titles: [],  // Array of titles to work on or a string of page separated by a pipe (|) character
		 *        summary: '', // String -- Reason for doing so
		 *        beforeSave: function() {} // Callback. First argument is the text to be saved. Must return the text to be finally saved.
		 *     };
		 *     new mw.libs.Cat().batchEdit( params ).progress(function() {
		 *        alert('One page edited');
		 *     }).done(function() {
		 *        alert('All pages edited');
		 *     });
		 *
		 * @param params {object} Object containing information about categories to add or remove and an edit summary
		 * @param cfg {object} Possiblity to overwrite the default configuration
		 *
		 * @return {$.Deferred} jQuery Defferred-Object.
		 *     Arguments are passed to
		 *           -notify ->  progress: (1) {string} status, (2) {string} title,       (3) {Object} stats|ft|<nothing>
		 *           -resolve -> done:     (1) {Object} stats,  (2) {Object} failedTitles
		 *           -reject ->  fail:     (1) {string} reason
		 */
		batchEdit: function(params, cfg) {
			var _t = this,
				$def = $.Deferred(),
				pending = 0,
				queryPending = false,
				retriedTitles = {},
				stats = {
					done: 0,
					outstanding: params.titles.length,
					failed: 0,
					percentDone: function() {
						return (this.done / (this.done + this.outstanding)) * 100;
					}
				};

			// Merge configuration: for this batch to the config of the mw.libs.Cat instance to the default config
			// Empty object prevents changing default config
			cfg = $.extend(true, {}, internal.cfg, _t.cfg, cfg);
			$.extend($def, {
				failedTitles: {},
				stats: stats
			});
			// Initialize RegExps, create create category object for current category
			init(cfg, this);

			// Process params (passing configuration); Normalze input
			params = internal.processArgs(this, $.extend(true, {}, params), cfg);

			var oneDone = function() {
				stats.done++;
				stats.outstanding--;
				return stats;
			};
			var possiblyNextChunk = function() {
				if (pending < 3 && params.titles.length && !queryPending) {
					fetch();
				} else if (0 === params.titles.length && 0 === pending) {
					$def.resolve(stats, $def.failedTitles);
				}
			};
			var editPage = function(pg, rv, txt) {
				if ($.trim(rv['*']) === $.trim(txt)) {
					$def.notify('nochange', title, oneDone());
					possiblyNextChunk();
					return;
				}

				pending++;
				var title = pg.title;

				mw.libs.commons.api.editPage($.extend(true, {}, {
					title: title,
					editType: 'text',
					text: txt,
					summary: params.summary,
					starttimestamp: pg.starttimestamp,
					basetimestamp: rv.timestamp,
					cb: function() {
						pending--;
						$def.notify('done', title, oneDone());
						possiblyNextChunk();
					},
					errCb: function(txt, r) {
						pending--;
						var rt = retriedTitles[title],
							dontRetry;

						dontRetry = function(ev) {
							// do not retry this title
							var ft = {
								reason: txt,
								response: r,
								evidence: 'retryConditions.' + ev
							};
							$def.failedTitles[title] = ft;
							stats.failed++;
							stats.outstanding--;
							$def.notify('failedTitle', title, ft);
						};
						if (rt) {
							rt++;
						} else {
							rt = 1;
						}
						retriedTitles[title] = rt;
						if (txt.indexOf('editconflict') !== -1 && !cfg.retryConditions.editConflict) {
							dontRetry('editConflict');
						} else if (rt > cfg.retryConditions.count) {
							dontRetry('count');
						} else {
							$def.notify('retryTitle', title);
							params.titles.push(title);
						}
						possiblyNextChunk();
					}
				}, cfg.editArgs));
			};

			var _contensAvailable = function(r) {
				queryPending = false;
				var pgs = r.query.pages;
				$.each(pgs, function(i, pg) {
					// Page disappeared?
					if (!pg.revisions) return;

					var allCats = $.map(pg.categories || [], function(cat) {
							return new Category(_t, cfg, cat.title).getTitle();
						}),
						rv = pg.revisions[0],
						txt = _t.change(params, cfg, rv['*'], allCats);

					if ($.isFunction(params.beforeSave)) txt = $.trim(params.beforeSave(txt, pg, rv));
					// Page blanking is blocked by AbuseFilter
					if (txt) editPage(pg, rv, txt);
				});
				// Check if we actually scheduled edits
				possiblyNextChunk();
			};

			// Fetch page content
			var fetch = function() {
				queryPending = true;
				mw.libs.commons.api.$query({
					action: 'query',
					prop: 'revisions|info|categories',
					cllimit: 'max',
					intoken: 'edit',
					titles: params.titles.splice(0, 5).join('|'),
					rvprop: 'content|timestamp'
				}, {
					// Prevent any caching
					method: 'POST'
				}).done(_contensAvailable).fail(function() {
					// Should not happen since libAPI is very error-tolerant
					$def.reject('libCat: Fetching page contents failed');
				});
			};
			possiblyNextChunk();
			return $def;
		},
		change: function(params, cfg, txt, allCats) {
			allCats = allCats || [];

			var dom = mw.libs.wikiDOM.parser.text2Obj(txt),
				// Get the list of categories in reverse order
				cats = (dom.nodesByType.category || []).reverse(),
				didReplacement = false,
				catCount = cats.length,
				toAdd = params.add.clone(),
				currentCatRE, catPart, toAppend;

			if ('move' === params.type) {
				// FIXME: Respect cfg.addDupes
				toAppend = '\n' + toAdd[0].getMarkup();
				if (catCount > 0) {
					currentCatRE = params.remove[0].getRegExp();
					$.each(cats, function(i, cat) {
						// Cannot and want not edit categories built with templates or templateargs
						// or other fancy stuff in it.
						catPart = cat.parts[0][0];
						if (!internal.isString(catPart)) return;
						
						// Are you the cat I want to catch?
						if (currentCatRE.test(catPart)) {
							if (didReplacement && cfg.removeDupes) {
								// If we already replaced the category, remove duplicate stuff
								// nodes with 'deleted' type are simply ignored by the DOM parser
								// and consequently this category won't be added again when
								// transforming DOM-Object -> text
								cat.type = 'deleted';
							} else {
								cat.parts[0][0] = toAdd[0].getName();
								didReplacement = true;
							}
						}
					});
					if (cfg.moveAddIfNotExist) {
						cats[0].after(toAppend);
						didReplacement = true;
					}
				}
				if (didReplacement) {
					txt = mw.libs.wikiDOM.parser.obj2Text(dom);
				} else if (cfg.moveAddIfNotExist) {
					// No cats on the page --> Append one
					txt += toAppend;
				}
			} else {
				if (!cfg.addDupes) {
					$.each(toAdd, function(i, cat2Add) {
						var title = cat2Add.getTitle();
						if ($.inArray(title, allCats) > -1) {
							toAdd.splice(i, 1);
						}
					});
				}
				if (0 !== toAdd.length) {
					toAppend = '\n' + toAdd.join('\n');
					if (0 === catCount) {
						dom.append(toAppend);
					} else {
						cats[0].after(toAppend);
					}
				}
				$.each(params.remove, function(i, cat2Remove) {
					currentCatRE = cat2Remove.getRegExp();
					$.each(cats, function(i, cat) {
						// Cannot and want not edit categories built with templates or templateargs
						// or other fancy stuff in it.
						catPart = cat.parts[0][0];
						if (!internal.isString(catPart)) return;
						// Are you the cat I want to catch?
						if (currentCatRE.test(catPart)) {
							cat.type = 'deleted';
						}
					});
				});
				txt = mw.libs.wikiDOM.parser.obj2Text(dom);
			}
			return txt;
		},

		localizedRegex: null,

		/**
		 * fullRegExp: Copyright by [[User:Lupo]]
		 * Taken from HotCat and slightly altered
		 */
		fullRegExp: function(category) {

			// Build a regexp string for matching the given category:
			// trim leading/trailing whitespace and underscores
			category = category.replace(/^[\s_]+/, '').replace(/[\s_]+$/, '');

			// escape regexp metacharacters (= any ASCII punctuation except _)
			category = mw.util.escapeRegExp(category);

			// any sequence of spaces and underscores should match any other
			category = category.replace(/[\s_]+/g, '[\\s_]+');

			// Make the first character case-insensitive:
			var first = category.substr(0, 1);
			if (first.toUpperCase() !== first.toLowerCase()) category = '[' + first.toUpperCase() + first.toLowerCase() + ']' + category.substr(1);

			// Compile it into a RegExp that matches MediaWiki category syntax (yeah, it looks ugly):
			return new RegExp('^[\\s_]*' + this.localizedRegex + '[\\s_]*:[\\s_]*' + category + '[\\s_]*$', '');
		},
		prefixRegExp: '',
		getPrefixRegExp: function() {
			return new RegExp('^[\\s_]*' + this.localizedRegex + '[\\s_]*\\:[\\s_]*', '');
		}
	});


}(jQuery, mediaWiki));