プロジェクト:ウィキ技術部/スクリプト開発/trunk/MassRevisionDelete.js
表示
/*********************************************************************************************\
MassRevisionDelete
Create an interface to delete multiple revisions at one fell swoop when the script
user visits a user's contributions page.
@link https://ja-two.iwiki.icu/wiki/Help:MassRevisionDelete
@author [[User:Dragoniez]]
\*********************************************************************************************/
//<nowiki>
(function() { // Container IIFE
// ************************************** INITIALIZATION **************************************
/**
* When true, the result of action=revisiondelete is fabricated when the revdel execution button is hit,
* meaning that the user can test all the functionalities of this script but actual revision deletion
* isn't performed.
* @readonly
*/
var debuggingMode = false;
/** @readonly */
var MRD = 'MassRevisionDelete';
// Use script only on [[Special:Contributions]] and [[Special:DeletedContributions]]
var spPageName = mw.config.get('wgCanonicalSpecialPageName');
var isDeletedContribs = spPageName === 'DeletedContributions' ? true : spPageName === 'Contributions' ? false : null;
if (isDeletedContribs === null) return;
// Use script only when the current user has the 'deleterevision' user right
var userRights = {
deleterevision: ['sysop', 'eliminator', 'suppress', 'global-sysop', 'staff', 'steward', 'sysadmin'],
suppressrevision: ['suppress', 'staff'],
apihighlimits: ['sysop', 'apihighlimits-requestor', 'global-sysop', 'staff', 'steward', 'sysadmin', 'wmf-researcher']
};
var canRevDel = false;
var canOversight = false;
var hasApiHighLimits = false;
// @ts-ignore
mw.config.get('wgUserGroups').concat(mw.config.get('wgGlobalGroups')).some(function(group) {
if (!canRevDel) {
canRevDel = userRights.deleterevision.indexOf(group) !== -1;
}
if (!canOversight) {
canOversight = userRights.suppressrevision.indexOf(group) !== -1;
}
if (!hasApiHighLimits) { // Bots excluded
hasApiHighLimits = userRights.apihighlimits.indexOf(group) !== -1;
}
return canRevDel && canOversight && hasApiHighLimits; // Get out of the loop when all entries have become true
});
if (!canRevDel) return;
/**
* The maximum number of revisions that can be handled by one API request
* @readonly
*/
var apilimit = hasApiHighLimits ? 500 : 50;
/** @type {mw.Api} @readonly */
var api;
/** @readonly */
var classSuppressed = 'mw-history-suppressed';
/** @readonly */
var classDeleted = 'history-deleted';
/**
* Interface messages.
* @readonly
*/
var msg = {
'rev-deleted-comment': '(edit summary removed)',
'rev-deleted-user-contribs': '[username or IP address removed - edit hidden from contributions]',
'rev-delundel': 'change visibility',
/** This message should be hidden on the DOM by '.mw-comment-none'. */
'changeslist-nocomment': 'No edit summary'
};
/** @readonly */
var msgNames = Object.keys(msg);
// Load dependent modules and create form
mw.loader.using('mediawiki.api', function() {
api = new mw.Api();
$.when(
getMessages(msgNames), // Get local interface messages
mw.loader.using('jquery.ui'),
$.ready
).then(function(res) {
msgNames.forEach(function(name) {
if (res[name]) msg[name] = res[name]; // Set local interface messages
});
createForm();
});
});
// ************************************** MAIN FUNCTIONS **************************************
/**
* Get interface messages.
* @param {string[]} namesArr
* @returns {JQueryPromise<Object.<string, string>>} name-message pairs
*/
function getMessages(namesArr) {
return api.getMessages(namesArr)
.then(function(res) {
return res || {};
}).catch(function(code, err) {
console.error(MRD, err);
return {};
});
}
/** Create the MassRevisionDelete interface. */
function createForm() {
/** @type {JQuery<HTMLUListElement>} */
var $contribsList = $('ul.mw-contributions-list');
if (!$contribsList.length) return;
// Counter of revisions that are to be (un)deleted (will be appended to the DOM later)
var checkedCounter = document.createElement('span');
checkedCounter.style.display = 'block';
checkedCounter.style.marginTop = '0.5em';
checkedCounter.textContent = '選択済み版数: ';
var checkedCounterNum = document.createElement('span');
checkedCounterNum.textContent = '0';
checkedCounter.appendChild(checkedCounterNum);
// Add checkboxes etc. to the contribs list and create a reference object
var listHasHiddenField = false;
var revdelLinkFontSize = $('.mw-revdelundel-link:first').css('font-size');
/** @type {string[]} */
var revidsNoComment = [];
/** @type {RevisionList} */
var revisionList = Array.prototype.reduce.call($contribsList.children('li'), // Create object out of all contribs list items
/**
* @param {RevisionList} acc
* @param {HTMLLIElement} listitem
*/
function(acc, listitem) {
var $listitem = $(listitem);
var clss = isDeletedContribs ? '.mw-changeslist-title' : '.mw-contributions-title';
var $title = $listitem.children(clss).eq(0);
var title = $title.text();
var revid = listitem.dataset.mwRevid;
// Get the 'change visibility' link and create its disabled alternant
/** @type {HTMLSpanElement|null} */
var revdelLink = listitem.querySelector('.mw-revdelundel-link');
var revdelLinkDisabled = document.createElement('span');
revdelLinkDisabled.classList.add('mrd-revdelundel-link');
revdelLinkDisabled.style.display = 'none';
revdelLinkDisabled.style.fontSize = revdelLinkFontSize;
revdelLinkDisabled.textContent = '(' + msg['rev-delundel'] + ')';
listitem.prepend(revdelLinkDisabled);
// Checkbox
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.classList.add('mrd-revdel-target');
checkbox.style.marginRight = '0.5em';
checkbox.addEventListener('change', function() { // Update counter when the box is (un)checked
// @ts-ignore
var num = parseInt(checkedCounterNum.textContent);
checkedCounterNum.textContent = (this.checked ? ++num : --num).toString();
});
listitem.prepend(checkbox);
// A span tag to show the progress of revdel execution
var progress = document.createElement('span');
progress.style.cssText = 'display: none; margin-right: 0.5em;';
progress.classList.add('mrd-progress');
listitem.prepend(progress);
/**
* PermaLink with a date text. See toggleContentVisibility() for all possible HTML structures.
* @type {HTMLSpanElement|HTMLAnchorElement|null}
*/
var date = (function() {
/** @type {HTMLAnchorElement|null} */
var dateAnchor = listitem.querySelector('a.mw-changeslist-date');
var pr;
if (dateAnchor && (pr = dateAnchor.parentElement) && pr.tagName === 'SPAN' && pr.classList.contains(classDeleted)) {
return pr; // span
} else {
return dateAnchor; // a or null
}
})();
/**
* Comment (missing on [[Special:DeletedContributions]] if the revision has no edit summary; create a tag in this case).
* See toggleCommentVisibility() for all possible HTML structures.
* @type {HTMLSpanElement|null}
*/
var comment = listitem.querySelector('.comment');
if (!comment) {
comment = document.createElement('span');
comment.classList.add('comment');
if (!isDeletedContribs) comment.classList.add('comment--without-parentheses');
$title.after(comment);
}
// Comment in an HTML format
var parsedcomment;
if (!comment.classList.contains(classDeleted) && !comment.classList.contains(classSuppressed) || // On Contributions and not deleted
// On DeletedContributions, parsed comment can be fetched even when deleted (unless oversighted in non-OS view)
isDeletedContribs && (canOversight ? true : !comment.classList.contains(classSuppressed))
) {
parsedcomment = comment.innerHTML;
} else {
parsedcomment = '';
if (revid && revidsNoComment.indexOf(revid) === -1) {
// Register revid for a later API request (fetch parsed comment from the API)
revidsNoComment.push(revid);
}
}
// 'Username hidden' warning (create one if there isn't any)
var $userhidden = $listitem.children('strong').filter(function() { return $(this).text() === msg['rev-deleted-user-contribs']; });
var userhidden = (function() {
/** @type {HTMLElement} */
var strong;
if ($userhidden.length) {
strong = $userhidden[0];
} else {
strong = document.createElement('strong');
strong.style.cssText = 'display: none; margin-right: 0.5em;';
strong.textContent = msg['rev-deleted-user-contribs'];
if (comment.nextElementSibling) { // Insert after the comment tag
listitem.insertBefore(strong, comment.nextElementSibling);
} else {
listitem.appendChild(strong);
}
}
strong.classList.add('mrd-userhidden');
return strong;
})();
var hasDeletedField = !!listitem.querySelector('.' + classDeleted);
var hasOversightedField = !!listitem.querySelector('.' + classSuppressed);
if (!listHasHiddenField) listHasHiddenField = hasDeletedField || hasOversightedField;
if (hasOversightedField && !canOversight) {
// Can't perform revdel if any item of the revision is oversighted and the current user isn't an oversighter
checkbox.disabled = true;
} else if (revdelLink && revid) {
// Labelify the 'change visibility' link for the checkbox
revdelLink.addEventListener('click', function(e) {
if (!e.shiftKey && !e.ctrlKey) {
e.preventDefault();
checkbox.checked = !checkbox.checked;
checkbox.dispatchEvent(new Event('change')); // Update counter
}
});
// Add object (only if the current user can (un)delete this revision)
acc[revid] = {
progress: progress,
checkbox: checkbox,
label: revdelLink,
pseudolabel: revdelLinkDisabled,
date: date,
title: title,
comment: comment,
parsedcomment: parsedcomment,
userhidden: userhidden,
revdeled: hasDeletedField || hasOversightedField,
suppressed: hasOversightedField,
current: !!listitem.querySelector('.mw-uctop')
};
}
return acc;
},
Object.create(null));
if (!Object.keys(revisionList).length) return; // Exit if all the revisions are inaccessible for revdel
// Get revdel'd edit summaries and overwrite the revisionList object
getParsedComment(revidsNoComment).then(function(obj) {
Object.keys(obj).forEach(function(revid) {
revisionList[revid].parsedcomment = obj[revid];
});
});
// Create a style tag
var style = document.createElement('style');
style.textContent =
// General
'#mrd-form legend {' +
'font-weight: bold;' +
'}' +
'#mrd-form select,' +
'#mrd-form input {' +
'box-sizing: border-box;' +
'}' +
'#mrd-form,' +
'#mrd-form fieldset {' +
'border-color: #a2a9b1;' +
'}' +
// Right margin for buttons
'#mrd-buttons > div > input:not(first-child) {' +
'margin-right: 0.5em;' +
'}';
document.head.appendChild(style);
// The form
var revdelForm = document.createElement('fieldset');
revdelForm.id = 'mrd-form';
revdelForm.style.cssText = 'font-size: 95%; margin: 0.5em 0;';
revdelForm.innerHTML = '<legend>一括版指定削除</legend>';
// Show/hide toggle
var formToggle = document.createElement('input');
formToggle.id = 'mrd-form-toggle';
formToggle.type = 'button';
formToggle.value = 'フォームを表示';
revdelForm.appendChild(formToggle);
// Form body
var formBody = document.createElement('div');
formBody.id = 'mrd-form-body';
formBody.style.display = 'none';
revdelForm.appendChild(formBody);
var reasonFetched = false;
var $formBody = $(formBody);
formToggle.addEventListener('click', function() { // Toggle show/hide of the form when the button is hit
this.value = $formBody.is(':visible') ? 'フォームを表示' : 'フォームを隠す';
$formBody.slideToggle();
if (reasonFetched) { // Adjust the width of custom reason input to that of the reason dropdowns when the form is expanded for the first time
reasonFetched = false;
reasonC.style.width = reason1.offsetWidth + 'px'; // reasonC: variable defined below
}
});
// Visibility level field
var levelField = document.createElement('fieldset');
levelField.id = 'mrd-levels';
levelField.style.margin = '0';
levelField.innerHTML = '<legend>閲覧レベル</legend>';
formBody.appendChild(levelField);
var levelsTable = document.createElement('table');
levelsTable.id = 'mrd-levels-revdel';
levelField.appendChild(levelsTable);
var levelsTableBody = document.createElement('tbody');
levelsTable.appendChild(levelsTableBody);
var visLevelCount = 0;
/**
* @class
* @constructor
* @param {HTMLTableSectionElement} tbody
* @param {string} labelText
* @param {{nochangeLabel?: string; showLabel?: string; hideLabel?: string;}} [options]
*/
var VisLevel = function(tbody, labelText, options) {
options = options || {};
var labelRow = document.createElement('tr');
tbody.appendChild(labelRow);
/** @type {HTMLTableRowElement} */
this.labelRow = labelRow;
var label = document.createElement('td');
label.style.fontWeight = 'bold';
label.colSpan = 3;
label.textContent = labelText;
labelRow.appendChild(label);
var radioRow = document.createElement('tr');
tbody.appendChild(radioRow);
/** @type {HTMLTableRowElement} */
this.radioRow = radioRow;
var nochange = document.createElement('td');
radioRow.appendChild(nochange);
var nochangeRadio = document.createElement('input');
nochangeRadio.type = 'radio';
nochangeRadio.style.marginRight = '0.5em';
var nochangeId = 'mp-levels-nochange-' + (++visLevelCount);
nochangeRadio.id = nochangeId;
var levelName = 'level' + visLevelCount;
nochangeRadio.name = levelName;
nochangeRadio.checked = true;
nochange.appendChild(nochangeRadio);
var nochangeLabel = document.createElement('label');
nochangeLabel.textContent = options.nochangeLabel || '変更しない';
nochangeLabel.htmlFor = nochangeId;
nochange.appendChild(nochangeLabel);
/** @type {HTMLInputElement} */
this.nochangeRadio = nochangeRadio;
var show = document.createElement('td');
radioRow.appendChild(show);
var showRadio = document.createElement('input');
showRadio.type = 'radio';
showRadio.style.marginLeft = '1em';
showRadio.style.marginRight = '0.5em';
var showId = 'mp-levels-show-' + visLevelCount;
showRadio.id = showId;
showRadio.name = levelName;
show.appendChild(showRadio);
var showLabel = document.createElement('label');
showLabel.textContent = options.showLabel || '閲覧可能';
showLabel.htmlFor = showId;
show.appendChild(showLabel);
/** @type {HTMLInputElement} */
this.showRadio = showRadio;
var hide = document.createElement('td');
radioRow.appendChild(hide);
var hideRadio = document.createElement('input');
hideRadio.type = 'radio';
hideRadio.style.marginLeft = '1em';
hideRadio.style.marginRight = '0.5em';
var hideId = 'mp-levels-hide-' + visLevelCount;
hideRadio.id = hideId;
hideRadio.name = levelName;
hide.appendChild(hideRadio);
var hideLabel = document.createElement('label');
hideLabel.textContent = options.hideLabel || '隠す';
hideLabel.htmlFor = hideId;
hide.appendChild(hideLabel);
/** @type {HTMLInputElement} */
this.hideRadio = hideRadio;
};
/**
* Get the value of the checked radio.
* @returns {VisStates}
* @method
* @typedef {"nochange"|"show"|"hide"} VisStates
*/
VisLevel.prototype.val = function() {
return this.nochangeRadio.checked ? 'nochange' : this.showRadio.checked ? 'show' : 'hide';
};
/**
* Hide the table rows created by the constructor.
* @returns {void}
* @method
*/
VisLevel.prototype.hide = function() {
this.labelRow.style.display = 'none';
this.radioRow.style.display = 'none';
};
var visLevelContent = new VisLevel(levelsTableBody, '版の本文');
var visLevelComment = new VisLevel(levelsTableBody, '編集の要約');
var visLevelUser = new VisLevel(levelsTableBody, '投稿者の利用者名/IPアドレス');
var visLevelOversight = new VisLevel(levelsTableBody, 'オーバーサイト', {showLabel: '適用しない', hideLabel: '適用する'});
if (!canOversight) visLevelOversight.hide();
// Reason field
var reasonField = document.createElement('fieldset');
reasonField.id = 'mrd-deletereason';
reasonField.style.margin = '0';
reasonField.innerHTML = '<legend>理由</legend>';
formBody.appendChild(reasonField);
var reason1 = createLabeledDropdown(reasonField, 'mrd-deletereason-1', '理由1');
var reason2 = createLabeledDropdown(reasonField, 'mrd-deletereason-2', '理由2', {appendBr: true});
var reasonC = createLabeledTextbox(reasonField, 'mrd-deletereason-C', '', {appendBr: true});
reasonC.placeholder = '非定型理由 (自由記述)';
// Button field
var buttonField = document.createElement('div');
buttonField.id = 'mrd-buttons';
buttonField.style.cssText = 'margin-top: 0.5em;';
formBody.appendChild(buttonField);
var buttonsPrimary = document.createElement('div');
buttonsPrimary.id = 'mrd-buttons-primary';
buttonField.appendChild(buttonsPrimary);
var buttonCheckAll = createButton(buttonsPrimary, 'mrd-checkall', '全選択');
buttonCheckAll.addEventListener('click', function() {
var cnt = 0;
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
obj.checkbox.checked = true;
cnt++;
});
checkedCounterNum.textContent = cnt.toString();
});
var buttonUncheckAll = createButton(buttonsPrimary, 'mrd-uncheckall', '全選択解除');
buttonUncheckAll.addEventListener('click', function() {
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
obj.checkbox.checked = false;
});
checkedCounterNum.textContent = '0';
});
var buttonInvert = createButton(buttonsPrimary, 'mrd-invert', '選択反転');
buttonInvert.addEventListener('click', function() {
var cnt = 0;
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
obj.checkbox.checked = !obj.checkbox.checked;
if (obj.checkbox.checked) cnt++;
});
checkedCounterNum.textContent = cnt.toString();
});
var buttonsSecondary = document.createElement('div');
buttonsSecondary.id = 'mrd-buttons-secondary';
buttonsSecondary.style.cssText = 'margin-top: 0.5em;';
buttonField.appendChild(buttonsSecondary);
if (!listHasHiddenField) buttonsSecondary.style.display = 'none';
var buttonCheckAllDeleted = createButton(buttonsSecondary, 'mrd-checkall-deleted', '削除済み版全選択');
buttonCheckAllDeleted.addEventListener('click', function() {
var cnt = 0;
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
if (obj.revdeled) obj.checkbox.checked = true;
if (obj.checkbox.checked) cnt++;
});
checkedCounterNum.textContent = cnt.toString();
});
var buttonUncheckAllDeleted = createButton(buttonsSecondary, 'mrd-uncheckall-deleted', '削除済み版全選択解除');
buttonUncheckAllDeleted.addEventListener('click', function() {
var cnt = 0;
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
if (obj.revdeled) obj.checkbox.checked = false;
if (obj.checkbox.checked) cnt++;
});
checkedCounterNum.textContent = cnt.toString();
});
var buttonCheckAllNotDeleted = createButton(buttonsSecondary, 'mrd-checkall-notdeleted', '未削除版全選択');
buttonCheckAllNotDeleted.addEventListener('click', function() {
var cnt = 0;
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
if (!obj.revdeled) obj.checkbox.checked = true;
if (obj.checkbox.checked) cnt++;
});
checkedCounterNum.textContent = cnt.toString();
});
var buttonUncheckAllNotDeleted = createButton(buttonsSecondary, 'mrd-uncheckall-notdeleted', '未削除版全選択解除');
buttonUncheckAllNotDeleted.addEventListener('click', function() {
var cnt = 0;
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
if (!obj.revdeled) obj.checkbox.checked = false;
if (obj.checkbox.checked) cnt++;
});
checkedCounterNum.textContent = cnt.toString();
});
formBody.appendChild(checkedCounter);
// Execute button
var buttonRevDel = createButton(formBody, 'mrd-revdel', '実行');
buttonRevDel.style.cssText = 'margin-top: 0.5em;';
var revdelProgress = document.createElement('span');
revdelProgress.id = 'mrd-revdel-progress';
revdelProgress.style.cssText = 'display: inline; margin-left: 0.5em;';
formBody.appendChild(revdelProgress);
var processMsgTimeout;
buttonRevDel.addEventListener('click', function() {
// Get target items to (un)delete
/** @type {{raw: string[]; show: string[]; hide: string[]; suppress: "no"|"nochange"|"yes";}} */
var targets = {
raw: ['content', 'comment', 'user'],
show: [],
hide: [],
// @ts-ignore
suppress: {show: 'no', hide: 'yes', nochange: 'nochange'}[visLevelOversight.val()]
};
[visLevelContent, visLevelComment, visLevelUser].forEach(function(visLevelObj, i) {
var val = visLevelObj.val();
if (targets[val]) { // 'show' and 'hide' only
targets[val].push(targets.raw[i]);
}
});
/**
* Object to store the revdel states of revisions before executing this procedure
* @type {Prev} revid-object pairs
*/
var prev = {};
/** How many revisions are selected */
var totalCount = 0;
/** Whether the visibility status of at least one revision is to be changed */
var someRevInfoIsToBeChanged = false;
// Get revids to (un)delete
/** @typedef {Object.<string, string[]>} Revids pagetitle-revids pairs */
/** @type {Revids} */
var revids = Object.keys(revisionList).reduce(/** @param {Revids} acc */ function(acc, revid) {
var obj = revisionList[revid];
if (obj.checkbox.checked) {
totalCount++;
if (acc[obj.title]) {
acc[obj.title].push(revid);
} else {
acc[obj.title] = [revid];
}
var prevObj = {
suppressed: obj.suppressed,
texthidden: !obj.date ? false : obj.date.classList.contains(classDeleted), // obj.date is basically never null
commenthidden: obj.comment.classList.contains(classDeleted),
userhidden: obj.userhidden.style.display !== 'none',
current: obj.current
};
prev[revid] = prevObj;
if (!someRevInfoIsToBeChanged) {
someRevInfoIsToBeChanged = targets[prevObj.texthidden ? 'show' : 'hide'].indexOf('content') !== -1 ||
targets[prevObj.commenthidden ? 'show' : 'hide'].indexOf('comment') !== -1 ||
targets[prevObj.userhidden ? 'show' : 'hide'].indexOf('user') !== -1 ||
targets.suppress === 'yes' && !prevObj.suppressed ||
targets.suppress === 'no' && prevObj.suppressed;
}
}
return acc;
}, Object.create(null));
if (!totalCount) { // No checked box is checked (= no revision selected)
return alert('版指定削除の対象版が指定されていません');
}
if (!someRevInfoIsToBeChanged) { // There'll be no change in revision item visibility
return alert('指定された新しい閲覧レベルが、選択された版の現在の閲覧レベルと全て同一です (処理を行っても閲覧レベルが変化しません)');
}
// Get reason
var reason = [reason1.value, reason2.value, reasonC.value.trim()].filter(function(el) { return el; }).join(': ');
if (!reason && !confirm('版指定削除理由が指定されていません。このまま実行しますか?')) {
return; // No reason is provided and the user has chosen to stop the execution
}
// Final confirm
/** @param {VisStates} revdelType */
var getRevdelType = function(revdelType) {
switch (revdelType) {
case 'nochange':
return ' (<b>変更なし</b>)';
case 'show':
return ' (<b style="color: mediumseagreen;">閲覧可</b>)';
case 'hide':
return ' (<b style="color: mediumvioletred;">閲覧不可</b>)';
}
};
/** @type {string[]} */
var confirmMsg = [];
confirmMsg.push('計' + totalCount + '版の閲覧レベルを変更します。');
confirmMsg.push('');
confirmMsg.push('・版の本文' + getRevdelType(visLevelContent.val()));
confirmMsg.push('・編集の要約' + getRevdelType(visLevelComment.val()));
confirmMsg.push('・投稿者の利用者名/IPアドレス' + getRevdelType(visLevelUser.val()));
confirmMsg.push('');
confirmMsg.push('よろしいですか?');
var self = this;
/** @type {JQuery<HTMLDivElement>} */
var $confirmDialog = $('<div><p>' + confirmMsg.join('<br>') + '</p></div>');
dConfirm($confirmDialog).then(function(confirmed) {
if (!confirmed) {
mw.notify('処理を中止しました。');
return;
}
// Final prep for revdel
self.disabled = true; // Make the execution button unclickable
Object.keys(revisionList).forEach(function(revid) {
var obj = revisionList[revid];
obj.checkbox.disabled = true; // Same for all the checkboxes
obj.label.style.display = 'none'; // And revdel links
obj.pseudolabel.style.display = 'inline'; // Temporarily show the pseudo-label
obj.progress.innerHTML = '';
if (obj.checkbox.checked) {
obj.progress.appendChild(getIcon('doing'));
obj.progress.style.display = 'inline';
}
});
clearTimeout(processMsgTimeout);
revdelProgress.innerHTML = '';
revdelProgress.appendChild(getIcon('doing'));
revdelProgress.appendChild(document.createTextNode(' 処理中'));
// Create parameters for action=revisiondelete
/** @type {ApiParamsRevisionDeleteFragment} */
var defaultParams = {
action: 'revisiondelete',
type: 'revision',
reason: reason,
show: targets.show.join('|'),
hide: targets.hide.join('|'),
suppress: targets.suppress,
tags: mw.config.get('wgDBname') === 'testwiki' ? 'testtag' : MRD + '|DevScript',
formatversion: '2'
};
// Send API requests (per page)
var deferreds = [];
var req = debuggingMode ? dev : revdel;
Object.keys(revids).forEach(function(pagetitle) {
var ids = revids[pagetitle].slice(); // Deep copy
while (ids.length) {
var params = $.extend({target: pagetitle, ids: ids.splice(0, apilimit).join('|')}, defaultParams);
deferreds.push(req(params, prev));
}
});
$.when.apply($, deferreds).then(function() { // When all done
// Merge the results to one object
/** @type {ApiResultRevisionDelete} */
var res = {};
var args = arguments;
for (var i = 0; i < args.length; i++) {
$.extend(res, args[i]);
}
console.log(MRD, res);
// Reflect the results to the DOM
var summaryShown = false;
var someRevisionDeleted = false;
var successCount = 0;
Object.keys(revisionList).forEach(function(revid) {
var revisionListObj = revisionList[revid];
revisionListObj.checkbox.disabled = false; // Get checkbox back to a clickable state
revisionListObj.label.style.display = 'inline'; // Show label
revisionListObj.pseudolabel.style.display = 'none'; // Hide pseudo-label
var resObj = res[revid];
if (resObj) { // Just in case ensure that the response object has this revid as a key
revisionListObj.progress.innerHTML = '';
if (resObj.errors) { // On failure
revisionListObj.progress.appendChild(getIcon('failed'));
var errMsg = document.createElement('span');
errMsg.style.color = 'mediumvioletred';
errMsg.textContent = ' (' + resObj.errors + ')';
revisionListObj.progress.appendChild(errMsg);
} else { // On success
successCount++;
if (!summaryShown && resObj.commenthidden === false) summaryShown = true;
revisionListObj.revdeled = resObj.texthidden || resObj.commenthidden || resObj.userhidden || resObj.suppressed;
revisionListObj.suppressed = resObj.suppressed;
revisionListObj.progress.appendChild(getIcon('done'));
// Update the DOM to display the show/hide results without reloading the page
revisionListObj.date = toggleContentVisibility(revisionListObj, resObj);
revisionListObj.comment = toggleCommentVisibility(revisionListObj, resObj);
toggleUsernameVisibility(revisionListObj, resObj);
}
}
if (!someRevisionDeleted && revisionListObj.revdeled) {
someRevisionDeleted = true;
}
});
// Trigger hook if edit summaries are replaced (event listners are gone; things like NavPopups won't work without this)
if (summaryShown) {
var content = document.querySelector('.mw-body-primary') ||
document.querySelector('.mw-body') ||
document.querySelector('#mw-content-text') ||
document.body;
mw.hook('wikipage.content').fire($(content));
}
// Show 'done' on the main form
self.disabled = false;
buttonsSecondary.style.display = someRevisionDeleted ? 'block' : 'none';
revdelProgress.innerHTML = '';
revdelProgress.appendChild(getIcon('done'));
revdelProgress.appendChild(document.createTextNode(' 処理が完了しました (' + successCount + '/' + totalCount + ')'));
processMsgTimeout = setTimeout(function() {
revdelProgress.innerHTML = '';
}, 10000);
});
});
});
// Append the form to the DOM
$contribsList.eq(0).before(revdelForm);
// Get revdel reason dropdown
getDeleteReasonDropdown().then(function(dropdown) {
if (!dropdown) return alert(MRD + '\n削除理由の取得に失敗しました。');
reasonFetched = true;
[reason1, reason2].forEach(function(el) {
el.innerHTML = dropdown.innerHTML;
});
});
}
/**
* Get edit summaries in an HTML format from revision IDs.
* Note: This function does not fetch parsed comments of the revisions of deleted pages (which is done by 'prop=deletedrevisions').
* Such a functionality is just unneeded because on [[Special:DeletedContributions]], parsed comments are struck through but visible.
* @param {string[]} revids
* @returns {JQueryPromise<Object.<string, string>>}
*/
function getParsedComment(revids) {
if (!revids.length) return $.Deferred().resolve({});
/**
* @param {string[]} revidsArr
* @returns {JQueryPromise<Object.<string, string>>}
*/
var query = function(revidsArr) {
return api.post({ // POST request just in case to prevent 404 (URL too long)
action: 'query',
revids: revidsArr.join('|'),
prop: 'revisions',
rvprop: 'ids|parsedcomment',
formatversion: '2'
}).then(function(res) {
var resPages;
if (res && res.query && (resPages = res.query.pages) && resPages.length) {
return resPages.reduce(/** @param {Object.<string, string>} acc */ function(acc, obj) {
(obj.revisions || []).forEach(function(revObj) {
var parsedcomment = revObj.parsedcomment;
if (typeof parsedcomment === 'string') {
var revid = revObj.revid.toString();
acc[revid] = parsedcomment;
}
});
return acc;
}, Object.create(null));
} else {
return {};
}
}).catch(function(code, err) {
console.log(MRD, err);
return {};
});
};
var deferreds = [];
revids = revids.slice();
while (revids.length) {
deferreds.push(query(revids.splice(0, apilimit)));
}
return $.when.apply($, deferreds).then(function() {
var args = arguments;
var ret = {};
for (var i = 0; i < args.length; i++) {
$.extend(ret, args[i]);
}
return ret;
});
}
/**
* Create a dropdown with a label on its left. Default CSS for the dropdown: 'display: inline-block; width: 6ch;'
* @param {HTMLElement} appendTo
* @param {string} id
* @param {string} labelText
* @param {{labelCss?: string; dropdownCss?: string; appendBr?: boolean;}} [options]
* @returns {HTMLSelectElement}
*/
function createLabeledDropdown(appendTo, id, labelText, options) {
options = options || {};
if (options.appendBr) appendTo.appendChild(document.createElement('br'));
var label = document.createElement('label');
label.htmlFor = id;
label.textContent = labelText;
label.style.cssText = 'display: inline-block; width: 6ch;';
if (options.labelCss) parseAndApplyCssText(label, options.labelCss);
appendTo.appendChild(label);
var dropdown = document.createElement('select');
dropdown.id = id;
if (options.dropdownCss) dropdown.style.cssText = options.dropdownCss;
appendTo.appendChild(dropdown);
return dropdown;
}
/**
* Create a textbox with a label on its left. Default CSS for the textbox: 'display: inline-block; width: 6ch;'
* @param {HTMLElement} appendTo
* @param {string} id
* @param {string} labelText
* @param {{labelCss?: string; textboxCss?: string; appendBr?: boolean;}} [options]
* @returns {HTMLInputElement}
*/
function createLabeledTextbox(appendTo, id, labelText, options) {
options = options || {};
if (options.appendBr) appendTo.appendChild(document.createElement('br'));
var label = document.createElement('label');
label.htmlFor = id;
label.textContent = labelText;
label.style.cssText = 'display: inline-block; width: 6ch;';
if (options.labelCss) parseAndApplyCssText(label, options.labelCss);
appendTo.appendChild(label);
var textbox = document.createElement('input');
textbox.type = 'text';
textbox.id = id;
if (options.textboxCss) textbox.style.cssText = options.textboxCss;
appendTo.appendChild(textbox);
return textbox;
}
/**
* @param {HTMLElement} appendTo
* @param {string} id
* @param {string} label
* @param {{buttonCss?: string;}} [options]
* @returns {HTMLInputElement}
*/
function createButton(appendTo, id, label, options) {
options = options || {};
var button = document.createElement('input');
button.type = 'button';
if (id) button.id = id;
button.value = label;
if (options.buttonCss) button.style.cssText = options.buttonCss;
appendTo.appendChild(button);
return button;
}
/**
* Parse cssText ('property: value;') recursively and apply the styles to a given element.
* @param {HTMLElement} element
* @param {string} cssText
*/
function parseAndApplyCssText(element, cssText) {
var regex = /(\S+?)\s*:\s*(\S+?)\s*;/g;
var m;
while ((m = regex.exec(cssText))) {
element.style[m[1]] = m[2];
}
}
/**
* An alternative to window.confirm, by a dialog.
* @param {JQuery<HTMLDivElement>} $dialog
* @returns {JQueryPromise<boolean>}
*/
function dConfirm($dialog) {
var def = $.Deferred();
var bool = false;
$dialog.prop('title', MRD + ' - Confirm');
$dialog.dialog({
resizable: false,
height: 'auto',
width: 'auto',
minWidth: 500,
modal: true,
position: {
my: 'center',
at: 'center',
of: window
},
buttons: [
{
text: 'はい',
click: function() {
bool = true;
$(this).dialog('close');
}
},
{
text: 'いいえ',
click: function() {
$(this).dialog('close');
}
}
],
close: function() {
def.resolve(bool);
$dialog.dialog('destroy').remove();
}
});
return def.promise();
}
/**
* Get a loading/check/cross image tag.
* @param {"doing"|"done"|"failed"} iconType
* @returns {HTMLImageElement}
*/
function getIcon(iconType) {
var img = document.createElement('img');
switch (iconType) {
case 'doing':
img.src = '//upload.wikimedia.org/wikipedia/commons/4/42/Loading.gif';
break;
case 'done':
img.src = '//upload.wikimedia.org/wikipedia/commons/f/fb/Yes_check.svg';
break;
case 'failed':
img.src = '//upload.wikimedia.org/wikipedia/commons/a/a2/X_mark.svg';
}
img.style.cssText = 'vertical-align: middle; height: 1em; border: 0;';
return img;
}
/**
* @typedef ApiParamsRevisionDeleteFragment
* @type {object}
* @property {"revisiondelete"} action
* @property {"archive"|"filearchive"|"logging"|"oldimage"|"revision"} type
* @property {string} hide Values (separate with "|"): comment, content, user
* @property {string} show Values (separate with "|"): comment, content, user
* @property {"no"|"nochange"|"yes"} suppress Default: nochange
* @property {string} reason
* @property {string} tags
* @property {"2"} formatversion
*/
/**
* @typedef ApiParamsRevisionDeleteRest
* @type {object}
* @property {string} target Pagetitle
* @property {string} ids Pipe-separated revision IDs
*/
/**
* @typedef ApiParamsRevisionDelete
* @type {ApiParamsRevisionDeleteFragment & ApiParamsRevisionDeleteRest}
*/
/**
* @typedef ApiResultRevisionDeleteItem
* @type {object}
* @property {boolean} suppressed
* @property {boolean} texthidden
* @property {boolean} commenthidden
* @property {boolean} userhidden
* @property {string} errors
*/
/**
* @typedef ApiResultRevisionDelete
* @type {Object.<string, ApiResultRevisionDeleteItem>} revid-object pairs
*/
/**
* @typedef Prev
* @type {Object.<string, {suppressed: boolean; texthidden: boolean; commenthidden: boolean; userhidden: boolean; current: boolean;}>} revid-object pairs
*/
/**
* Execute revision delete.
* @param {ApiParamsRevisionDelete} params
* @param {Prev} prev
* @returns {JQueryPromise<ApiResultRevisionDelete>}
*/
function revdel(params, prev) {
var revids = params.ids.split('|');
return api.postWithToken('csrf', params)
.then(function(res) {
var resItems;
if (res && res.revisiondelete && (resItems = res.revisiondelete.items) && resItems.length) {
return resItems.reduce(/** @param {ApiResultRevisionDelete} acc */ function(acc, obj) {
/** @type {string[]} */
var err = [];
var revid = obj.id.toString();
if (obj.errors) { // Get error codes if there's any
err = obj.errors.reduce(function(errAcc, errObj) {
if (errObj.type === 'error' && errAcc.indexOf(errObj.code) === -1) {
errAcc.push(errObj.code);
}
return errAcc;
}, err);
}
acc[revid] = {
suppressed: params.suppress === 'yes' || params.suppress === 'nochange' && prev[revid].suppressed,
texthidden: obj.texthidden,
commenthidden: obj.commenthidden,
userhidden: obj.userhidden,
errors: err.join(', ')
};
return acc;
}, Object.create(null));
} else {
return _createRevdelErrorObject(revids, 'unknown', prev);
}
}).catch(function(code, err) {
console.error(MRD, err);
return _createRevdelErrorObject(revids, code, prev);
});
}
/**
* Create a return object for revdel() by assigning the same error code to all the passed revision IDs.
* @param {string[]} revids
* @param {string} code
* @param {Prev} prev
* @returns {ApiResultRevisionDelete}
* @private
*/
function _createRevdelErrorObject(revids, code, prev) {
return revids.reduce(/** @param {ApiResultRevisionDelete} acc */ function(acc, revid) {
acc[revid] = {
suppressed: prev[revid].suppressed,
texthidden: prev[revid].texthidden,
commenthidden: prev[revid].commenthidden,
userhidden: prev[revid].userhidden,
errors: code
};
return acc;
}, Object.create(null));
}
/**
* Debugging alternant of revdel(). Return the same kind of object without executing revision (un)deletion.
* @param {ApiParamsRevisionDelete} params
* @param {Prev} prev
* @returns {JQueryPromise<ApiResultRevisionDelete>}
*/
function dev(params, prev) {
var def = $.Deferred();
var targets = {
show: params.show.split('|'),
hide: params.hide.split('|')
};
/** @type {ApiResultRevisionDelete} */
var ret = params.ids.split('|').reduce(/** @param {ApiResultRevisionDelete} acc */ function(acc, revid) {
if (targets.hide.indexOf('content') !== -1 && prev[revid].current) {
$.extend(acc, _createRevdelErrorObject([revid], 'revdelete-hide-current', prev));
} else {
acc[revid] = {
suppressed: params.suppress === 'yes' || params.suppress === 'nochange' && prev[revid].suppressed,
texthidden: targets.hide.indexOf('content') !== -1 ? true : targets.show.indexOf('content') !== -1 ? false : prev[revid].texthidden,
commenthidden: targets.hide.indexOf('comment') !== -1 ? true : targets.show.indexOf('comment') !== -1 ? false : prev[revid].commenthidden,
userhidden: targets.hide.indexOf('user') !== -1 ? true : targets.show.indexOf('user') !== -1 ? false : prev[revid].userhidden,
errors : ''
};
}
return acc;
}, Object.create(null));
setTimeout(function() {
def.resolve(ret);
}, 1000);
return def.promise();
}
/**
* @typedef RevisionListItem
* @type {object}
* @property {HTMLSpanElement} progress Wrapper to show revdel progress by an icon
* @property {HTMLInputElement} checkbox
* @property {HTMLSpanElement} label The 'change visibility' link that works as the label of the checkbox
* @property {HTMLSpanElement} pseudolabel An alternative 'change visibility' label that cannot be clicked
* @property {HTMLSpanElement|HTMLAnchorElement|null} date PermaLink to revision with a date text
* @property {string} title
* @property {HTMLSpanElement} comment Edit summary wrapper
* @property {string} parsedcomment Edit summary in an HTML format
* @property {HTMLElement} userhidden A (hidden) \<strong> tag storing a warning message for revdel'd username
* @property {boolean} revdeled Whether some item of the revision is deleted
* @property {boolean} suppressed Whether some item of the revision is oversighted
* @property {boolean} current Whether this is the current revision
*/
/**
* @typedef RevisionList
* @type {Object.<string, RevisionListItem>} revid-object pairs
*/
/**
* Toggle the revdel status of a date link.
* ```
* // Normal date link
* <a class="mw-changeslist-date">2023-01-01T00:00:00</a>
* // Deleted date link
* <span class="history-deleted">
* <a class="mw-changeslist-date">2023-01-01T00:00:00</a>
* </span>
* // Suppressed date link (non-suppressor view; this function should never face this pattern)
* <span class="history-deleted mw-history-suppressed mw-changeslist-date"></span>
* ```
* Concerning the third pattern, a revision with any field suppressed can only be revision-deleted by suppressors.
* This means that undeletable revisions should never be forwarded to the API in the first place.
* @param {RevisionListItem} reivisionListItem
* @param {ApiResultRevisionDeleteItem} revdelResult
* @return {HTMLSpanElement|HTMLAnchorElement|null} The input tag must be updated with this returned tag.
*/
function toggleContentVisibility(reivisionListItem, revdelResult) {
var dateSpanchor = reivisionListItem.date;
if (dateSpanchor == null) return null;
var alreadyDeleted = dateSpanchor.tagName === 'SPAN';
if (revdelResult.texthidden) { // Deleted
if (alreadyDeleted) {
// #2 => #2
// The date link is already a span tag. This means that the content of this revision had already been hidden
// before the current revdel execution. We just need to modify the class list of the tag.
console.log(MRD, 'content1');
if (revdelResult.suppressed) {
dateSpanchor.classList.add(classSuppressed);
} else {
dateSpanchor.classList.remove(classSuppressed);
}
return dateSpanchor;
} else {
// #1 => #2
// The date link is a bare anchor tag (newly revision-deleted): wrap this tag with span.
console.log(MRD, 'content2');
var wrapper = document.createElement('span');
wrapper.classList.add(classDeleted);
if (revdelResult.suppressed) wrapper.classList.add(classSuppressed);
// @ts-ignore
dateSpanchor.parentElement.insertBefore(wrapper, dateSpanchor); // Insert the new span tag before the anchor
wrapper.appendChild(dateSpanchor); // Move the anchor into the span tag
// Final note: outerHTML strategies should be avoided because that would destroy event listners of the date link, if there's any.
return wrapper;
}
} else { // Undeleted
if (alreadyDeleted) {
// #2 => #1
// Deleted content has been undeleted: remove span wrapper.
console.log(MRD, 'content3');
/** @type {HTMLAnchorElement} */
// @ts-ignore This should never be null, as documented above
var a = dateSpanchor.querySelector('a.mw-changeslist-date');
// @ts-ignore
dateSpanchor.parentElement.insertBefore(a, dateSpanchor); // Move the anchor out of the span and make it the preceding sibling
dateSpanchor.remove(); // Remove wrapper span
return a;
} else {
// #1 => #1
// Content not hidden before and after the execution (no need to do anything).
console.log(MRD, 'content4');
return dateSpanchor;
}
}
}
/**
* Toggle the revdel status of a comment (edit summary).
* ```
* // On [[Special:Contributions]]
* // Normal comment
* <span class="comment comment--without-parentheses">Some comment</span>
* // Deleted comment
* <span class="history-deleted comment">
* <span class="comment">(edit summary removed)</span>
* </span>
* // On [[Special:DeletedContributions]]
* // Normal comment (same as #1)
* <span class="comment comment--without-parentheses">Some comment</span>
* // Deleted comment
* <span class="history-deleted comment">
* <span class="comment comment--without-parentheses">Some comment</span>
* </span>
* ```
* Note that when the comment is an empty string, there IS a span tag for comment with the 'mw-comment-none' class added
* to pattern #1 on [[Special:Contributions]], but the tag is entirely missing on [[Special:DeletedContributions]]. This
* script internally creates a comment element in createForm() if missing, so there's no problem.
* @param {RevisionListItem} revisionListItem
* @param {ApiResultRevisionDeleteItem} revdelResult
* @returns {HTMLSpanElement}
*/
function toggleCommentVisibility(revisionListItem, revdelResult) {
var commentSpan = revisionListItem.comment;
var parsedComment = revisionListItem.parsedcomment;
var alreadyDeleted = commentSpan.classList.contains(classDeleted) || commentSpan.classList.contains(classSuppressed);
var classComment = 'comment';
var classNoComment = 'mw-comment-none';
var classNoParentheses = 'comment--without-parentheses';
/** @type {HTMLSpanElement|null} */
var innerComment = commentSpan.querySelector('.' + classComment);
if (revdelResult.commenthidden) { // Deleted
if (alreadyDeleted) {
// #2/#4 => #2/#4 (class modification only)
console.log(MRD, 'comment1');
if (revdelResult.suppressed) {
commentSpan.classList.add(classSuppressed);
} else {
commentSpan.classList.remove(classSuppressed);
}
if (innerComment) {
if (isDeletedContribs) {
innerComment.classList.add(classNoParentheses);
} else {
innerComment.classList.remove(classNoParentheses);
}
}
return commentSpan;
} else {
// #1/#3 => #2/#4 (wrap with another span)
console.log(MRD, 'comment2');
if (isDeletedContribs) {
commentSpan.classList.add(classNoParentheses);
} else {
commentSpan.classList.remove(classNoParentheses);
commentSpan.innerHTML = msg['rev-deleted-comment'];
}
commentSpan.classList.remove(classNoComment); // When deleted, the inner span of #2/#4 should never have this class
var wrapper = document.createElement('span');
wrapper.classList.add(classComment);
wrapper.classList.add(classDeleted);
if (revdelResult.suppressed) wrapper.classList.add(classSuppressed);
// @ts-ignore
commentSpan.parentElement.insertBefore(wrapper, commentSpan); // Insert wrapper right before the comment span
wrapper.appendChild(commentSpan); // Move the comment span inside wrapper
return wrapper;
}
} else { // Undeleted
if (alreadyDeleted) {
// #2/#4 => #1/#3 (remove wrapper span)
console.log(MRD, 'comment3');
var inner;
if (innerComment) {
innerComment.innerHTML = parsedComment;
inner = innerComment;
} else {
inner = document.createElement('span');
inner.classList.add(classComment);
inner.innerHTML = parsedComment;
}
if (inner.innerHTML === '' || inner.innerHTML === msg['changeslist-nocomment']) { // in case the comment is an empty string
inner.classList.add(classNoComment);
} else {
inner.classList.add(classNoParentheses);
}
// @ts-ignore
commentSpan.parentElement.insertBefore(inner, commentSpan); // Move the inner span out of and immediately before wrapper
commentSpan.remove(); // Remove wrapper
return inner;
} else {
// #1/#3 => #1/#3 (do nothing)
console.log(MRD, 'comment4');
return commentSpan;
}
}
}
/**
* Toggle the revdel status of a username.
* @param {RevisionListItem} revisionListItem
* @param {ApiResultRevisionDeleteItem} revdelResult
*/
function toggleUsernameVisibility(revisionListItem, revdelResult) {
revisionListItem.userhidden.style.display = revdelResult.userhidden ? 'inline' : 'none';
}
/**
* Get a dropdown for revision-delete reasons. (Note: this function presumes that the interface is an HTMLUList equivalent
* indented only by asterisks.
* @returns {JQueryPromise<HTMLSelectElement|null>}
*/
function getDeleteReasonDropdown() {
var interfaceName = 'revdelete-reason-dropdown';
return getMessages([interfaceName]).then(function(res) {
var reasons = res[interfaceName];
if (typeof reasons !== 'string') return null;
var wrapper = document.createElement('select');
wrapper.innerHTML =
'<optgroup label="その他の理由">' +
'<option value="">その他の理由</option>' +
'</optgroup>';
var regex = /(\*+)([^*]+)/g;
var m, optgroup;
while ((m = regex.exec(reasons))) {
if (m[1].length === 1) {
optgroup = document.createElement('optgroup');
optgroup.label = m[2].trim();
wrapper.appendChild(optgroup);
} else {
var opt = document.createElement('option');
opt.textContent = m[2].trim();
if (optgroup) {
optgroup.appendChild(opt);
} else {
wrapper.appendChild(opt);
}
}
}
return wrapper;
});
}
// ****************************************************************************
})();
//</nowiki>