プロジェクト:ウィキ技術部/スクリプト開発/trunk/MassDelete.js

/********************************************************************************************************\
 * 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>