MediaWiki:Gadget-morebits.js: Difference between revisions

Content deleted Content added
Repo at 8215b50: Note defaults for errorformat, uselang, and json's formatversion; Update deprecation notices; Add a few quick tests for Morebits.unbinder
Repo at b60c1b8: Convert Morebits.wiki.page methods to json, not xml; Require matching level markers; Add YYYYMMDDHHmmss to morebits.date constructor; Fixes for internal templates/parser functions; Consolidate largely duplicated code; Fixes for unnamed parameters
Line 1,531:
var args = Array.prototype.slice.call(arguments);
 
// Check if it's a MediaWiki signature timestamp (which the native Date cannot parse directly)formats
// Must be first since firefox erroneously accepts the format, sans timezonetimestamp
// format, sans timezone (See also: #921, #936, #1174, #1187), and the
// 14-digit string will be interpreted differently.
var dateParts;
if (args.length === 1) {
if (args.length === 1 && typeof args[0] === 'string' && (dateParts = Morebits.date.localeData.signatureTimestampFormat(args[0]))) {
var param = args[0];
this._d = new Date(Date.UTC.apply(null, dateParts));
if (/^\d{14}$/.test(param)) {
} else {
// YYYYMMDDHHmmss
var digitMatch = /(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})/.exec(param);
if (digitMatch) {
// ..... year ... month .. date ... hour .... minute ..... second
this._d = new Date(Date.UTC.apply(null, [digitMatch[1], digitMatch[2] - 1, digitMatch[3], digitMatch[4], digitMatch[5], digitMatch[6]]));
}
} else if (typeof param === 'string') {
// Wikitext signature timestamp
var dateParts = Morebits.date.localeData.signatureTimestampFormat(param);
if (dateParts) {
this._d = new Date(Date.UTC.apply(null, dateParts));
}
}
}
 
if (!this._d) {
// Try standard date
this._d = new (Function.prototype.bind.apply(Date, [Date].concat(args)));
Line 1,574 ⟶ 1,590:
},
signatureTimestampFormat: function (str) {
// HH:mm, DD Month YYYY (UTC)
var rgx = /(\d{2}):(\d{2}), (\d{1,2}) (\w+) (\d{4}) \(UTC\)/;
var match = rgx.exec(str);
Line 1,805 ⟶ 1,822:
*/
monthHeaderRegex: function() {
return new RegExp('^(==+)\\s*(?:' + this.getUTCMonthName() + '|' + this.getUTCMonthNameAbbrev() +
')\\s+' + this.getUTCFullYear() + '\\s*==+\\1', 'mg');
},
 
Line 1,994 ⟶ 2,011:
this.statelem = new Morebits.status(currentAction);
}
// JSON is used throughout Morebits/Twinkle, but xml remains the default for backwards compatibility
if (!query.format) {
this.query.format = 'xml';
Line 2,212 ⟶ 2,230:
action: 'query',
meta: 'tokens',
type: 'csrf',
format: 'json'
});
return tokenApi.post().then(function(apiobj) {
return $(apiobj.responseXML)response.query.find('tokens').attr('csrftoken');
});
};
Line 2,411 ⟶ 2,430:
meta: 'tokens',
type: 'csrf',
titles: ctx.pageName,
format: 'json'
// don't need rvlimit=1 because we don't need rvstartid here and only one actual rev is returned by default
};
Line 2,493 ⟶ 2,513:
summary: ctx.editSummary,
token: canUseMwUserToken ? mw.user.tokens.get('csrfToken') : ctx.csrfToken,
watchlist: ctx.watchlistOption,
format: 'json'
};
if (ctx.changeTags) {
Line 3,035 ⟶ 3,056:
'rvlimit': 1,
'rvprop': 'user|timestamp',
'rvdir': 'newer',
'format': 'json'
};
 
Line 3,137 ⟶ 3,159:
rcprop: 'patrolled',
rctitle: ctx.pageName,
rclimit: 1,
format: 'json'
};
 
