/********************************************************************************************************\
* MassDelete
* Delete/undelete given pages at one fell swoop.
* @author [[User:Dragoniez]]
\********************************************************************************************************/
//<nowiki>
(function() { // Container IIFE
// *************************************************** INITIALIZATION ***************************************************
/**
* When true, the result of action=delete is fabricated when the execution button is hit, meaning that
* the user can test all the functionalities of this script but actual deletion isn't performed.
* @readonly
*/
var debuggingMode = false;
/** @readonly */
var MD = 'MassDelete';
// Check user rights
var userRights = {
delete: ['eliminator', 'sysop', 'interface-admin', 'global-deleter', 'global-sysop', 'new-wikis-importer', 'staff', 'steward', 'sysadmin'],
undelete: ['eliminator', 'sysop', 'global-deleter', 'global-sysop', 'new-wikis-importer', 'staff', 'steward', 'sysadmin', 'wmf-researcher'],
apihighlimits: ['sysop', 'apihighlimits-requestor', 'global-sysop', 'staff', 'steward', 'sysadmin', 'wmf-researcher']
};
var canDelete = false;
var canUndelete = false;
var hasApiHighLimits = false;
// @ts-ignore
mw.config.get('wgUserGroups').concat(mw.config.get('wgGlobalGroups')).some(function(group) {
if (!canDelete) {
canDelete = userRights.delete.indexOf(group) !== -1;
}
if (!canUndelete) {
canUndelete = userRights.undelete.indexOf(group) !== -1;
}
if (!hasApiHighLimits) { // Bots excluded
hasApiHighLimits = userRights.apihighlimits.indexOf(group) !== -1;
}
return canDelete && canUndelete && hasApiHighLimits; // Get out of the loop when all entries have become true
});
/** @readonly */
var apilimit = hasApiHighLimits ? 500 : 50;
/** @readonly */
var optionName = 'userjs-md-targetpages';
/** @type {mw.Api} @readonly */
var api;
// Entry point
$.when(
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.user', 'mediawiki.Title', 'jquery.ui']),
$.ready
).then(function() { // When dependent modules and the DOM are loaded
api = new mw.Api();
// Create an interface on Special:MassDelete, or else create a portlet link to the special page
if (mw.config.get('wgNamespaceNumber') === -1 && /^(massdelete|md|一括削除)$/i.test(mw.config.get('wgTitle'))) {
document.title = '一括削除 - Wikipedia';
if (canDelete) {
createInterface();
} else {
showUserRightError();
}
} else {
mw.util.addPortletLink(
'p-tb',
mw.util.getUrl('Special:MassDelete'),
'一括削除' ,
't-mb',
'ページを一括で削除する'
);
}
});
// *************************************************** MAIN FUNCTIONS ***************************************************
/** Create the MassDelete interface. */
function createInterface() {
// style tag
var style = document.createElement('style');
style.textContent =
// General
'#md-container legend {' +
'font-weight: bold;' +
'}' +
'#md-container fieldset {' +
'border-color: #a2a9b1;' +
'}' +
'#md-container input[type="text"],' +
'#md-container textarea,' +
'#md-container select {' +
'box-sizing: border-box;' +
'}' +
// Tab switcher
'#md-targets-tab {' +
'display: flex;' +
'flex-wrap: wrap;' +
'}' +
'#md-targets-tab > input[type="radio"] {' +
'display: none;' +
'}' +
'#md-targets-tab > label {' +
'padding: 0.3em 0.5em;' +
'cursor: pointer;' +
'width: 12ch;' +
'background-color: lightgray;' +
'border: 1px solid #a2a9b1;' +
'text-align: center;' +
'}' +
'#md-targets-tab > label:not(:first-child) {' +
'border-left: none;' +
'}' +
'#md-targets-tab > div {' +
'order: 1;' +
'width: 100%;' +
'display: none;' +
'margin-top: 0.5em;' +
'}' +
'#md-targets-tab > input[type="radio"]:checked+label {' +
'background-color: deepskyblue;' +
'}' +
'#md-targets-tab > input[type="radio"]:disabled+label {' +
'text-decoration: line-through;' +
'color: rgba(0,0,0,0.4);' +
'}' +
'#md-targets-tab > input[type="radio"]:checked+label+div {' +
'display: initial;' +
'}' +
// Pagetitle fetcher's mode switcher
'.md-targets-fetcher-settings {' +
'display: none;' +
'}' +
'#md-targets-fetcher-mode[data-chosen="テンプレート"]+div {' +
'display: block;' +
'}' +
'#md-targets-fetcher-mode[data-chosen="カテゴリ"]+div+div {' +
'display: block;' +
'}' +
'#md-targets-fetcher-mode[data-chosen="投稿記録"]+div+div+div {' +
'display: block;' +
'}' +
'#md-targets-fetcher-mode[data-chosen="リンク選択"]+div+div+div+div {' +
'display: block;' +
'}' +
// Link selector dialog
'.md-targets-fetcher-linkselector-dialog-invalidlink {' +
'color: #64af70 !important;' +
'}' +
'.md-targets-fetcher-linkselector-dialog-selectedlink {' +
'background-color: orange;' +
'}' +
// jQuery UI toolip (line breaks by '\n')
'.md-namespace-tooltip {' +
'white-space: pre-line;' +
'}' +
// Show/hide toggle for watchlist expiry dropdown
'#md-settings-watch-expiry-container {' +
'display: none;' +
'}' +
'#md-settings-watch:checked + label + #md-settings-watch-expiry-container {' +
'display: block;' +
'}';
document.head.appendChild(style);
// The form
var container = document.createElement('div');
container.id = 'md-container';
container.innerHTML =
'<div id="md-policy">' +
'<ul>' +
'<li>' +
'<a href="/wiki/Wikipedia:削除の方針" target="_blank">削除の方針</a>、' +
'<a href="/wiki/Wikipedia:即時削除の方針" target="_blank">即時削除の方針</a>、' +
'<a href="/wiki/Wikipedia:リダイレクト削除の方針" target="_blank">リダイレクト削除の方針</a>' +
'に基づいているか<b>確認してください</b>。' +
'</li>' +
'<li>各ページの削除記録を確認してください。</li>' +
'<li>リンク元を確認してください。削除対象ページへの既存のリンクは自動的には更新されません。</li>' +
'<li>' +
'削除理由を明示してください。ドロップダウン・メニューから選択、直接記入、あるいはその両方を行うことができます。' +
'<ul>' +
'<li>' +
'不特定多数が閲覧可能な<a href="/wiki/Special:Log/delete" target="_blank">削除記録</a>に理由が残ります。' +
'不適切な内容を残さないようにしてください。' +
'</li>' +
'<li>削除依頼などの審議結果に基づいて削除する場合は、審議ページへのリンクを理由に含めてください。</li>' +
'</ul>' +
'</li>' +
'</ul>' +
'</div>';
replaceContent(container, '一括削除');
/**
* Enable/disable select, input, and textarea elements in the md-container.
* @param {boolean} disable
*/
var toggleDisableAttributes = function(disable) {
Array.prototype.forEach.call(container.querySelectorAll('select, input, textarea'),
/** @param {HTMLSelectElement|HTMLInputElement|HTMLTextAreaElement} el */
function(el) {
// Originally disabled elements shouldn't be enabled
if (disable) {
if (el.disabled) {
el.dataset.disabled = '1'; // Set a temporary data attribute
} else {
el.disabled = true;
}
} else {
if (el.dataset.disabled === '1') {
el.dataset.disabled = '0';
} else {
el.disabled = false;
}
}
}
);
};
// Field for deletion targets
var tgtWrapper = document.createElement('fieldset');
tgtWrapper.id = 'md-targets';
tgtWrapper.innerHTML = '<legend>対象ページ</legend>';
container.appendChild(tgtWrapper);
// Field for deletion targets - Tab container
var tgtTab = document.createElement('div');
tgtTab.id = 'md-targets-tab';
tgtWrapper.appendChild(tgtTab);
var tabCnt = 0;
/**
* Create elements for a new tab and append them to the tab container.
* ```
* <input id="md-targets-tabN" type="radio" name="tab">
* <label for="md-targets-tabN">labelText</label>
* <div></div>
* ```
* @param {string} labelText
* @param {string} contentId
* @returns {{radio: HTMLInputElement; label: HTMLLabelElement; content: HTMLDivElement;}}
*/
var createTab = function(labelText, contentId) {
var id = 'md-targets-tab' + (++tabCnt);
var radio = document.createElement('input');
radio.type = 'radio';
if (tabCnt === 1) radio.checked = true;
radio.name = 'tab';
radio.id = id;
tgtTab.appendChild(radio);
var label = document.createElement('label');
label.htmlFor = id;
label.textContent = labelText;
tgtTab.appendChild(label);
var content = document.createElement('div');
content.id = contentId;
tgtTab.appendChild(content);
return {radio: radio, label: label, content: content};
};
// Field for deletion targets - Textbox tab
var tgtTab1 = createTab('入力フィールド', 'md-targets-input');
var tgtInput = document.createElement('textarea');
tgtInput.style.cssText = 'width: 100%; font-family: inherit; padding: 0.3em;';
tgtInput.rows = 20;
tgtInput.placeholder = 'ページ名ごとに改行';
tgtTab1.content.appendChild(tgtInput);
/**
* @typedef TitleConfig
* @type {object} Only one config can be specified on one function call.
* @property {string[]} [add] Add these titles.
* @property {string[]} [remove] Remove these titles.
* @property {string[]} [replace] Replace with these titles.
*/
/**
* Clean up pagetitles in the textbox and return an array of pagetitles.
* @param {TitleConfig} [titleConfig]
* @returns {string[]} An array of pagetitles. First letter is in uppercase and spaces are represented by underscores.
*/
var cleanupPagetitles = function(titleConfig) {
titleConfig = titleConfig || {};
var sourceArr = (titleConfig.replace || tgtInput.value.replace(/\u200e/g, '').split('\n')).concat(titleConfig.add || []);
var pagetitles = sourceArr.reduce(/** @param {string[]} acc */ function(acc, title) {
try { // mw.Title() can throw an error if an empty string is passed
title = new mw.Title(title).getPrefixedText().replace(/ /g, '_');
if (title && acc.indexOf(title) === -1) { // No duplicates
if (!(titleConfig && titleConfig.remove && titleConfig.remove.indexOf(title) !== -1)) { // Ignore titles in titleConfig.remove
acc.push(title);
}
}
}
catch (err) {}
return acc;
}, []);
tgtInput.value = pagetitles.join('\n');
tgtInputLength.textContent = pagetitles.length.toString(); // tgtInputLength - variable declared below
return pagetitles;
};
var tgtInputCleanup = createButton(tgtTab1.content, 'md-targets-input-cleanup', '整形', {buttonCss: 'margin: 0.5em 1em 0 0;'});
tgtInputCleanup.addEventListener('click', function(e) {
cleanupPagetitles();
});
tgtTab1.content.appendChild(document.createTextNode('ページ数: '));
var tgtInputLength = document.createElement('span');
tgtInputLength.id = 'md-targets-input-length';
tgtInputLength.textContent = '0';
tgtTab1.content.appendChild(tgtInputLength);
// Field for deletion targets - List tab
var tgtTab2 = createTab('リスト表示', 'md-targets-list');
tgtTab2.content.style.height = tgtTab1.content.offsetHeight + 'px'; // Set the same height as tab1's content div
tgtTab2.content.style.overflowY = 'scroll';
tgtTab2.content.style.border = '1px solid #a2a9b1';
var tgtListOl = document.createElement('ol');
tgtListOl.style.margin = '0.3em 0 0 2em';
tgtTab2.content.appendChild(tgtListOl);
/**
* Create list items in the \<ol> element of the list tab out of pagetitles in the textbox field. When the list is created,
* an API request is sent to check whether the listed pages exist and to mark those that do not in red.
* @param {boolean} hideRemoveButtons
* @param {TitleConfig} [titleConfig]
* @returns {JQueryPromise<{titles: string[]; existingTitles: string[]; missingTitles: string[]; progress: Progress;}|undefined>}
* Returns undefined if no pagetitle can be fetched from the textbox field.
*/
var createList = function(hideRemoveButtons, titleConfig) {
var pagetitles = cleanupPagetitles(titleConfig);
if (!pagetitles.length) return $.Deferred().resolve(undefined);
/**
* "Pagetitle -> An array of associated anchors" pairs. The keys include titles for sub-anchors (main/talk page correspondents to the main anchors).
* @type {Object.<string, HTMLAnchorElement[]>}
*/
var anchors = {};
/** @type {Progress} */
var progressObj = {};
tgtListOl.innerHTML = '';
pagetitles.forEach(function(title) {
var li = document.createElement('li');
li.style.marginBottom = '0.2em';
var titleLink = document.createElement('a');
titleLink.href = mw.config.get('wgScript') + '?title=' + title + '&redirect=no';
titleLink.classList.add('md-targets-list-itemtitle');
titleLink.target = '_blank';
titleLink.textContent = title;
titleLink.dataset.title = title;
li.appendChild(titleLink);
li.appendChild(document.createTextNode(' ('));
if (anchors[title]) {
anchors[title].push(titleLink);
} else {
anchors[title] = [titleLink];
}
var Title = new mw.Title(title);
var relPageLink = document.createElement('a');
var relPageTitle;
if (Title.isTalkPage()) {
// @ts-ignore
relPageTitle = Title.getSubjectPage().getPrefixedText();
relPageLink.textContent = 'メイン';
} else {
// @ts-ignore
relPageTitle = Title.getTalkPage().getPrefixedText();
relPageLink.textContent = 'ノート';
}
relPageLink.href = mw.config.get('wgScript') + '?title=' + relPageTitle + '&redirect=no';
relPageLink.target = '_blank';
relPageLink.dataset.title = relPageTitle;
li.appendChild(relPageLink);
li.appendChild(document.createTextNode(' | '));
if (anchors[relPageTitle]) {
anchors[relPageTitle].push(relPageLink);
} else {
anchors[relPageTitle] = [relPageLink];
}
var backlinkLink = document.createElement('a');
backlinkLink.href = mw.config.get('wgScript') + '?title=Special:WhatLinksHere/' + title;
backlinkLink.target = '_blank';
backlinkLink.textContent = 'リンク元';
li.appendChild(backlinkLink);
li.appendChild(document.createTextNode(' | '));
var historyLink = document.createElement('a');
historyLink.href = mw.config.get('wgScript') + '?title=' + title + '&action=history';
historyLink.target = '_blank';
historyLink.textContent = '履歴';
li.appendChild(historyLink);
li.appendChild(document.createTextNode(' | '));
var logLink = document.createElement('a');
logLink.href = mw.config.get('wgScript') + '?title=Special:Log&page=' + title;
logLink.target = '_blank';
logLink.textContent = '記録';
li.appendChild(logLink);
li.appendChild(document.createTextNode(' | '));
var deleteLogLink = document.createElement('a');
deleteLogLink.href = mw.config.get('wgScript') + '?title=Special:Log/delete&page=' + title;
deleteLogLink.target = '_blank';
deleteLogLink.textContent = '削除記録';
li.appendChild(deleteLogLink);
li.appendChild(document.createTextNode(')'));
var removeButton = createButton(li, '', '除去', {buttonCss: 'margin-left: 1em;'});
removeButton.classList.add('md-targets-list-deleteitem');
if (hideRemoveButtons) removeButton.style.display = 'none';
removeButton.addEventListener('click', function() {
cleanupPagetitles({remove: [title]});
li.remove();
});
var progress = document.createElement('span');
progress.classList.add('md-targets-list-progress');
progress.style.cssText = 'display: inline-block; margin-left: 1em;';
li.appendChild(progress);
progressObj[title] = {
list: li,
title: titleLink,
progress: progress
};
tgtListOl.appendChild(li);
});
mw.hook('wikipage.content').fire(mw.util.$content);
return checkPageExistence(Object.keys(anchors)).then(function(pageInfoArray) {
/** @type {string[]} */
var missingMainTitles = [];
pageInfoArray.forEach(function(obj) {
if (obj.missing) {
if (anchors[obj.title]) {
anchors[obj.title].forEach(function(a) {
a.classList.add('new');
});
}
if (pagetitles.indexOf(obj.title) !== -1 && missingMainTitles.indexOf(obj.title) === -1) {
missingMainTitles.push(obj.title);
}
}
});
return {
titles: pagetitles,
existingTitles: pagetitles.filter(function(p) { return missingMainTitles.indexOf(p) === -1; }),
missingTitles: missingMainTitles,
progress: progressObj
};
});
};
/** Show hidden remove buttons in the pagetitle list. */
var showListItemRemoveButtons = function() {
var removeButtons = document.querySelectorAll('.md-targets-list-deleteitem');
Array.prototype.forEach.call(removeButtons, /** @param {HTMLInputElement} el */ function(el) {
el.style.display = 'inline-block';
});
};
tgtTab2.radio.addEventListener('change', function() {
createList(false).then(function(titlesGiven) {
if (!titlesGiven) {
tgtTab1.radio.checked = true;
alert('対象ページが入力されていません。');
}
});
});
// Field for save button
var saveButtonWrapper = document.createElement('div');
saveButtonWrapper.id = 'md-targets-save';
saveButtonWrapper.style.marginTop = '0.5em';
tgtWrapper.appendChild(saveButtonWrapper);
var saveButton = createButton(saveButtonWrapper, '', '保存');
var getSavedButton = createButton(saveButtonWrapper, '', '保存済みページを取得', {buttonCss: 'margin-left: 1em;'});
if (!mw.user.options.get(optionName)) getSavedButton.style.display = 'none';
getSavedButton.addEventListener('click', function(e) {
/** @type {string|null} */
var savedPages = mw.user.options.get(optionName);
var pagetitles;
if (savedPages && (pagetitles = JSON.parse(savedPages)).length) {
if (tgtTab2.radio.checked) {
createList(false, {add: pagetitles});
} else {
cleanupPagetitles({add: pagetitles});
}
}
});
saveButtonWrapper.appendChild(getSavedButton);
var saveProgress = document.createElement('span');
saveProgress.style.cssText = 'margin-left: 1em; display: inline-block;';
saveButtonWrapper.appendChild(saveProgress);
var saveTimeout;
saveButton.addEventListener('click', function(e) {
var pagetitles = cleanupPagetitles();
var reset = !pagetitles.length;
if (reset) {
if (!confirm('保存済みページを初期化します。よろしいですか?')) return;
}
toggleDisableAttributes(true);
var data = reset ? null : JSON.stringify(pagetitles);
clearTimeout(saveTimeout);
saveProgress.innerHTML = getIcon('doing').outerHTML + ' 保存中';
// @ts-ignore
api.saveOption(optionName, data)
.then(function(res) {
console.log(MD, res);
toggleDisableAttributes(false);
saveProgress.innerHTML = getIcon('done').outerHTML + ' 保存完了';
getSavedButton.style.display = reset ? 'none' : 'inline-block';
mw.user.options.set(optionName, data);
}).catch(function(code, err) {
console.error(MD, err);
toggleDisableAttributes(false);
saveProgress.innerHTML = getIcon('failed').outerHTML + ' 保存失敗 (' + code + ')';
}).then(function() {
saveTimeout = setTimeout(function(){
saveProgress.innerHTML = '';
}, 5000);
});
});
// Field for deletion targets - Fetcher container
var fetcherWrapper = document.createElement('fieldset');
fetcherWrapper.id = 'md-targets-fetcher';
fetcherWrapper.style.marginTop = '1em';
fetcherWrapper.innerHTML = '<legend>一括取得</legend>';
tgtWrapper.appendChild(fetcherWrapper);
/**
* Create a width-fixed label for a fetcher option.
* @param {HTMLElement} appendTo
* @param {string} labelText
* @param {boolean} [appendBr] Append \<br> before the label if true.
* @returns {HTMLLabelElement}
*/
var createFetcherLabel = function(appendTo, labelText, appendBr) {
if (appendBr) appendTo.appendChild(document.createElement('br'));
var label = document.createElement('label');
label.style.cssText = 'display: inline-block; width: 12ch;';
label.textContent = labelText;
appendTo.appendChild(label);
return label;
};
/**
* Create a fetcher option textbox.
* @param {HTMLElement} appendTo
* @param {string} textboxId
* @param {string} labelText
* @param {boolean} [appendBr] Append \<br> before the label if true.
* @returns {HTMLInputElement}
*/
var createFetcherTextbox = function(appendTo, textboxId, labelText, appendBr) {
return createLabeledTextbox(appendTo, textboxId, labelText, {labelCss: 'width: 12ch;', textboxCss: 'width: 50ch;', appendBr: !!appendBr});
};
/**
* @typedef FetcherRegexOption
* @type {object}
* @property {HTMLLabelElement} label
* @property {HTMLInputElement} regexMode
* @property {HTMLInputElement} caseInsensitive
*/
/**
* Create a regex fetcher option (empty label and two checkboxes).
* @param {HTMLElement} appendTo
* @param {string} checkboxIdFragment md-targets-fetcher-XXXX-regex
* @param {boolean} [appendBr] Append \<br> before the label if true.
* @returns {FetcherRegexOption}
*/
var createFetcherRegexOption = function(appendTo, checkboxIdFragment, appendBr) {
var label = createFetcherLabel(appendTo, '', !!appendBr);
var id = 'md-targets-fetcher-' + checkboxIdFragment + '-regex';
var chRegex = createLabeledCheckbox(appendTo, id, '正規表現モード', {checkboxCss: 'margin-left: 1em;'});
id += '-caseinsensitive';
var chRegexCI = createLabeledCheckbox(appendTo, id, '大文字小文字を区別しない', {checkboxCss: 'margin-left: 1em;'});
return {label: label, regexMode: chRegex, caseInsensitive: chRegexCI};
};
/**
* Set a dynamic placeholder for a regex setting textbox.
* @param {HTMLInputElement} textbox
* @param {HTMLInputElement} checkbox
*/
var setRegexPlaceholder = function(textbox, checkbox) {
var defaultMsg = '複数指定する場合はカンマで分割';
textbox.placeholder = defaultMsg;
checkbox.addEventListener('change', function() {
textbox.placeholder = this.checked ? '一文字目は大文字扱い・スペースは"_"扱い' : defaultMsg;
});
};
// Field for deletion targets - Fetcher - Mode dropdown
var fetcherMode = createLabeledDropdown(fetcherWrapper, 'md-targets-fetcher-mode', 'モード', {labelCss: 'width: 12ch;'});
fetcherMode.dataset.chosen = 'テンプレート';
fetcherMode.onchange = function() { // CSS looks at data-chosen to decide which field to show below the mode dropdown
fetcherMode.dataset.chosen = fetcherMode.value;
};
fetcherMode.innerHTML =
'<option value="テンプレート">テンプレート</option>' +
'<option value="カテゴリ">カテゴリ</option>' +
'<option value="投稿記録">投稿記録</option>' +
'<option value="リンク選択">リンク選択</option>';
fetcherWrapper.appendChild(fetcherMode);
// Field for deletion targets - Fetcher - By template
var fetcherTemplateWrapper = document.createElement('div');
fetcherTemplateWrapper.classList.add('md-targets-fetcher-settings');
fetcherTemplateWrapper.id = 'md-targets-fetcher-template';
fetcherWrapper.appendChild(fetcherTemplateWrapper);
var fetcherTemplatePagetitle = createFetcherTextbox(fetcherTemplateWrapper, 'md-targets-fetcher-template-pagetitle', 'ページ');
var fetcherTemplateTitle = createFetcherTextbox(fetcherTemplateWrapper, 'md-targets-fetcher-template-title', 'テンプレート', true);
var fetcherTemplateTitleRegex = createFetcherRegexOption(fetcherTemplateWrapper, 'template-title', true);
setRegexPlaceholder(fetcherTemplateTitle, fetcherTemplateTitleRegex.regexMode);
var fetcherTemplateSection = createFetcherTextbox(fetcherTemplateWrapper, 'md-targets-fetcher-template-section', 'セクション', true);
fetcherTemplateSection.placeholder = '随意指定';
// Field for deletion targets - Fetcher - By category
var fetcherCategoryWrapper = document.createElement('div');
fetcherCategoryWrapper.classList.add('md-targets-fetcher-settings');
fetcherCategoryWrapper.id = 'md-targets-fetcher-category';
fetcherWrapper.appendChild(fetcherCategoryWrapper);
var fetcherCategoryTitle = createFetcherTextbox(fetcherCategoryWrapper, 'md-targets-fetcher-category-title', 'カテゴリ');
fetcherCategoryTitle.placeholder = '名前空間接頭辞を除いたページ名';
var fetcherCategoryExclude = createFetcherTextbox(fetcherCategoryWrapper, 'md-targets-fetcher-category-exclude', '除外ページ', true);
var fetcherCategoryExcludeRegex = createFetcherRegexOption(fetcherCategoryWrapper, 'category-exclude', true);
setRegexPlaceholder(fetcherCategoryExclude, fetcherCategoryExcludeRegex.regexMode);
var fetcherCategoryNamespace = createFetcherTextbox(fetcherCategoryWrapper, 'md-targets-fetcher-category-namespace', '名前空間', true);
fetcherCategoryNamespace.placeholder = '随意指定・カンマで分割';
var nsTooltip = [
'ノートは+1', '0: 標準', '2: 利用者', '4: Wikipedia', '6: ファイル', '8: MediaWiki',
'10: Template', '12: Help', '14: Category', '100: Portal', '102: プロジェクト', '828: モジュール'
];
$(fetcherCategoryNamespace).tooltip({ // Show tooltip when the namespace specifier textbox is hovered over
tooltipClass: 'md-namespace-tooltip',
items: '#' + fetcherCategoryNamespace.id,
content: nsTooltip.join('\n'),
position: {
my: 'left bottom',
at: 'left top'
}
});
// Field for deletion targets - Fetcher - By contribs
var fetcherContribsWrapper = document.createElement('div');
fetcherContribsWrapper.classList.add('md-targets-fetcher-settings');
fetcherContribsWrapper.id = 'md-targets-fetcher-contribs';
fetcherWrapper.appendChild(fetcherContribsWrapper);
var fetcherContribsUsername = createFetcherTextbox(fetcherContribsWrapper, 'md-targets-fetcher-contribs-username', '利用者名');
var fetcherContribsExclude = createFetcherTextbox(fetcherContribsWrapper, 'md-targets-fetcher-contribs-exclude', '除外ページ', true);
var fetcherContribsExcludeRegex = createFetcherRegexOption(fetcherContribsWrapper, 'contribs-exclude', true);
setRegexPlaceholder(fetcherContribsExclude, fetcherContribsExcludeRegex.regexMode);
var fetcherContribsNamespace = createFetcherTextbox(fetcherContribsWrapper, 'md-targets-fetcher-contribs-namespace', '名前空間', true);
fetcherContribsNamespace.placeholder = '随意指定・カンマで分割';
$(fetcherContribsNamespace).tooltip({
tooltipClass: 'md-namespace-tooltip',
items: '#' + fetcherContribsNamespace.id,
content: nsTooltip.join('\n'),
position: {
my: 'left bottom',
at: 'left top'
}
});
createFetcherLabel(fetcherContribsWrapper, '期間', true); // Addtional date specifier
var fetcherContribsDateFrom = document.createElement('input');
fetcherContribsDateFrom.type = 'text';
fetcherContribsDateFrom.id = 'md-targets-fetcher-contribs-datefrom';
fetcherContribsDateFrom.placeholder = 'この日時以降';
fetcherContribsWrapper.appendChild(fetcherContribsDateFrom);
fetcherContribsWrapper.appendChild(document.createTextNode(' ~ '));
var fetcherContribsDateTo = document.createElement('input');
fetcherContribsDateTo.type = 'text';
fetcherContribsDateTo.id = 'md-targets-fetcher-contribs-dateto';
fetcherContribsDateTo.placeholder = 'この日時以前';
fetcherContribsWrapper.appendChild(fetcherContribsDateTo);
var fetcherContribsDateWarning = document.createElement('span');
fetcherContribsDateWarning.id = 'md-targets-fetcher-contribs-datewarning';
fetcherContribsDateWarning.style.cssText = 'display: none; margin-left: 1em;';
fetcherContribsDateWarning.innerHTML = '<b style="color:red;">!</b>「YYYY-MM-DD」のフォーマットで入力してください';
fetcherContribsWrapper.appendChild(fetcherContribsDateWarning);
var dateRegex = /^(2[01][0-9][0-9]-[0-2][0-9]-[0-3][0-9])?$/;
[fetcherContribsDateFrom, fetcherContribsDateTo].forEach(function(el, i, arr) {
var other = i === 0 ? arr[1] : arr[0];
el.addEventListener('input', function() { // Show a warning message if an ill-formatted timestamp is typed into either of the textboxes
fetcherContribsDateWarning.style.display = dateRegex.test(this.value) && dateRegex.test(other.value) ? 'none' : 'inline-block';
});
$(el).datepicker({ // Add datepicker
dateFormat: 'yy-mm-dd',
onSelect: function() { // Trigger input event when a date is selected
el.dispatchEvent(new Event('input'));
}
});
});
// Field for deletion targets - Fetcher - By links
var fetcherLinkselectorWrapper = document.createElement('div');
fetcherLinkselectorWrapper.classList.add('md-targets-fetcher-settings');
fetcherLinkselectorWrapper.id = 'md-targets-fetcher-linkselector';
fetcherWrapper.appendChild(fetcherLinkselectorWrapper);
var fetcherLinkselector = createFetcherTextbox(fetcherLinkselectorWrapper, 'md-targets-fetcher-linkselector-pagename', 'ページ');
createFetcherLabel(fetcherLinkselectorWrapper, '', true);
var fetcherLinkselectorUrlMode = createLabeledCheckbox(fetcherLinkselectorWrapper, 'md-targets-fetcher-linkselector-urlmode', 'URLモード', {checkboxCss: 'margin-left: 1em;'});
fetcherLinkselectorUrlMode.addEventListener('change', function() {
if (this.checked) {
fetcherLinkselector.placeholder = '"' + mw.config.get('wgServer') + mw.config.get('wgScript') + '?title="の後続部分';
} else {
fetcherLinkselector.placeholder = '';
}
});
// Field for deletion targets - Fetcher - Main button
var fetcher = createButton(fetcherWrapper, 'md-targets-fetcher-fetch', '取得', {buttonCss: 'margin-top: 0.5em;'});
var fetcherResult = document.createElement('span');
fetcherResult.id = 'md-targets-fetcher-result';
fetcherResult.style.cssText = 'display: inline-block; margin-left: 1em;';
fetcherWrapper.appendChild(fetcherResult);
var fetcherTimeout;
/**
* Show a progress message for a fetcher request.
* @param {string} message
* @param {"doing"|"done"|"failed"} imageType
* @param {boolean} autoHide
*/
var showFetcherResult = function(message, imageType, autoHide) {
clearTimeout(fetcherTimeout);
toggleDisableAttributes(imageType === 'doing');
fetcherResult.innerHTML = message;
fetcherResult.appendChild(getIcon(imageType));
if (autoHide) {
fetcherTimeout = setTimeout(function() {
fetcherResult.innerHTML = '';
}, 2500);
}
};
/**
* Show the number of pages fetched when the fetching succeeds.
* @param {string[]} pagetitles
*/
var pagesFetched = function(pagetitles) {
var curTitleList = cleanupPagetitles();
var newTitleList = cleanupPagetitles({replace: curTitleList.concat(pagetitles)});
var diff = pagetitles.length - (newTitleList.length - curTitleList.length);
var msg = pagetitles.length + 'ページ取得しました' + (diff ? ' (うち' + diff + 'ページは既に入力済みです)' : '');
showFetcherResult(msg, 'done', true);
tgtTab1.radio.checked = true;
};
/**
* Create a regex out of a string.
* @param {string} trimedStr Input string. An empty string should not be passed.
* @param {FetcherRegexOption} options An object of HTML elements that stores regex settings.
* @returns {RegExp|undefined} Returns undefined if regex conversion fails.
*/
var createFetcherRegex = function(trimedStr, options) {
var flag = options.caseInsensitive.checked ? 'i' : '';
if (options.regexMode.checked) { // If the input string is intended as a regex
try {
return new RegExp(trimedStr, flag);
}
catch (err) {
showFetcherResult(err, 'failed', false);
return;
}
} else { // If not
trimedStr = mw.util.escapeRegExp(trimedStr);
var strArr = trimedStr.split(',').reduce(/** @param {string[]} acc */ function(acc, val) {
val = ucFirst(val.trim().replace(/ /g, '_'));
if (val && acc.indexOf(val) === -1) acc.push(val);
return acc;
}, []);
return new RegExp('^(' + strArr.join('|') + ')$', flag);
}
};
/**
* Create a string array of namespace numbers from a comma-divided plain text.
* @param {string} nsStr A plain string of namespace numbers divided by commas.
* @returns {string[]|undefined} Returns undefined if the input string contains an invalid namespace number.
*/
var createNamespaceNumberArray = function(nsStr) {
var invalidNsNums = [];
var namespacesArr = nsStr.split(',').reduce(/** @param {string[]} acc */ function(acc, num) {
if (!num) return acc;
if (/^([0-9]|1[0-5]|10[0-3]|82[89])$/.test(num)) {
if (acc.indexOf(num) === -1) acc.push(num);
} else {
if (invalidNsNums.indexOf(num) === -1) invalidNsNums.push(num);
}
return acc;
}, []);
if (invalidNsNums.length) {
showFetcherResult(invalidNsNums.join(', ') + 'は不正な名前空間です', 'failed', false);
return;
} else if (!namespacesArr.length) {
return ['*'];
} else {
return namespacesArr;
}
};
// Event listner to run the fetcher
fetcher.addEventListener('click', function() {
switch (fetcherMode.value) {
case 'テンプレート':
(function() { // just for scope
var title = fetcherTemplatePagetitle.value.replace(/\u200e/g, '').trim();
if (!title) return showFetcherResult('ページ名は必須です', 'failed', true);
var nameVal = fetcherTemplateTitle.value.replace(/\u200e/g, '').trim();
if (!nameVal) return showFetcherResult('取得するテンプレート名を入力してください', 'failed', true);
var templateName = createFetcherRegex(nameVal, fetcherTemplateTitleRegex);
if (!templateName) return;
showFetcherResult('取得しています', 'doing', false);
var sectionName = fetcherTemplateSection.value.replace(/\u200e/g, '').trim();
var params = {
title: title,
templateName: templateName,
sectionName: sectionName ? sectionName : undefined
};
return getFirstTemplateParameters(params).then(function(pagetitles) {
if (typeof pagetitles === 'string') {
var err = pagetitles;
showFetcherResult(err, 'failed', true);
} else {
pagesFetched(pagetitles);
}
});
})();
break;
case 'カテゴリ':
(function() {
var title = fetcherCategoryTitle.value.replace(/\u200e/g, '').trim();
if (!title) return showFetcherResult('カテゴリ名は必須です', 'failed', true);
var excludeVal = fetcherCategoryExclude.value.replace(/\u200e/g, '').trim();
/** @type {RegExp|undefined} */
var excludeRegex;
if (excludeVal) {
excludeRegex = createFetcherRegex(excludeVal, fetcherCategoryExcludeRegex);
if (!excludeRegex) return;
}
var namespaces = fetcherCategoryNamespace.value.replace(/[\u200e\s]/g, '');
var namespacesArr = createNamespaceNumberArray(namespaces);
if (!namespacesArr) return;
showFetcherResult('取得しています', 'doing', false);
return getCatMembers(title, namespacesArr).then(function(pagetitles) {
// @ts-ignore
if (excludeRegex) pagetitles = pagetitles.filter(function(el) { return !excludeRegex.test(el); });
if (!pagetitles.length) {
showFetcherResult('ページが見つかりませんでした', 'failed', true);
} else {
pagesFetched(pagetitles);
}
});
})();
break;
case '投稿記録':
(function() {
var username = fetcherContribsUsername.value.replace(/\u200e/g, '').trim();
if (!username) return showFetcherResult('利用者は必須です', 'failed', true);
var excludeVal = fetcherContribsExclude.value.replace(/\u200e/g, '').trim();
/** @type {RegExp|undefined} */
var excludeRegex;
if (excludeVal) {
excludeRegex = createFetcherRegex(excludeVal, fetcherContribsExcludeRegex);
if (!excludeRegex) return;
}
var namespaces = fetcherContribsNamespace.value.replace(/[\u200e\s]/g, '');
var namespacesArr = createNamespaceNumberArray(namespaces);
if (!namespacesArr) return;
var tsFrom = fetcherContribsDateFrom.value.replace(/[\u200e\s]/g, '');
var tsTo = fetcherContribsDateTo.value.replace(/[\u200e\s]/g, '');
var dFrom, dTo, isoFrom, isoTo;
try {
if (tsFrom) isoFrom = (dFrom = new Date(tsFrom)).toJSON();
if (tsTo) isoTo = (dTo = new Date(tsTo)).toJSON();
}
catch (err) {
return showFetcherResult(err, 'failed', true);
}
if (dFrom && dTo) {
if (dFrom < dTo) {
// Do nothing
} else if (dFrom > dTo) {
var isoTemp = isoFrom;
isoFrom = isoTo;
isoTo = isoTemp;
} else {
isoTo = undefined;
}
}
showFetcherResult('取得しています', 'doing', false);
return getUserContribs(username, namespacesArr, isoFrom, isoTo).then(function(pagetitles) {
if (!pagetitles) return showFetcherResult('取得に失敗しました', 'failed', true);
// @ts-ignore
if (excludeRegex) pagetitles = pagetitles.filter(function(el) { return !excludeRegex.test(el); });
if (!pagetitles.length) {
showFetcherResult('ページが見つかりませんでした', 'failed', true);
} else {
pagesFetched(pagetitles);
}
});
})();
break;
case 'リンク選択':
(function() {
var title = fetcherLinkselector.value.replace(/\u200e/g, '').trim();
if (!title) return showFetcherResult('ページ名は必須です', 'failed', true);
var getHtml = function() {
if (fetcherLinkselectorUrlMode.checked || getNamespaceNumber(title) === -1) {
return scrapeWikipage(title);
} else {
return read(title, true);
}
};
showFetcherResult('取得しています', 'doing', false);
return getHtml().then(function(html) {
if (html === null) {
showFetcherResult('ページが見つかりませんでした', 'failed', true);
return;
} else if (html === undefined) {
showFetcherResult('ページコンテンツの取得に失敗しました', 'failed', true);
return;
}
linkSelector(html).then(function(pagetitles) {
if (!pagetitles.length) {
showFetcherResult('ページが選択されませんでした', 'failed', true);
} else {
pagesFetched(pagetitles);
}
});
});
})();
break;
default:
showFetcherResult('バグが発生しました', 'failed', true);
}
});
// Field for deletion settings
var settingWrapper = document.createElement('fieldset');
settingWrapper.id = 'md-settings';
settingWrapper.innerHTML = '<legend>処理設定</legend>';
container.appendChild(settingWrapper);
var reasonLabelCss = {labelCss: 'width: 8ch;'};
var reasonLabelCssBr = {labelCss: 'width: 8ch;', appendBr: true};
var settingMode = createLabeledDropdown(settingWrapper, 'md-settings-mode', 'モード', reasonLabelCss);
settingMode.innerHTML =
'<option>削除</option>'/* +
(canUndelete ? '<option>復帰</option>' : '')*/;
var settingRsn1Dropdown = createLabeledDropdown(settingWrapper, 'md-settings-reason1', '理由1', reasonLabelCssBr);
var settingRsn2Dropdown = createLabeledDropdown(settingWrapper, 'md-settings-reason2', '理由2', reasonLabelCssBr);
settingMode.addEventListener('change', function() {
[settingRsn1Dropdown, settingRsn2Dropdown].forEach(function(el) {
var undeleteMode = settingMode.value === '復帰';
el.disabled = undeleteMode;
if (undeleteMode) el.value = '';
});
});
var settingRsnCInput = createLabeledTextbox(settingWrapper, 'md-settings-reasonC', '', reasonLabelCssBr);
settingRsnCInput.placeholder = '非定形理由';
getDeleteReasonDropdown().then(function(dropdown) {
if (!dropdown) return alert('削除理由の取得に失敗しました。');
settingRsn1Dropdown.innerHTML = dropdown.innerHTML;
settingRsn2Dropdown.innerHTML = dropdown.innerHTML;
settingRsnCInput.style.width = settingRsn1Dropdown.offsetWidth.toString() + 'px';
});
var settingWatch = createLabeledCheckbox(settingWrapper, 'md-settings-watch', '対象ページをウォッチリストに追加', {appendBr: true});
var settingWatchExpiryUl = document.createElement('ul');
settingWatchExpiryUl.id = 'md-settings-watch-expiry-container';
settingWrapper.appendChild(settingWatchExpiryUl);
var settingWatchExpiryLi = document.createElement('li');
settingWatchExpiryUl.appendChild(settingWatchExpiryLi);
var settingWatchExpiry = createLabeledDropdown(settingWatchExpiryLi, 'md-settings-watch-expiry', '期限', {labelCss: 'margin-right: 1em;'});
settingWatchExpiry.innerHTML =
'<option value="indefinite">無期限</option>' +
'<option value="1 week">1週間</option>' +
'<option value="1 month">1か月</option>' +
'<option value="3 months">3か月</option>' +
'<option value="6 months">6か月</option>' +
'<option value="1 year">1年</option>' +
'<option value="3 years">3年</option>';
// Field for the 'execute' button
var massDeleteWrapper = document.createElement('div');
massDeleteWrapper.id = 'md-massdelete-container';
massDeleteWrapper.style.marginTop = '0.5em';
container.appendChild(massDeleteWrapper);
var massDeleteButton = createButton(massDeleteWrapper, 'md-massdelete', '実行', {buttonCss: 'display: block;'});
var massDeleteMessage = document.createElement('span');
massDeleteMessage.style.marginTop = '0.5em';
massDeleteMessage.style.display = 'block';
massDeleteWrapper.appendChild(massDeleteMessage);
/**
* An alternative for window.confirm by a link.
* @param {number} pageCount The number of pages to delete.
* @param {number} removeCount The number of pages to remove from the delete targets.
* @returns {JQueryPromise<boolean>}
*/
var linkConfirm = function(pageCount, removeCount) {
var def = $.Deferred();
var msg = pageCount + 'ページを一括' + settingMode.value + 'します';
if (removeCount) {
msg += '(うち' + (settingMode.value === '削除' ? '未作成' : '作成済み') + 'の' + removeCount + 'ページは自動除去されます)';
}
msg += '。よろしいですか? ';
massDeleteMessage.appendChild(document.createTextNode(msg));
var msgYes = document.createElement('a');
msgYes.role = 'button';
msgYes.textContent = '続行';
msgYes.addEventListener('click', function() {
massDeleteMessage.innerHTML = '';
def.resolve(true);
});
massDeleteMessage.appendChild(msgYes);
massDeleteMessage.appendChild(document.createTextNode('・'));
var msgNo = document.createElement('a');
msgNo.role = 'button';
msgNo.textContent = '中止';
msgNo.addEventListener('click', function() {
massDeleteMessage.innerHTML = '';
def.resolve(false);
});
massDeleteMessage.appendChild(msgNo);
return def.promise();
};
massDeleteButton.addEventListener('click', function() {
tgtTab2.radio.checked = true;
toggleDisableAttributes(true);
massDeleteMessage.appendChild(document.createTextNode('準備中'));
massDeleteMessage.appendChild(getIcon('doing'));
createList(true).then(function(listObj) {
massDeleteMessage.innerHTML = '';
if (!listObj) {
toggleDisableAttributes(false);
tgtTab1.radio.checked = true;
alert('対象ページが入力されていません。');
return;
} else if (settingMode.value === '削除' && listObj.titles.length === listObj.missingTitles.length) {
toggleDisableAttributes(false);
showListItemRemoveButtons();
tgtWrapper.scrollIntoView({behavior: 'smooth'});
alert('削除対象ページが全て未作成ページです。');
return;
} else if (settingMode.value === '復帰' && listObj.missingTitles.length === 0) {
toggleDisableAttributes(false);
showListItemRemoveButtons();
tgtWrapper.scrollIntoView({behavior: 'smooth'});
alert('復帰対象ページが全て作成済みページです。');
return;
}
// Get reason
var reason1 = settingRsn1Dropdown.value;
var reason2 = settingRsn2Dropdown.value;
var reasonC = settingRsnCInput.value.replace(/\u200e/g, '').trim();
var reason = [reason1, reason2, reasonC].filter(function(el) { return el; }).join(': ');
if (!reason) {
if (!confirm(settingMode.value + '理由が入力されていません。このまま実行しますか?')) {
toggleDisableAttributes(false);
showListItemRemoveButtons();
return;
}
}
// Get watchlist options
var watchlist = settingWatch.checked ? 'watch' : 'nochange';
var watchlistexpiry = settingWatch.checked ? settingWatchExpiry.value: undefined;
mw.notify('入力内容をよく確認のうえ「続行」ボタンを押してください');
var removeLen = settingMode.value === '削除' ? listObj.missingTitles.length : listObj.titles.length - listObj.missingTitles.length;
linkConfirm(listObj.titles.length, removeLen).then(function(confirmed) {
if (!confirmed) {
toggleDisableAttributes(false);
showListItemRemoveButtons();
mw.notify('中止しました');
return;
}
// Update list (remove non-existing pages on the delete mode or existing pages on the undelete mode)
var pagetitles = listObj.titles.filter(function(title) {
if (settingMode.value === '削除' && listObj.missingTitles.indexOf(title) !== -1 ||
settingMode.value === '復帰' && listObj.missingTitles.indexOf(title) === -1
) {
listObj.progress[title].list.remove();
delete listObj.progress[title];
return false;
} else {
listObj.progress[title].progress.append(getIcon('doing'));
return true;
}
});
cleanupPagetitles({replace: pagetitles}); // Update textbox
var params = {
action: settingMode.value === '削除' ? 'delete' : 'undelete',
reason: reason,
tags: mw.config.get('wgDBname') === 'testwiki' ? 'testtag' : 'DevScript',
watchlist: watchlist,
watchlistexpiry: watchlistexpiry,
formatversion: '2'
};
tgtWrapper.scrollIntoView({behavior: 'smooth'});
[saveButtonWrapper, fetcherWrapper, settingWrapper, massDeleteWrapper].forEach(function(el) {
el.hidden = true;
});
massDelete(listObj.progress, params);
});
});
});
}
/**
* Create a user right error interface.
*/
function showUserRightError() {
var container = document.createElement('div');
container.id = 'md-container';
replaceContent(container, '権限エラー');
var p1 = document.createElement('p');
p1.textContent = 'あなたには「このページの削除」を行う権限がありません。理由は以下の通りです:';
container.appendChild(p1);
var p2 = document.createElement('p');
p2.appendChild(document.createTextNode('この操作は、以下のグループのいずれかに属する利用者のみが実行できます: '));
['管理者', 'インターフェース管理者', '削除者'].forEach(function(g, i, arr) {
var a = document.createElement('a');
a.href = mw.config.get('wgArticlePath').replace('$1', 'Wikipedia:' + g);
a.textContent = g;
p2.appendChild(a);
p2.appendChild(document.createTextNode(i !== arr.length - 1 ? '、' : '。'));
});
container.appendChild(p2);
}
/**
* Replace the DOM body with a new content.
* @param {HTMLDivElement} newContent
* @param {string} headerText
*/
function replaceContent(newContent, headerText) {
var bodyContent = document.querySelector('.mw-body-content');
if (bodyContent) {
bodyContent.replaceChildren(newContent);
var firstHeading = document.querySelector('.mw-first-heading');
if (firstHeading) {
firstHeading.textContent = headerText;
}
}
}
/**
* @typedef PageInfo
* @property {string} title Spaces are represented by underscores.
* @property {boolean} missing
*/
/**
* Check whether given pages exist.
* @param {string[]} titlesArr
* @returns {JQueryPromise<PageInfo[]>} Spaces in pagetitles are represented by underscores.
*/
function checkPageExistence(titlesArr) {
/** @type {PageInfo[]} */
var pageInfo = [];
/**
* @param {string[]} titles
* @returns {JQueryPromise<void>}
*/
var query = function(titles) {
return api.post({
action: 'query',
titles: titles.join('|'),
formatversion: '2'
}).then(function(res) {
var resPages;
if (!res || !res.query || !(resPages = res.query.pages) || !resPages.length) return;
resPages.forEach(function(obj) {
if (!obj.title) return;
obj.title = obj.title.replace(/ /g, '_');
pageInfo.push({
title: obj.title,
missing: !!obj.missing
});
});
}).catch(function(code, err) {
console.error(MD, err);
return;
});
};
titlesArr = titlesArr.slice();
var deferreds = [];
while (titlesArr.length) {
deferreds.push(query(titlesArr.splice(0, apilimit)));
}
return $.when.apply($, deferreds).then(function() {
return pageInfo;
});
}
/**
* Create a dropdown with a label on its left. Default CSS for the dropdown: 'display: inline-block;'
* @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;';
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;'
* @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;';
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;
}
/**
* Create a checkbox with a label on its right. Default CSS for the checkbox: 'margin-right: 0.5em;'
* @param {HTMLElement} appendTo
* @param {string} id
* @param {string} labelText This is applied to innerHTML, not textContent.
* @param {{checkboxCss?: string; appendBr?: boolean;}} [options]
* @returns {HTMLInputElement}
*/
function createLabeledCheckbox(appendTo, id, labelText, options) {
options = options || {};
if (options.appendBr) appendTo.appendChild(document.createElement('br'));
var checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = id;
checkbox.style.marginRight = '0.5em';
if (options.checkboxCss) parseAndApplyCssText(checkbox, options.checkboxCss);
appendTo.appendChild(checkbox);
var label = document.createElement('label');
label.htmlFor = id;
label.innerHTML = labelText;
appendTo.appendChild(label);
return checkbox;
}
/**
* @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];
}
}
/**
* Get a loading/check/cross icon 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;
}
/**
* Get the namespace number of a pagetitle.
* @param {string} pagetitle
* @returns {number}
*/
function getNamespaceNumber(pagetitle) {
var prefix = pagetitle.replace(/ /g, '_').split(':')[0].toLowerCase();
var namespaceIds = mw.config.get('wgNamespaceIds');
for (var alias in namespaceIds) {
if (prefix === alias) return namespaceIds[alias];
}
return 0;
}
/**
* Get the content of a page, parse templates in it, and filter out the values of the first parameter.
* @param {{title: string; templateName: RegExp; sectionName?: string;}} parseConfig
* @returns {JQueryPromise<string[]|string>} Returns an array of strings on success, otherwise an error message as a string.
*/
function getFirstTemplateParameters(parseConfig) {
return read(parseConfig.title).then(function(content) {
if (content === null) {
return 'ページが見つかりませんでした';
} else if (content === undefined) {
return '取得に失敗しました';
}
if (parseConfig.sectionName) {
var sections = parseSections(content).filter(function(obj) {
return obj.title === parseConfig.sectionName;
});
if (!sections.length) return '指定された名前のセクションが見つかりませんでした';
content = sections[0].content;
}
var templates = parseTemplates(content, {namePredicate: function(name) {
return parseConfig.templateName.test(name);
}});
return templates.reduce(/** @param {string[]} acc */ function(acc, Template) {
/** @type {TemplateArgument[]} */
var filtered;
if ((filtered = Template.arguments.filter(function(obj) { return obj.name === '1'; })).length) {
if (filtered[0].value && acc.indexOf(filtered[0].value) === -1) acc.push(filtered[0].value);
}
return acc;
}, []);
});
}
/**
* Get the content of a given page.
* @param {string} pagename
* @param {boolean} [parseHtml] Returns HTML if true.
* @returns {JQueryPromise<string|null|undefined>} Returns null if the page doesn't exist, undefined if the fetching fails.
*/
function read(pagename, parseHtml) {
var prop = !!parseHtml ? 'text' : 'wikitext';
var params = {
action: 'parse',
page: pagename,
prop: prop,
formatversion: '2'
};
if (parseHtml) {
$.extend(params, {
disablelimitreport: true,
disableeditsection: true,
disabletoc: true,
preview: true
});
}
return api.get(params)
.then(function(res) {
var resParse;
return res && res.parse && (resParse = res.parse[prop]) !== undefined ? resParse : undefined;
}).catch(function(code, err) {
console.log(MD, err);
return code === 'missingtitle' ? null : undefined;
});
}
/**
* Scrape the content of a wikipage from a pagetitle and additional query parameters.
* @param {string} queryParam URL query params following '/w/index.php?title='
* @returns {JQueryPromise<string|null|undefined>} Returns null if the page doesn't exist, undefined if the fetching fails.
*/
function scrapeWikipage(queryParam) {
var def = $.Deferred();
var url = mw.config.get('wgServer') + mw.config.get('wgScript') + '?title=' + queryParam;
$.get(url)
.then(function(res) {
if (!res) return def.resolve(undefined);
var html = document.createElement('html');
html.innerHTML = res;
var body;
if (html.querySelector('.page-メインページ')) { // If the page doesn't exist, the content of Main page is returned
return def.resolve(null);
} else if ((body = html.querySelector('.mw-body-content'))) {
return def.resolve(body.innerHTML);
} else {
return def.resolve(undefined);
}
}).catch(function(err) {
console.log(MD, err);
return def.resolve(undefined);
});
return def.promise();
}
/**
* Parse the content of a page by each section.
* @param {string} content
* @returns {{header: string|null; title: string|null; level: number; index: number; content: string; deepest: boolean|null;}[]}
*/
function parseSections(content) {
var regex = {
comments: /<!--[\s\S]*?-->|<(nowiki|pre|syntaxhighlight|source|math)[\s\S]*?<\/\1\s*>/gi,
header: /={2,5}[^\S\n\r]*.+[^\S\n\r]*={2,5}/,
headerG: /={2,5}[^\S\n\r]*.+[^\S\n\r]*={2,5}/g,
headerEquals: /(?:^={2,5}[^\S\n\r]*|[^\S\n\r]*={2,5}$)/g
};
// Replace comment-related tags
var idx = 0;
var comments = [];
var m;
while ((m = regex.comments.exec(content))) {
content = content.replace(m[0], '$CM' + (idx++));
comments.push(m[0]);
}
// Get headers
/** @type {{text:string; title:string; level: number; index: number;}[]} */
var headers = [];
while ((m = regex.headerG.exec(content))) {
headers.push({
text: m[0],
title: m[0].replace(regex.headerEquals, ''),
level: (m[0].match(/=/g) || []).length / 2,
index: m.index // This is the index number of the header in the content
});
}
headers.unshift({text: '', title: '', level: 1, index: 0}); // For the top section
// Return an array of objects
return headers.map(function(obj, i, arr) {
var isTopSection = i === 0;
var sectionContent = arr.length > 1 ? content.slice(0, arr[1].index) : content;
var deepest = null;
if (!isTopSection) {
var nextSameLevelSection = arr.slice(i + 1).filter(function(objF) {return objF.level <= obj.level; });
sectionContent = content.slice(obj.index, nextSameLevelSection.length ? nextSameLevelSection[0].index : content.length);
var dRegex = new RegExp('(={' + (obj.level + 1) + ',})[^\\S\\n\\r]*.+[^\\S\\n\\r]*\\1');
deepest = !dRegex.test(sectionContent.slice(obj.text.length));
}
comments.forEach(function(el, j){ sectionContent = sectionContent.replace('$CM' + j, el); }); // Get comments back
return {
header: isTopSection ? null : obj.text,
title: isTopSection ? null : obj.title,
level: obj.level,
index: i,
content: sectionContent,
deepest: deepest
};
});
}
/**
* @typedef TemplateConfig
* @property {boolean} [recursive] Whether to parse templates nested inside others. Defaulted to true.
* @property {boolean} [parseComments] Whether to parse templates inside comment-related tags. Defaulted to false.
* @property {NamePredicate} [namePredicate] Include template in result only if its name matches this predicate.
* @property {TemplatePredicate} [templatePredicate] Include template in result only if it matches this predicate.
* @callback NamePredicate
* @param {string} name Template name (first letter in uppercase, spaces represented by underscores)
* @returns {boolean}
* @callback TemplatePredicate
* @param {Template} Template Template object
* @returns {boolean}
* @typedef Template
* @property {string} text The whole text of the template, starting with '{{' and ending with '}}'.
* @property {string} name Name of the template. The first letter is always in upper case and spaces are represented by underscores.
* @property {TemplateArgument[]} arguments Parsed template arguments as an array.
* @property {number} nestlevel Nest level of the template. If it's not part of another template, the value is 0.
* @typedef TemplateArgument
* @property {string} text The whole text of the template argument.
* @property {string} name A key name such as '1' (numeral key is assigned if not explicitly defined)
* @property {string} value A value following a key such as '1='.
*/
/**
* Parse templates in wikitext. Templates within \<!-- -->, nowiki, pre, syntaxhighlight, source, and math are ignored.
* (Those in comment tags can be parsed if TemplateConfig.parseComments is true.)
* @param {string} wikitext Text in which to parse templates.
* @param {TemplateConfig} [config]
* @param {number} [nestlevel] Private parameter. Do not specify this manually.
* @returns {Template[]}
*/
function parseTemplates(wikitext, config, nestlevel) {
/** @type {TemplateConfig} */
var cfg = {
recursive: true,
parseComments: false,
namePredicate: undefined,
templatePredicate: undefined
};
$.extend(cfg, config || {});
nestlevel = typeof nestlevel === 'number' ? nestlevel : 0;
// Number of unclosed braces
var numUnclosed = 0;
// Are we in a {{{parameter}}}, or between wikitags that prevent transclusions?
var inParameter = false;
var inTag = false;
var tagNames = [];
var parsed = [];
var startIdx, endIdx;
// Look at every character of the wikitext one by one. This loop only extracts the outermost templates.
for (var i = 0; i < wikitext.length; i++) {
var slicedWkt = wikitext.slice(i);
var matchedTag, isComment;
if (!inParameter && !inTag) {
if (/^\{\{\{(?!\{)/.test(slicedWkt)) {
inParameter = true;
i += 2;
} else if (/^\{\{/.test(slicedWkt)) {
if (numUnclosed === 0) {
startIdx = i;
}
numUnclosed += 2;
i++;
} else if (/^\}\}/.test(slicedWkt)) {
if (numUnclosed === 2) {
endIdx = i + 2;
var templateText = wikitext.slice(startIdx, endIdx); // Pipes could have been replaced with a control character if they're part of nested templates
var templateTextPipesBack = _replacePipesBack(templateText);
var templateName = templateTextPipesBack.replace(/\u200e|^\{\{\s*(:?\s*template\s*:|:?\s*テンプレート\s*:)?\s*|\s*[|}][\s\S]*$/gi, '').replace(/ /g, '_');
templateName = ucFirst(templateName);
if (!cfg.namePredicate || cfg.namePredicate(templateName)) {
parsed.push({
text: templateTextPipesBack,
name: templateName,
arguments: _parseTemplateArguments(templateText),
nestlevel: nestlevel
});
}
}
numUnclosed -= 2;
i++;
} else if (wikitext[i] === '|' && numUnclosed > 2) { // numUnclosed > 2 means we're in a nested template
// Swap out pipes with \x01 character.
wikitext = strReplaceAt(wikitext, i, '\x01');
} else if ((matchedTag = slicedWkt.match(/^(?:<!--|<(nowiki|pre|syntaxhighlight|source|math) ?[^>]*?>)/))) {
isComment = /^<!--/.test(slicedWkt);
if (!(cfg.parseComments && isComment)) {
inTag = true;
tagNames.push(isComment ? 'comment' : matchedTag[1]);
i += matchedTag[0].length - 1;
}
}
} else {
// we are in a {{{parameter}}} or tag
if (wikitext[i] === '|' && numUnclosed > 2) {
wikitext = strReplaceAt(wikitext, i, '\x01');
} else if ((matchedTag = slicedWkt.match(/^(?:-->|<\/(nowiki|pre|syntaxhighlight|source|math) ?[^>]*?>)/))) {
isComment = /^-->/.test(slicedWkt);
if (!(cfg.parseComments && isComment)) {
inTag = false;
tagNames.pop();
i += matchedTag[0].length - 1;
}
} else if (/^\}\}\}/.test(slicedWkt)) {
inParameter = false;
i += 2;
}
}
}
// Get nested templates?
if (cfg.recursive) {
/** @type {Template[]} */
var accumulator = [];
var subtemplates = parsed.reduce(function(acc, obj) {
var tempInner = obj.text.slice(2, -2);
if (/\{\{[\s\S]*?\}\}/.test(tempInner)) {
// @ts-ignore
acc = acc.concat(parseTemplates(tempInner, cfg, nestlevel + 1));
}
return acc;
}, accumulator);
parsed = parsed.concat(subtemplates);
}
// Filter the array by a user-defined condition?
if (cfg.templatePredicate) {
// @ts-ignore
parsed = parsed.filter(function(Template) { return cfg.templatePredicate(Template); });
}
return parsed;
}
/**
* This function should never be called externally because it presupposes that pipes in nested templates have been replaced with the control character '\x01',
* and otherwise it doesn't work as expeceted.
* @param {string} template
* @returns {TemplateArgument[]}
*/
function _parseTemplateArguments(template) {
if (template.indexOf('|') === -1) return [];
var innerContent = template.slice(2, -2); // Remove braces
// Swap out pipes in links with \x01 control character
// [[File: ]] can have multiple pipes, so might need multiple passes
var wikilinkRegex = /(\[\[[^\]]*?)\|(.*?\]\])/g;
while (wikilinkRegex.test(innerContent)) {
innerContent = innerContent.replace(wikilinkRegex, '$1\x01$2');
}
var args = innerContent.split('|');
args.shift(); // Remove template name
var unnamedArgCount = 0;
/** @type {TemplateArgument[]} */
var parsedArgs = args.map(function(arg) {
arg = arg.trim();
// Replace {{=}}s with a (unique) control character
// The magic words could have spaces before/after the equal sign in an inconsistent way
// We need the input string back as it was before replacement, so mandane replaceAll isn't a solution here
var magicWordEquals = arg.match(/\{\{\s*=\s*\}\}/g) || [];
magicWordEquals.forEach(function(equal, i) {arg = arg.replace(equal, '$EQ' + i); });
var argName, argValue;
var indexOfEqual = arg.indexOf('=');
if (indexOfEqual >= 0) { // The argument is named
argName = arg.slice(0, indexOfEqual).trim();
argValue = arg.slice(indexOfEqual + 1).trim();
if (argName === unnamedArgCount.toString()) unnamedArgCount++;
} else { // The argument is unnamed
argName = (++unnamedArgCount).toString();
argValue = arg.trim();
}
// Get the replaced {{=}}s back
magicWordEquals.forEach(function(equal, i) {
var replacee = '$EQ' + i;
arg = arg.replace(replacee, equal);
argName = argName.replace(replacee, equal);
argValue = argValue.replace(replacee, equal);
});
return {
text: _replacePipesBack(arg),
name: _replacePipesBack(argName),
value: _replacePipesBack(argValue)
};
});
return parsedArgs;
}
/**
* Capitalize the first letter of a string.
* @param {string} string
* @returns {string}
*/
function ucFirst(string) {
return string.charAt(0).toUpperCase() + string.slice(1);
}
/**
* Replace the nth character in a string with a given string.
* @param {string} string
* @param {number} index
* @param {string} char
* @returns {string}
*/
function strReplaceAt(string, index, char) {
return string.slice(0, index) + char + string.slice(index + 1);
}
/**
* @param {string} string
* @returns {string}
*/
function _replacePipesBack(string) {
return string.replace(/\x01/g, '|');
}
/**
* Get pagetitles that belong to a given category.
* @param {string} cattitle A 'Category:' prefix is automatically added if there's none
* @param {string[]} namespaces
* @returns {JQueryPromise<string[]>}
*/
function getCatMembers(cattitle, namespaces) {
if (!/^Category:/i.test(cattitle)) cattitle = 'Category:' + cattitle;
/** @type {string[]} */
var cats = [];
var query = function(cmcontinue) {
return api.get({
action: 'query',
list: 'categorymembers',
cmtitle: cattitle,
cmprop: 'title',
cmnamespace: namespaces.join('|'),
cmtype: 'page|file|subcat',
cmlimit: 'max',
cmcontinue: cmcontinue,
formatversion: '2'
}).then(function(res) {
var resCM, resCont;
if (!res || !res.query || !(resCM = res.query.categorymembers) || !resCM.length) return undefined;
resCM.forEach(function(obj) {
if (obj.title && cats.indexOf(obj.title) === -1) cats.push(obj.title);
});
return res.continue && (resCont = res.continue.cmcontinue) ? query(resCont) : undefined;
}).catch(function(code, err) {
console.error(MD, err);
return undefined;
});
};
return query().then(function() {
return cats;
});
}
/**
* Get pagetitles from a user's contributions.
* @param {string} username
* @param {string[]} namespaces A string array of namespace numbers. Pass ['*'] for all namespaces.
* @param {string} [oldestTs] Filter out contribs AFTER this timestamp.
* @param {string} [newestTs] Filter out contribs BEFORE this timestamp.
* @returns {JQueryPromise<string[]|undefined>} 無要素配列が返る場合あり
*/
function getUserContribs(username, namespaces, oldestTs, newestTs) {
var params = {
action: 'query',
list: 'usercontribs',
uclimit: 'max',
ucnamespace: namespaces.join('|'),
ucprop: 'title',
formatversion: '2'
};
if (mw.util.isIPAddress(username, true) && !mw.util.isIPAddress(username)) { // CIDR
params.uciprange = username;
} else {
params.ucuser = username;
}
if (oldestTs) params.ucend = oldestTs;
if (newestTs) params.ucstart = newestTs;
return api.get(params)
.then(function(res) {
var resUc;
if (!res || !res.query || !(resUc = res.query.usercontribs) || !resUc.length) return undefined;
return resUc.reduce(/** @param {string[]} acc */ function(acc, obj) {
if (obj.title && acc.indexOf(obj.title) === -1) acc.push(obj.title);
return acc;
}, []);
}).catch(function(code, err) {
console.error(MD, err);
return undefined;
});
}
/**
* Create a link selector dialog from a raw html.
* @param {string} html
* @returns {JQueryPromise<string[]>}
*/
function linkSelector(html) {
var def = $.Deferred();
// Create the content of the dialog
var dialog = document.createElement('div');
dialog.id = 'md-targets-fetcher-linkselector-dialog';
dialog.title = 'MassDelete - LinkSelector';
dialog.style.cssText = 'max-height: 80vh; max-width: 90vw;';
var dialogHeader = document.createElement('div');
dialogHeader.style.padding = '0.5em';
dialogHeader.innerHTML =
'リンクを選択してください(通常クリック: 選択、SHIFTクリック: 選択解除;リンク先は開きません)<br>' +
'<b>黄緑色のリンク</b>は削除/復帰対象にできないページへのリンクです(クリックするとリンク先が開きます)';
dialog.appendChild(dialogHeader);
var dialogBody = document.createElement('div');
dialogBody.style.cssText = 'border: 1px solid silver; padding: 0.5em; background: white; max-height: 60vh; overflow-y: scroll;';
dialogBody.innerHTML = html;
dialog.appendChild(dialogBody);
var dialogFooter = document.createElement('div');
dialogFooter.style.padding = '0.5em';
dialogFooter.appendChild(document.createTextNode('選択済みページ数: '));
var linkCnt = document.createElement('span');
linkCnt.id = 'md-targets-fetcher-linkselector-dialog-linkcount';
linkCnt.textContent = '0';
dialog.appendChild(dialogFooter);
dialogFooter.appendChild(linkCnt);
var endpoint = '^https?:' + mw.config.get('wgServer');
var regex = {
article: new RegExp(endpoint + mw.config.get('wgArticlePath').replace('$1', '([^#?]+)')), // '/wiki/PAGENAME'
script: new RegExp(endpoint + mw.config.get('wgScript') + '\\?title=([^#&]+)') // '/w/index.php?title=PAGENAME'
};
// Loop all anchors
/** @type {Object.<string, HTMLAnchorElement[]>} */
var links = {};
/** @type {string[]} */
var pages = [];
var invalidLinkClass = 'md-targets-fetcher-linkselector-dialog-invalidlink';
var selectedLinkClass = 'md-targets-fetcher-linkselector-dialog-selectedlink';
Array.prototype.forEach.call(dialogBody.getElementsByTagName('a'), /** @param {HTMLAnchorElement} a */ function(a) {
var href = a.href;
if (!href || a.role === 'button') {
a.classList.add(invalidLinkClass);
return;
}
a.target = '_blank';
var m, pagetitle;
if ((m = regex.article.exec(href))) {
pagetitle = m[1];
} else if ((m = regex.script.exec(href))) {
pagetitle = m[1];
} else {
a.classList.add(invalidLinkClass);
return;
}
pagetitle = decodeURIComponent(pagetitle);
try {
var Title = new mw.Title(pagetitle);
if (Title.getNamespaceId() === -1) {
a.classList.add(invalidLinkClass);
return;
}
pagetitle = Title.getPrefixedText().replace(/ /g, '_');
}
catch (err) {
a.classList.add(invalidLinkClass);
return;
}
if (links[pagetitle]) {
links[pagetitle].push(a);
} else {
links[pagetitle] = [a];
}
a.addEventListener('click', function(e) {
if (e.shiftKey) {
e.preventDefault();
if (this.classList.contains(selectedLinkClass)) {
links[pagetitle].forEach(function(l) {
l.classList.remove(selectedLinkClass);
});
pages = pages.filter(function(p) { return p !== pagetitle; });
linkCnt.textContent = pages.length.toString();
}
} else if (!e.ctrlKey) {
e.preventDefault();
if (!this.classList.contains(selectedLinkClass)) {
links[pagetitle].forEach(function(l) {
l.classList.add(selectedLinkClass);
});
pages.push(pagetitle);
linkCnt.textContent = pages.length.toString();
}
}
});
});
var $dialog = $(dialog);
mw.hook('wikipage.content').fire($dialog);
$dialog.dialog({
resizable: false,
draggable: true,
height: 'auto',
// @ts-ignore
width: $(window).width() * 0.8,
position: {
my: 'center',
at: 'center',
of: window
},
modal: true,
buttons: [{
text: '選択終了',
click: function() {
$(this).dialog('close');
}
}],
close: function() {
def.resolve(pages);
$(this).dialog('destroy').remove();
}
});
return def.promise();
}
/**
* Get the delete reason dropdown as an HTMLSelectElement.
* @returns {JQueryPromise<HTMLSelectElement|undefined>}
*/
function getDeleteReasonDropdown() {
var msgName = 'deletereason-dropdown';
var query = function() {
return api.getMessages([msgName])
.then(function(res) {
return res && res[msgName] ? res[msgName] : undefined;
}).catch(function(code, err) {
console.error(MD, err);
return undefined;
});
};
return query().then(function(msg) {
if (!msg) return;
var wrapper = document.createElement('select');
var other = document.createElement('option');
other.value = '';
other.textContent = 'その他の理由';
wrapper.appendChild(other);
var regex = /(\*+)([^*]+)/g;
var m, optgroup;
while ((m = regex.exec(msg))) {
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;
});
}
/**
* @typedef Progress
* @type {Object.<string, {list: HTMLLIElement; title: HTMLAnchorElement; progress: HTMLSpanElement;}>}
*/
/**
* Perform MassDelete.
* @param {Progress} progressObj
* @param {{
* action: string;
* reason: string;
* tags: string;
* watchlist: string;
* watchlistexpiry: string|undefined;
* formatversion: string;
* }} deleteParams
*/
function massDelete(progressObj, deleteParams) {
var pagetitles = Object.keys(progressObj);
var i = 0;
/**
* @param {object} params
* @returns {JQueryPromise<undefined|string>} Error message on failure, otherwise undefined
*/
var req = function(params) {
return api.postWithToken('csrf', params)
.then(function(res) {
console.log(MD, res);
return;
}).catch(function(code, err) {
console.log(MD, err);
// @ts-ignore
return err && err.error && err.error.info ? err.error.info : code;
});
};
/**
* Sleep 1 second and return undefined. For debugging mode.
* @param {object} params Not used
* @returns {JQueryPromise<undefined>}
*/
var dev = function(params) {
var def = $.Deferred();
setTimeout(function() {
def.resolve();
}, 1000);
return def.promise();
};
var execute = debuggingMode ? dev : req;
var deletePages = function() {
var title = pagetitles[i];
var params = $.extend(true, deleteParams, {title: title});
execute(params).then(function(err) {
progressObj[title].progress.innerHTML = '';
if (err) {
progressObj[title].progress.appendChild(getIcon('failed'));
progressObj[title].progress.appendChild(document.createTextNode(' 失敗 (' + err + ')'));
} else {
progressObj[title].progress.appendChild(getIcon('done'));
progressObj[title].progress.appendChild(document.createTextNode(' 成功'));
}
if (pagetitles[++i]) deletePages();
});
};
deletePages();
}
})();
//</nowiki>