MediaWiki:Gadget-morebits.js: Difference between revisions

Content deleted Content added
Repo at c0f5591: Better zero-pad (don't break on properly-padded dates)
Repo at 4cba4ef: date: cleanup constructor (#1187); Fix bug in fnProcessChecks; Rename Morebits.wiki.isPageRedirect to Morebits.isPageRedirect; Add intestactions to loadQuery; Default date.format to ISOString; Properly detect and process existing cascading protection; Don't overwrite unprovided edit protection with cascading; Use common checks for m.w.p methods; Do triage properly, with extra query to determine if page is in queue
Line 1,033:
RegExp.escape = function(text, space_fix) {
if (space_fix) {
console.logwarn('NOTE: RegExp.escape from Morebits was deprecated September 2020, please replace it with Morebits.string.escapeRegExp'); // eslint-disable-line no-console
return Morebits.string.escapeRegExp(text);
}
console.logwarn('NOTE: RegExp.escape from Morebits was deprecated September 2020, please replace it with mw.util.escapeRegExp'); // eslint-disable-line no-console
return mw.util.escapeRegExp(text);
};
Line 1,303:
};
 
 
/**
* Determines whether the current page is a redirect or soft redirect
* (fails to detect soft redirects on edit, history, etc. pages)
* Will attempt to detect Module:RfD, with the same failure points
* @returns {boolean}
*/
Morebits.isPageRedirect = function() {
return !!(mw.config.get('wgIsRedirect') || document.getElementById('softredirect') || $('.box-RfD').length);
};
 
/**
Line 1,403 ⟶ 1,413:
Morebits.date = function() {
var args = Array.prototype.slice.call(arguments);
this._d = new (Function.prototype.bind.apply(Date, [Date].concat(args)));
 
if (!this.isValid()) {
// Date.parse implementations vary too much between browsers, and
if (args.length === 1 && typeof args[0] === 'string') {
// MediaWiki's format is too non-standard, so we just convert MW
// Check if it's a MediaWiki signature timestamp (which the native Date cannot parse directly)
// timestamps to ISO-8601. A paren-wrapped 'UTC' messes everyone up,
var dateParts = Morebits.date.localeData.signatureTimestampFormat(args[0]);
// and the comma after the time is only okay in modern Firefox. After
if (dateParts) {
// this first replace, Chrome and Firefox are content. The second
this._d = new Date(Date.UTC.apply(null, dateParts));
// replace is mainly for Safari, which basically *only* accepts the
// simplified ECMA-262 implementation of ISO-8601.
if (typeof args[0] === 'string') {
args[0] = args[0].replace(/(\d\d:\d\d),/, '$1').replace(/\(UTC\)/, 'UTC');
// Safari is particular about timezone offsets, so this is intentionally specific
args[0] = args[0].replace(/(\d\d:\d\d) (\d{1,2}) ([A-Z][a-z]+) (\d{4}) UTC$/, function(match, time, date, monthname, year) {
// zero-pad date
if (date.length === 1) {
date = '0' + date;
}
}
return [year, mw.config.get('wgMonthNames').indexOf(monthname), date].join('-') + 'T' + time + 'Z';
}
});
// Still no?
if (!this.isValid()) {
mw.log.warn('Invalid Morebits.date initialisation:', args);
}
this._d = new (Function.prototype.bind.apply(Date, [Date].concat(args)));
};
 
Line 1,437 ⟶ 1,442:
pastWeek: '[Last] dddd [at] h:mm A',
other: 'YYYY-MM-DD'
},
signatureTimestampFormat: function (str) {
var rgx = /(\d{2}):(\d{2}), (\d{1,2}) (\w+) (\d{4}) \(UTC\)/;
var match = rgx.exec(str);
if (!match) {
return null;
}
var month = Morebits.date.localeData.months.indexOf(match[4]);
if (month === -1) {
return null;
}
// ..... year ... month .. date ... hour .... minute
return [match[5], month, match[3], match[1], match[2]];
}
};
Line 1,534 ⟶ 1,552:
*/
format: function(formatstr, zone) {
if (!this.isValid()) {
return 'Invalid date'; // Put the truth out, preferable to "NaNNaNNan NaN:NaN" or whatever
}
var udate = this;
// create a new date object that will contain the date to display as system time
Line 1,541 ⟶ 1,562:
// convert to utc, then add the utc offset given
udate = new Morebits.date(this.getTime()).add(this.getTimezoneOffset() + zone, 'minutes');
}
 
// default to ISOString
if (!formatstr) {
return udate.toISOString();
}
 
Line 1,641 ⟶ 1,667:
Morebits.wiki = {};
 
/** @deprecated in favor of Morebits.isPageRedirect */
/**
* Determines whether the current page is a redirect or soft redirect
* (fails to detect soft redirects on edit, history, etc. pages)
* @returns {boolean}
*/
Morebits.wiki.isPageRedirect = function wikipediaIsPageRedirect() {
console.warn('NOTE: Morebits.wiki.isPageRedirect has been deprecated, use Morebits.isPageRedirect instead.'); // eslint-disable-line no-console
return !!(mw.config.get('wgIsRedirect') || document.getElementById('softredirect'));
return Morebits.isPageRedirect();
};
 
 
 
Line 2,095 ⟶ 2,117:
editSummary: null,
changeTags: null,
testActions: null, // array if any valid actions
callbackParameters: null,
statusElement: new Morebits.status(currentAction),
Line 2,130 ⟶ 2,153:
protectMove: null,
protectCreate: null,
protectCascade: falsenull,
 
// - creation lookup
Line 2,178 ⟶ 2,201:
patrolProcessApi: null,
triageApi: null,
triageProcessListApi: null,
triageProcessApi: null,
deleteApi: null,
Line 2,210 ⟶ 2,234:
action: 'query',
prop: 'info|revisions',
intestactions: 'edit', // can be expanded
curtimestamp: '',
meta: 'tokens',
Line 2,760 ⟶ 2,785:
this.getCreationTimestamp = function() {
return ctx.timestamp;
};
 
/** @returns {boolean} whether or not you can edit the page */
this.canEdit = function() {
return !!ctx.testActions && ctx.testActions.indexOf('edit') !== -1;
};
 
Line 2,834 ⟶ 2,864:
ctx.onMoveFailure = onFailure || emptyFunction;
 
if (!fnPreflightChecks.call(this, 'move', ctx.editSummaryonMoveFailure)) {
return; // abort
ctx.statusElement.error('Internal error: move reason not set before move (use setEditSummary function)!');
ctx.onMoveFailure(this);
return;
}
 
if (!ctx.moveDestination) {
ctx.statusElement.error('Internal error: destination page name was not set before move!');
Line 2,906 ⟶ 2,935:
* passing a pageid to the API is sufficient, so in those cases just
* using Morebits.wiki.api is probably preferable.
*
* Will first check if the page is queued via fnProcessTriageList
*
* No error handling since we don't actually care about the errors
Line 2,921 ⟶ 2,952:
if (new mw.Title(Morebits.pageNameNorm).getPrefixedText() === new mw.Title(ctx.pageName).getPrefixedText()) {
ctx.pageID = mw.config.get('wgArticleId');
fnProcessTriagefnProcessTriageList(this, this);
} else {
var query = fnNeedTokenInfoQuery('triage');
 
ctx.triageApi = new Morebits.wiki.api('retrieving token...', query, fnProcessTriagefnProcessTriageList);
ctx.triageApi.setParent(this);
ctx.triageApi.post();
Line 2,942 ⟶ 2,973:
ctx.onDeleteFailure = onFailure || emptyFunction;
 
if (!fnPreflightChecks.call(this, 'delete', ctx.onDeleteFailure)) {
// if a non-admin tries to do this, don't bother
return; // abort
if (!Morebits.userIsSysop) {
ctx.statusElement.error('Cannot delete page: only admins can do that');
ctx.onDeleteFailure(this);
return;
}
if (!ctx.editSummary) {
ctx.statusElement.error('Internal error: delete reason not set before delete (use setEditSummary function)!');
ctx.onDeleteFailure(this);
return;
}
 
Line 2,974 ⟶ 2,997:
ctx.onUndeleteFailure = onFailure || emptyFunction;
 
if (!fnPreflightChecks.call(this, 'undelete', ctx.onUndeleteFailure)) {
// if a non-admin tries to do this, don't bother
return; // abort
if (!Morebits.userIsSysop) {
ctx.statusElement.error('Cannot undelete page: only admins can do that');
ctx.onUndeleteFailure(this);
return;
}
if (!ctx.editSummary) {
ctx.statusElement.error('Internal error: undelete reason not set before undelete (use setEditSummary function)!');
ctx.onUndeleteFailure(this);
return;
}
 
Line 3,006 ⟶ 3,021:
ctx.onProtectFailure = onFailure || emptyFunction;
 
if (!fnPreflightChecks.call(this, 'protect', ctx.onProtectFailure)) {
// if a non-admin tries to do this, don't bother
return; // abort
if (!Morebits.userIsSysop) {
ctx.statusElement.error('Cannot protect page: only admins can do that');
ctx.onProtectFailure(this);
return;
}
 
if (!ctx.protectEdit && !ctx.protectMove && !ctx.protectCreate) {
ctx.statusElement.error('Internal error: you must set edit and/or move and/or create protection before calling protect()!');
ctx.onProtectFailure(this);
return;
}
if (!ctx.editSummary) {
ctx.statusElement.error('Internal error: protection reason not set before protect (use setEditSummary function)!');
ctx.onProtectFailure(this);
return;
Line 3,044 ⟶ 3,052:
ctx.onStabilizeFailure = onFailure || emptyFunction;
 
if (!fnPreflightChecks.call(this, 'FlaggedRevs', ctx.onStabilizeFailure)) {
// if a non-admin tries to do this, don't bother
return; // abort
if (!Morebits.userIsSysop) {
ctx.statusElement.error('Cannot apply FlaggedRevs settings: only admins can do that');
ctx.onStabilizeFailure(this);
return;
}
 
if (!ctx.flaggedRevs) {
ctx.statusElement.error('Internal error: you must set flaggedRevs before calling stabilize()!');
ctx.onStabilizeFailure(this);
return;
}
if (!ctx.editSummary) {
ctx.statusElement.error('Internal error: reason not set before calling stabilize() (use setEditSummary function)!');
ctx.onStabilizeFailure(this);
return;
Line 3,192 ⟶ 3,193:
ctx.lastEditTime = $(xml).find('rev').attr('timestamp');
ctx.revertCurID = $(xml).find('page').attr('lastrevid');
 
var testactions = $(xml).find('actions');
if (testactions.length) {
ctx.testActions = []; // was null
$.each(testactions[0].attributes, function(_idx, value) {
ctx.testActions.push(value.name);
});
}
 
if (ctx.editMode === 'revert') {
Line 3,433 ⟶ 3,442:
ctx.onLookupCreationSuccess(this);
 
};
 
// Common checks for action methods
// Used for move, undelete, delete, protect, stabilize
var fnPreflightChecks = function(action, onFailure) {
// if a non-admin tries to do this, don't bother
if (!Morebits.userIsSysop && action !== 'move') {
ctx.statusElement.error('Cannot ' + action + 'page : only admins can do that');
onFailure(this);
return false;
}
 
if (!ctx.editSummary) {
ctx.statusElement.error('Internal error: ' + action + ' reason not set (use setEditSummary function)!');
onFailure(this);
return false;
}
return true; // all OK
};
 
// Common checks for fnProcess functions (fnProcessDelete, fnProcessMove, etc.)
// Used for move, undelete, delete, protect, stabilize
var fnProcessChecks = function(action, onFailure, xml) {
var missing = $(xml).find('page').attr('missing') === '';
 
// No undelete as an existing page could have deleted revisions
var actionMissing = missing && ['delete', 'stabilize', 'move'].indexOf(action) !== -1;
var protectMissing = action === 'protect' && missing && (ctx.protectEdit || ctx.protectMove);
var saltMissing = action === 'protect' && !missing && ctx.protectCreate;
 
if (actionMissing || protectMissing || saltMissing) {
ctx.statusElement.error('Cannot ' + action + ' the page because it ' + (missing ? 'no longer' : 'already') + ' exists');
onFailure(this);
return false;
}
 
// Delete, undelete, move
// extract protection info
var editprot;
if (action === 'undelete') {
editprot = $(xml).find('pr[type="create"]');
} else if (action === 'delete' || action === 'move') {
editprot = $(xml).find('pr[type="edit"]');
}
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.');
onFailure(this);
return false;
}
 
if (!$(xml).find('tokens').attr('csrftoken')) {
ctx.statusElement.error('Failed to retrieve token.');
onFailure(this);
return false;
}
return true; // all OK
};
 
Line 3,444 ⟶ 3,512:
var xml = ctx.moveApi.getXML();
 
if ($(xml).find!fnProcessChecks('pagemove'), ctx.attr('missing'onMoveFailure, xml) === '') {
return; // abort
ctx.statusElement.error('Cannot move the page, because it no longer exists');
ctx.onMoveFailure(this);
return;
}
 
// extract protection info
if (Morebits.userIsSysop) {
var editprot = $(xml).find('pr[type="edit"]');
if (editprot.length > 0 && editprot.attr('level') === 'sysop' && !ctx.suppressProtectWarning &&
!confirm('You are about to move 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 the move, or Cancel to skip this move.')) {
ctx.statusElement.error('Move of fully protected page was aborted.');
ctx.onMoveFailure(this);
return;
}
}
 
token = $(xml).find('tokens').attr('csrftoken');
if (!token) {
ctx.statusElement.error('Failed to retrieve move token.');
ctx.onMoveFailure(this);
return;
}
 
pageTitle = $(xml).find('page').attr('title');
}
Line 3,541 ⟶ 3,588:
};
 
// Ensure that the page is curatable
var fnProcessTriage = function() {
var fnProcessTriageList = function() {
var pageID, token;
 
if (ctx.pageID) {
tokenctx.csrfToken = mw.user.tokens.get('csrfToken');
pageID = ctx.pageID;
} else {
var xml = ctx.triageApi.getXML();
 
ctx.pageID = $(xml).find('page').attr('pageid');
if (!ctx.pageID) {
return;
}
 
tokenctx.csrfToken = $(xml).find('tokens').attr('csrftoken');
if (!tokenctx.csrfToken) {
return;
}
Line 3,562 ⟶ 3,607:
 
var query = {
action: 'pagetriageactionpagetriagelist',
pageidpage_id: ctx.pageID,
reviewed: 1,
// tags: ctx.changeTags, // pagetriage tag support: [[phab:T252980]]
// Could use an adder to modify/create note:
// summaryAd, but that seems overwrought
token: token
};
 
var triageStatctx.triageProcessListApi = new Morebits.statuswiki.api('Markingchecking pagecuration as curatedstatus...', query, fnProcessTriage);
ctx.triageProcessListApi.setParent(this);
 
ctx.triageProcessListApi.post();
ctx.triageProcessApi = new Morebits.wiki.api('curating page...', query, fnProcessTriageSuccess, triageStat, fnProcessTriageError);
ctx.triageProcessApi.setParent(this);
ctx.triageProcessApi.post();
};
 
var fnProcessTriage = function() {
// callback from triageProcessApi.post()
var $xml = $(ctx.triageProcessListApi.getXML());
var fnProcessTriageSuccess = function() {
// Exit if not in the queue
// Swallow succesful return if nothing changed i.e. page in queue and already triaged
if ($(ctx.triageProcessApi.getResponse())xml.find('pagetriageactionpagetriagelist').attr('pagetriage_unchanged_statusresult') !== 'success') {
return;
ctx.triageProcessApi.getStatusElement().unlink();
}
var page = $xml.find('pages _v');
};
// Nothing if page already triaged/patrolled
 
if (!page || !parseInt(page.attr('patrol_status'), 10)) {
// callback from triageProcessApi.post()
var query = {
var fnProcessTriageError = function() {
action: 'pagetriageaction',
// Ignore error if page not in queue, see https://github.com/azatoth/twinkle/pull/930
pageid: ctx.pageID,
if (ctx.triageProcessApi.getErrorCode() === 'bad-pagetriage-page') {
reviewed: 1,
ctx.triageProcessApi.getStatusElement().unlink();
// tags: ctx.changeTags, // pagetriage tag support: [[phab:T252980]]
// Could use an adder to modify/create note:
// summaryAd, but that seems overwrought
token: ctx.csrfToken
};
var triageStat = new Morebits.status('Marking page as curated');
ctx.triageProcessApi = new Morebits.wiki.api('curating page...', query, null, triageStat);
ctx.triageProcessApi.setParent(this);
ctx.triageProcessApi.post();
}
};
Line 3,603 ⟶ 3,650:
var xml = ctx.deleteApi.getXML();
 
if ($(xml).find!fnProcessChecks('pagedelete'), ctx.attr('missing'onDeleteFailure, xml) === '') {
return; // abort
ctx.statusElement.error('Cannot delete the page, because it no longer exists');
ctx.onDeleteFailure(this);
return;
}
 
// extract protection info
var editprot = $(xml).find('pr[type="edit"]');
if (editprot.length > 0 && editprot.attr('level') === 'sysop' && !ctx.suppressProtectWarning &&
!confirm('You are about to delete 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 the deletion, or Cancel to skip this deletion.')) {
ctx.statusElement.error('Deletion of fully protected page was aborted.');
ctx.onDeleteFailure(this);
return;
}
 
token = $(xml).find('tokens').attr('csrftoken');
if (!token) {
ctx.statusElement.error('Failed to retrieve delete token.');
ctx.onDeleteFailure(this);
return;
}
 
pageTitle = $(xml).find('page').attr('title');
}
Line 3,681 ⟶ 3,709:
var xml = ctx.undeleteApi.getXML();
 
if ($(xml).find!fnProcessChecks('pageundelete'), ctx.attr('missing'onUndeleteFailure, xml) !== '') {
return; // abort
ctx.statusElement.error('Cannot undelete the page, because it already exists');
ctx.onUndeleteFailure(this);
return;
}
 
// extract protection info
var editprot = $(xml).find('pr[type="create"]');
if (editprot.length > 0 && editprot.attr('level') === 'sysop' && !ctx.suppressProtectWarning &&
!confirm('You are about to undelete the fully create 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 the undeletion, or Cancel to skip this undeletion.')) {
ctx.statusElement.error('Undeletion of fully create protected page was aborted.');
ctx.onUndeleteFailure(this);
return;
}
 
token = $(xml).find('tokens').attr('csrftoken');
if (!token) {
ctx.statusElement.error('Failed to retrieve undelete token.');
ctx.onUndeleteFailure(this);
return;
}
 
pageTitle = $(xml).find('page').attr('title');
}
Line 3,759 ⟶ 3,768:
var xml = ctx.protectApi.getXML();
 
if (!fnProcessChecks('protect', ctx.onProtectFailure, xml)) {
var missing = $(xml).find('page').attr('missing') === '';
return; // abort
if ((ctx.protectEdit || ctx.protectMove) && missing) {
ctx.statusElement.error('Cannot protect the page, because it no longer exists');
ctx.onProtectFailure(this);
return;
}
if (ctx.protectCreate && !missing) {
ctx.statusElement.error('Cannot create protect the page, because it already exists');
ctx.onProtectFailure(this);
return;
}
 
// TODO cascading protection not possible on edit<sysop
 
var token = $(xml).find('tokens').attr('csrftoken');
if (!token) {
ctx.statusElement.error('Failed to retrieve protect token.');
ctx.onProtectFailure(this);
return;
}
 
var pageTitle = $(xml).find('page').attr('title');
 
// fetchFetch existing protection levels
var prs = $(xml).find('pr');
var editprot = prs.filter('[type="edit"]:not([source])');
var moveprot = prs.filter('[type="move"]');
var createprot = prs.filter('[type="create"]');
 
// Fall back to current levels if not explicitly set
var protections = [], expirys = [];
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') };
}
 
// Warn if cascading protection being applied with an invalid protection level,
// which for edit protection will cause cascading to be silently stripped
// 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)
if (((!ctx.protectEdit || ctx.protectEdit.level !== 'sysop') ||
(!ctx.protectMove || ctx.protectMove.level !== 'sysop')) &&
!confirm('You have cascading protection enabled on "' + ctx.pageName +
'" but have not selected uniform sysop-level protection.\n\n' +
'Click OK to adjust and proceed with sysop-level cascading protection, or Cancel to skip this action.')) {
ctx.statusElement.error('Cascading protection was aborted.');
ctx.onProtectFailure(this);
return;
}
 
ctx.protectEdit.level = 'sysop';
ctx.protectMove.level = 'sysop';
ctx.protectCascade = 'true';
}
 
// set editBuild protection levellevels and expirys (expiries?) for query
var protections = [], expirys = [];
if (ctx.protectEdit) {
protections.push('edit=' + ctx.protectEdit.level);
expirys.push(ctx.protectEdit.expiry);
} else if (editprot.length) {
protections.push('edit=' + editprot.attr('level'));
expirys.push(editprot.attr('expiry').replace('infinity', 'indefinite'));
}
 
Line 3,802 ⟶ 3,823:
protections.push('move=' + ctx.protectMove.level);
expirys.push(ctx.protectMove.expiry);
} else if (moveprot.length) {
protections.push('move=' + moveprot.attr('level'));
expirys.push(moveprot.attr('expiry').replace('infinity', 'indefinite'));
}
 
Line 3,810 ⟶ 3,828:
protections.push('create=' + ctx.protectCreate.level);
expirys.push(ctx.protectCreate.expiry);
} else if (createprot.length) {
protections.push('create=' + createprot.attr('level'));
expirys.push(createprot.attr('expiry').replace('infinity', 'indefinite'));
}
 
Line 3,847 ⟶ 3,862:
var xml = ctx.stabilizeApi.getXML();
 
// 'stabilize' as a verb not necessarily well understood
var missing = $(xml).find('page').attr('missing') === '';
if (!fnProcessChecks('stabilize', ctx.onStabilizeFailure, xml)) {
if (missing) {
return; // abort
ctx.statusElement.error('Cannot protect the page, because it no longer exists');
ctx.onStabilizeFailure(this);
return;
}
 
token = $(xml).find('tokens').attr('csrftoken');
if (!token) {
ctx.statusElement.error('Failed to retrieve stabilize token.');
ctx.onStabilizeFailure(this);
return;
}
 
pageTitle = $(xml).find('page').attr('title');
}