Line 3,371 ⟶ 3,394:
meta: 'tokens',
type: 'csrf',
titles: ctx.pageName,
format: 'json'
};
// Protection not checked for flagged-revs or non-sysop moves
Line 3,391 ⟶ 3,415:
// callback from loadApi.post()
var fnLoadSuccess = function() {
var xmlresponse = ctx.loadApi.getXMLgetResponse().query;
 
if (!fnCheckPageName(xmlresponse, ctx.onLoadFailure)) {
return; // abort
}
 
var page = response.pages[0], rev;
ctx.pageExists = $(xml).find('page').attr('missing') !== '';
ctx.pageExists = !page.missing;
if (ctx.pageExists) {
rev = page.revisions[0];
ctx.pageText = $(xml).find('rev').text();
ctx.pageIDlastEditTime = $(xml)rev.find('page').attr('pageid')timestamp;
ctx.pageText = rev.content;
ctx.pageID = page.pageid;
} else {
ctx.pageText = ''; // allow for concatenation, etc.
ctx.pageID = 0; // nonexistent in response, matches wgArticleId
}
ctx.csrfToken = $(xml)response.find('tokens').attr('csrftoken');
if (!ctx.csrfToken) {
ctx.statusElement.error('Failed to retrieve edit token.');
Line 3,411 ⟶ 3,438:
return;
}
ctx.loadTime = $(xml)ctx.findloadApi.getResponse('api').attr('curtimestamp');
if (!ctx.loadTime) {
ctx.statusElement.error('Failed to retrieve current timestamp.');
Line 3,418 ⟶ 3,445:
}
 
ctx.contentModel = $(xml).find('page').attr('contentmodel');
 
// extract protection info, to alert admins when they are about to edit a protected page
// Includes cascading protection
if (Morebits.userIsSysop) {
var editproteditProt = $(xml)page.findprotection.filter(function('pr[type="edit"]'); {
if return (editprotpr.lengthtype >=== 0'edit' && editprotpr.attr('level') === 'sysop') {;
}).pop();
ctx.fullyProtected = editprot.attr('expiry');
if (editProt) {
ctx.fullyProtected = editProt.expiry;
} else {
ctx.fullyProtected = false;
Line 3,430 ⟶ 3,460:
}
 
ctx.revertCurID = page.lastrevid;
ctx.lastEditTime = $(xml).find('rev').attr('timestamp');
ctx.revertCurID = $(xml).find('page').attr('lastrevid');
 
var testactions = $(xml)page.find('actions');
ctx.testActions = []; // was null
if (testactions.length) {
Object.keys(testactions).forEach(function(action) {
ctx.testActions = []; // was null
$.eachif (testactions[0action].attributes, function(_idx, value) {
ctx.testActions.push(value.nameaction);
});
});
 
if (ctx.editMode === 'revert') {
ctx.revertCurID = $(xml).find('rev') && rev.attr('revid');
if (!ctx.revertCurID) {
ctx.statusElement.error('Failed to retrieve current revision ID.');
Line 3,448 ⟶ 3,477:
return;
}
ctx.revertUser = $(xml).find('rev') && rev.attr('user');
if (!ctx.revertUser) {
if ($(xml).find('rev').attr('userhidden') ===&& ''rev.userhidden) { // username was RevDel'd or oversighted
ctx.revertUser = '<username hidden>';
} else {
Line 3,469 ⟶ 3,498:
 
// helper function to parse the page name returned from the API
var fnCheckPageName = function(xmlresponse, onFailure) {
if (!onFailure) {
onFailure = emptyFunction;
}
 
var page = response.pages[0];
// check for invalid titles
if ($(xml).find('page').attr('invalid') === '') {
ctx.statusElement.error('The page title is invalid: ' + ctx.pageName);
onFailure(this);
Line 3,482 ⟶ 3,512:
 
// retrieve actual title of the page after normalization and redirects
if ($(xml).find('page').attr('title')) {
var resolvedName = $(xml).find('page').attr('title');
 
if ($(xml)response.find('redirects').length > 0) {
// check for cross-namespace redirect:
var origNs = new mw.Title(ctx.pageName).namespace;
Line 3,516 ⟶ 3,546:
var fnSaveSuccess = function() {
ctx.editMode = 'all'; // cancel append/prepend/newSection/revert modes
var xmlresponse = ctx.saveApi.getXMLgetResponse();
 
// see if the API thinks we were successful
if ($(xml)response.find('edit').attr('result') === 'Success') {
 
// real success
Line 3,535 ⟶ 3,565:
// errors here are only generated by extensions which hook APIEditBeforeSave within MediaWiki,
// which as of 1.34.0-wmf.23 (Sept 2019) should only encompass captcha messages
if ($(xml)response.find('captcha')edit.length > 0captcha) {
ctx.statusElement.error('Could not save the page because the wiki server wanted you to fill out a CAPTCHA.');
} else {
Line 3,595 ⟶ 3,625:
 
case 'abusefilter-disallowed':
ctx.statusElement.error('The edit was disallowed by the edit filter: "' + $(ctx.saveApi.getXMLgetResponse()).find('error.abusefilter').attr('description') + '".');
break;
 
case 'abusefilter-warning':
ctx.statusElement.error([ 'A warning was returned by the edit filter: "', $(ctx.saveApi.getXMLgetResponse()).find('error.abusefilter').attr('description'), '". If you wish to proceed with the edit, please carry it out again. This warning will not appear a second time.' ]);
// We should provide the user with a way to automatically retry the action if they so choose -
// I can't see how to do this without creating a UI dependency on Morebits.wiki.page though -- TTO
Line 3,605 ⟶ 3,635:
 
case 'spamblacklist':
// .find('matches') returns an array in caseIf multiple items are blacklisted, we only return the first
var spam = $(ctx.saveApi.getXMLgetResponse()).find('error.spamblacklist').find('matches').children()[0].textContent;
ctx.statusElement.error('Could not save the page because the URL ' + spam + ' is on the spam blacklist');
break;
Line 3,622 ⟶ 3,652:
 
var fnLookupCreationSuccess = function() {
var xmlresponse = ctx.lookupCreationApi.getXMLgetResponse().query;
 
if (!fnCheckPageName(xmlresponse)) {
return; // abort
}
 
var rev = response.pages[0].revisions && response.pages[0].revisions[0];
if (!ctx.lookupNonRedirectCreator || !/^\s*#redirect/i.test($(xml).find('rev').text())) {
if (!rev) {
ctx.statusElement.error('Could not find any revisions of ' + ctx.pageName);
return;
}
 
if (!ctx.lookupNonRedirectCreator || !/^\s*#redirect/i.test(rev.content)) {
 
ctx.creator = $(xml).find('rev').attr('user');
if (!ctx.creator) {
ctx.statusElement.error('Could not find name of page creator');
return;
}
ctx.timestamp = $(xml).find('rev').attr('timestamp');
if (!ctx.timestamp) {
ctx.statusElement.error('Could not find timestamp of page creation');
Line 3,654 ⟶ 3,690:
 
var fnLookupNonRedirectCreator = function() {
var xmlresponse = ctx.lookupCreationApiookupCreationApi.getXMLgetResponse().query;
var revs = response.pages[0].revisions;
 
$(xml).find('rev')revs.eachforEach(function(_, rev) {
if (!/^\s*#redirect/i.test(rev.textContent)) { // inaccessible revisions also check out
ctx.creator = rev.getAttribute('user');
ctx.timestamp = rev.getAttribute('timestamp');
return false; // break
}
Line 3,666 ⟶ 3,703:
if (!ctx.creator) {
// fallback to give first revision author if no non-redirect version in the first 50
ctx.creator = $(xml).find('rev')revs[0].getAttribute('user');
ctx.timestamp = $(xml).find('rev')revs[0].getAttribute('timestamp');
if (!ctx.creator) {
ctx.statusElement.error('Could not find name of page creator');
Line 3,713 ⟶ 3,750:
* @param {string} action - The action being checked.
* @param {string} onFailure - Failure callback.
* @param {string} xmlresponse - The response document from the API call.
* @returns {boolean}
*/
var fnProcessChecks = function(action, onFailure, xmlresponse) {
var missing = $(xml)response.find('page')pages[0].attr('missing') === '';
 
// No undelete as an existing page could have deleted revisions
Line 3,734 ⟶ 3,771:
var editprot;
if (action === 'undelete') {
editprot = $(xml)response.find('prpages[type="create"0]'.protection.filter(function(pr); {
return pr.type === 'create' && pr.level === 'sysop';
}).pop();
} else if (action === 'delete' || action === 'move') {
editprot = $(xml)response.find('prpages[type="edit"0]'.protection.filter(function(pr); {
return pr.type === 'edit' && pr.level === 'sysop';
}).pop();
}
if (editprot && editprot.length && editprot.attr('level') === 'sysop' && !ctx.suppressProtectWarning &&
!confirm('You are about to ' + action + ' the fully protected page "' + ctx.pageName +
(editprot.attr('expiry') === 'infinity' ? '" (protected indefinitely)' : '" (protection expiring ' + new Morebits.date(editprot.attr('expiry')).calendar('utc') + ' (UTC))') +
'. \n\nClick OK to proceed with ' + action + ', or Cancel to skip.')) {
ctx.statusElement.error('Aborted ' + action + ' on fully protected page.');
Line 3,747 ⟶ 3,788:
}
 
if (!$(xml)response.find('tokens').attr('csrftoken')) {
ctx.statusElement.error('Failed to retrieve token.');
onFailure(this);
Line 3,762 ⟶ 3,803:
pageTitle = ctx.pageName;
} else {
var xmlresponse = ctx.moveApi.getXMLgetResponse().query;
 
if (!fnProcessChecks('move', ctx.onMoveFailure, xmlresponse)) {
return; // abort
}
 
token = $(xml)response.find('tokens').attr('csrftoken');
pageTitle = $(xml)response.find('page')pages[0].attr('title');
}
 
Line 3,778 ⟶ 3,819:
'token': token,
'reason': ctx.editSummary,
'watchlist': ctx.watchlistOption,
'format': 'json'
};
if (ctx.changeTags) {
Line 3,804 ⟶ 3,846:
var fnProcessPatrol = function() {
var query = {
action: 'patrol',
format: 'json'
};
 
Line 3,812 ⟶ 3,855:
query.token = mw.user.tokens.get('patrolToken');
} else {
var xmlresponse = ctx.patrolApi.getResponse().query;
 
// Don't patrol if not unpatrolled
if ($(xml)!response.find('rc')recentchanges[0].attr('unpatrolled') !== '') {
return;
}
 
var lastrevid = $(xml)response.find('page')pages[0].attr('lastrevid');
if (!lastrevid) {
return;
Line 3,825 ⟶ 3,868:
query.revid = lastrevid;
 
var token = $(xml)response.find('tokens').attr('patroltoken')csrftoken;
if (!token) {
return;
}
 
query.token = token;
}
Line 3,848 ⟶ 3,890:
ctx.csrfToken = mw.user.tokens.get('csrfToken');
} else {
var xmlresponse = ctx.triageApi.getXMLgetResponse().query;
 
ctx.pageID = $(xml)response.find('page')pages[0].attr('pageid');
if (!ctx.pageID) {
return;
}
 
ctx.csrfToken = $(xml)response.find('tokens').attr('csrftoken');
if (!ctx.csrfToken) {
return;
Line 3,863 ⟶ 3,905:
var query = {
action: 'pagetriagelist',
page_id: ctx.pageID,
format: 'json'
};
 
Line 3,871 ⟶ 3,914:
};
 
// callback from triageProcessListApi.post()
var fnProcessTriage = function() {
var $xmlresponseList = $(ctx.triageProcessListApi.getXMLgetResponse()).pagetriagelist;
// Exit if not in the queue
if ($xml!responseList || responseList.find('pagetriagelist').attr('result') !== 'success') {
return;
}
var page = $xmlresponseList.find('pages _v')&& responseList.pages[0];
// NothingDo nothing if page already triaged/patrolled
if (!page || !parseInt(page.attr('patrol_status'), 10)) {
var query = {
action: 'pagetriageaction',
Line 3,887 ⟶ 3,931:
// Could use an adder to modify/create note:
// summaryAd, but that seems overwrought
token: ctx.csrfToken,
format: 'json'
};
var triageStat = new Morebits.status('Marking page as curated');
Line 3,903 ⟶ 3,948:
pageTitle = ctx.pageName;
} else {
var xmlresponse = ctx.deleteApi.getXMLgetResponse().query;
 
if (!fnProcessChecks('delete', ctx.onDeleteFailure, xmlresponse)) {
return; // abort
}
 
token = $(xml)response.find('tokens').attr('csrftoken');
pageTitle = $(xml)response.find('page')pages[0].attr('title');
}
 
Line 3,918 ⟶ 3,963:
'token': token,
'reason': ctx.editSummary,
'watchlist': ctx.watchlistOption,
'format': 'json'
};
if (ctx.changeTags) {
Line 3,965 ⟶ 4,011:
pageTitle = ctx.pageName;
} else {
var xmlresponse = ctx.undeleteApi.getXMLgetResponse().query;
 
if (!fnProcessChecks('undelete', ctx.onUndeleteFailure, xmlresponse)) {
return; // abort
}
 
token = $(xml)response.find('tokens').attr('csrftoken');
pageTitle = $(xml)response.find('page')pages[0].attr('title');
}
 
Line 3,980 ⟶ 4,026:
'token': token,
'reason': ctx.editSummary,
'watchlist': ctx.watchlistOption,
'format': 'json'
};
if (ctx.changeTags) {
Line 4,027 ⟶ 4,074:
 
var fnProcessProtect = function() {
var xmlresponse = ctx.protectApi.getXMLgetResponse().query;
 
if (!fnProcessChecks('protect', ctx.onProtectFailure, xmlresponse)) {
return; // abort
}
 
var token = $(xml)response.find('tokens').attr('csrftoken');
var pageTitle = $(xml)response.find('page')pages[0].attr('title');
 
// Fetch existing protection levels
var prs = $(xml)response.find('pr')pages[0].protection;
var editprot, =moveprot, prs.filter('[type="edit"]:not([source])')createprot;
prs.forEach(function(pr) {
var moveprot = prs.filter('[type="move"]');
// Filter out protection from cascading
var createprot = prs.filter('[type="create"]');
if (pr.type === 'edit' && !pr.source) {
editprot = pr;
} else if (pr.type === 'move') {
moveprot = pr;
} else if (pr.type === 'create') {
createprot = pr;
}
});
 
 
// Fall back to current levels if not explicitly set
if (!ctx.protectEdit && editprot.length) {
ctx.protectEdit = { level: editprot.attr('level'), expiry: editprot.attr('expiry') };
}
if (!ctx.protectMove && moveprot.length) {
ctx.protectMove = { level: moveprot.attr('level'), expiry: moveprot.attr('expiry') };
}
if (!ctx.protectCreate && createprot.length) {
ctx.protectCreate = { level: createprot.attr('level'), expiry: createprot.attr('expiry') };
}
 
// Default to pre-existing cascading protection if unchanged (similar to above)
if (ctx.protectCascade === null) {
ctx.protectCascade = !!prs.filter(function(pr) {
return pr.cascade;
}).length;
}
// Warn if cascading protection being applied with an invalid protection level,
// which for edit protection will cause cascading to be silently stripped
if (ctx.protectCascade) {
// Also default to pre-existing cascading protection if unchanged (as with others)
if (ctx.protectCascade || (ctx.protectCascade === null && !!prs.filter('[cascade]').length)) {
// On move protection, this is technically stricter than the MW API,
// but seems reasonable to avoid dumb values and misleading log entries (T265626)
Line 4,071 ⟶ 4,132:
ctx.protectEdit.level = 'sysop';
ctx.protectMove.level = 'sysop';
ctx.protectCascade = 'true';
}
 
Line 4,098 ⟶ 4,158:
expiry: expirys.join('|'),
reason: ctx.editSummary,
watchlist: ctx.watchlistOption,
format: 'json'
};
// Only shows up in logs, not page history [[phab:T259983]]
Line 4,124 ⟶ 4,185:
pageTitle = ctx.pageName;
} else {
var xmlresponse = ctx.stabilizeApi.getXMLgetResponse().query;
 
// 'stabilize' as a verb not necessarily well understood
if (!fnProcessChecks('stabilize', ctx.onStabilizeFailure, xmlresponse)) {
return; // abort
}
 
token = $(xml)response.find('tokens').attr('csrftoken');
pageTitle = $(xml)response.find('page')pages[0].attr('title');
}
 
Line 4,143 ⟶ 4,204:
// tags: ctx.changeTags, // flaggedrevs tag support: [[phab:T247721]]
reason: ctx.editSummary,
watchlist: ctx.watchlistOption, // Doesn't support watchlist expiry [[phab:T263336]]
format: 'json'
};
 
Line 4,207 ⟶ 4,269:
text: wikitext,
title: pageTitle || mw.config.get('wgPageName'),
disablelimitreport: true,
format: 'json'
};
if (sectionTitle) {
Line 4,218 ⟶ 4,281:
 
var fnRenderSuccess = function(apiobj) {
var xmlhtml = apiobj.getXMLgetResponse().parse.text;
var html = $(xml).find('text').text();
if (!html) {
apiobj.statelem.error('failed to retrieve preview, or template was blanked');
Line 4,256 ⟶ 4,318:
start = start || 0;
 
var count = -1; // Number of parameters found
var unnamed = 0; // Keep track of what number an unnamed parameter should receive
var level = -1;
var level = -1; // How many levels deep of template code we're in, 0-based
var equals = -1;
var equals = -1; // After finding "=" before a parameter, the index; otherwise, -1
var current = '';
var result = {
Line 4,265 ⟶ 4,328:
};
var key, value;
 
/**
* Function to handle finding parameter values.
*
* @param {boolean} [final=false] - Whether this is the final
* parameter and we need to remove the trailing `}}`.
*/
var findParam = function(final) {
// Nothing found yet, this must be the template name
if (count === -1) {
result.name = current.substring(2).trim();
++count;
} else {
// In a parameter
if (equals !== -1) {
// We found an equals, so save the parameter as key: value
key = current.substring(0, equals).trim();
value = final ? current.substring(equals + 1, current.length - 2).trim() : current.substring(equals + 1).trim();
result.parameters[key] = value;
equals = -1;
} else {
// No equals, so it must be unnamed; no trim since whitespace allowed
var param = final ? current.substring(equals + 1, current.length - 2) : current;
if (param) {
result.parameters[++unnamed] = param;
++count;
}
}
}
};
 
for (var i = start; i < text.length; ++i) {
var test3 = text.substr(i, 3);
if (test3 === '{{{' || test3 === '}}}') {
current += '{{{'test3;
i += 2;
test3 === '}}}' ? --level : ++level;
continue;
}
if (test3 === '}}}') {
current += '}}}';
i += 2;
--level;
continue;
}
var test2 = text.substr(i, 2);
// Entering a template (or link)
if (test2 === '{{' || test2 === '[[') {
current += test2;
Line 4,287 ⟶ 4,375:
continue;
}
// Leaving a link
if (test2 === ']]') {
current += ']]';
Line 4,293 ⟶ 4,382:
continue;
}
// Either leaving a template or an internal template/parser function
if (test2 === '}}') {
// Regardless, decrement the level
current += test2;
++i;
--level;
 
// Find the final parameter if this really is the end
if (level <= 0) {
if (countlevel === -1) {
findParam(true);
result.name = current.substring(2).trim();
++count;
} else {
if (equals !== -1) {
key = current.substring(0, equals).trim();
value = current.substring(equals + 1, current.length - 2).trim();
result.parameters[key] = value;
equals = -1;
} else {
result.parameters[count] = current;
++count;
}
}
break;
}
Line 4,318 ⟶ 4,397:
}
 
if (text.charAt(i) === '|' && level <=== 0) {
// Another pipe found, toplevel, so parameter coming up!
if (count === -1) {
findParam();
result.name = current.substring(2).trim();
++count;
} else {
if (equals !== -1) {
key = current.substring(0, equals).trim();
value = current.substring(equals + 1).trim();
result.parameters[key] = value;
equals = -1;
} else {
result.parameters[count] = current;
++count;
}
}
current = '';
} else if (equals === -1 && text.charAt(i) === '=' && level <=== 0) {
// Equals found, toplevel
equals = current.length;
current += text.charAt(i);
} else {
// Just advance the position
current += text.charAt(i);
}