/**
* 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));