コンテンツにスキップ

英文维基 | 中文维基 | 日文维基 | 草榴社区

「利用者:Dragoniez/scripts/AN Reporter.js」の版間の差分

削除された内容 追加された内容
v7.5.1: Bug fix
v8.2.0: Emigrate to ES6
 
(同じ利用者による、間の20版が非表示)
1行目: 1行目:
"use strict";
/************************************
/*********************************************************************************\
* AN Reporter (ANR) *
AN Reporter
* Author: Dragoniez *
@author [[User:Dragoniez]]
* Version: 7.5.1 *
@version 8.2.0
************************************/
@see https://github.com/Dr4goniez/wiki-gadgets/blob/main/src/ANReporter.ts
\*********************************************************************************/
//<nowiki>
//<nowiki>
/* global mw, $, OO */

(() => {
// ******************** CONFIGS ********************
// ******************************************************************************************

// Across-the-board variables
/* Config
/** The script name. */
anrConfig: {
const ANR = 'AN Reporter';
predefinedReasons: {},
const ANI = 'Wikipedia:管理者伝言板/投稿ブロック';
addToWatchlist: true,
const ANS = 'Wikipedia:管理者伝言板/投稿ブロック/ソックパペット';
headerColor: '#FEC493',
const AN3RR = 'Wikipedia:管理者伝言板/3RR';
backgroundColor: '#FFF0E4',
/**
portletlinkPosition: 'skin-dependent',
* This variable being a string means that we're in a debugging mode. (cf. {@link Reporter.collectData})
fontSize: 'skin-dependent',
*/
dropdownFontSize: 'skin-dependent'
const ANTEST = false;
} */
/**

* Format the `ANTEST` variable to a processable page name.
if (typeof anrConfig === 'undefined') var anrConfig = {};
* @param toWikipedia Whether to format to a page name in the Wikipedia namespace, defaulted to `false`.
if (!anrConfig.predefinedReasons) anrConfig.predefinedReasons = {};
* @returns Always `false` if `ANTEST` is set to `false`, otherwise a formatted page name.
if (!anrConfig.headerColor) anrConfig.headerColor = '#FEC493';
*/
if (!anrConfig.backgroundColor) anrConfig.backgroundColor = '#FFF0E4';
const formatANTEST = (toWikipedia = false) => {
if (!anrConfig.portletlinkPosition) {
if (typeof ANTEST === 'string') {
switch(mw.config.get('skin')) {
return toWikipedia ? eval(ANTEST) : '利用者:DragoTest/test/WP' + ANTEST;
case 'vector':
case 'vector-2022':
}
else {
anrConfig.portletlinkPosition = 'p-views';
break;
return false;
case 'minerva':
}
};
anrConfig.portletlinkPosition = 'p-personal';
break;
/**
* Whether to use the library on testwiki.
default: // monobook, timeless, or something else
*/
anrConfig.portletlinkPosition = 'p-cactions';
const useDevLibrary = false;
const ad = ' ([[利用者:Dragoniez/scripts/AN_Reporter|AN Reporter]])';
let lib;
let mwString;
let idList;
// ******************************************************************************************
// Main functions
/** Initialize the script. */
function init() {
// Is the user autoconfirmed?
if ((mw.config.get('wgUserGroups') || []).indexOf('autoconfirmed') === -1) {
mw.notify('あなたは自動承認されていません。AN Reporterを終了します。', { type: 'warn' });
return;
}
// Shouldn't run on API pages
if (location.href.indexOf('/api.php') !== -1) {
return;
}
/** Whether the user is on the config page. */
const onConfig = mw.config.get('wgNamespaceNumber') === -1 && /^(ANReporterConfig|ANRC)$/i.test(mw.config.get('wgTitle'));
// Load the library and dependent modules, then go on to the main procedure
loadLibrary(useDevLibrary).then((libReady) => {
if (!libReady)
return;
// Main procedure
if (onConfig) {
// If on the config page, create the interface after loading dependent modules
$(loadConfigInterface); // Show a 'now loading' message as soon as the DOM gets ready
const modules = [
'mediawiki.user', // mw.user.options
'oojs-ui',
'oojs-ui.styles.icons-editing-core',
'oojs-ui.styles.icons-moderation',
'mediawiki.api', // mw.Api().saveOption
];
$.when(mw.loader.using(modules), $.ready).then(() => {
createStyleTag(Config.merge());
createConfigInterface();
});
}
else {
// If not on the config page, create a portlet link to open the ANR dialog after loading dependent modules
const modules = [
'mediawiki.String', // IdList
'mediawiki.user', // mw.user.options
'mediawiki.util', // addPortletLink
'mediawiki.api', // API queries
'mediawiki.Title', // lib
'jquery.ui',
];
$.when(mw.loader.using(modules), mw.loader.getScript('https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.full.js'), $.ready).then((require) => {
mwString = require(modules[0]);
const portlet = createPortletLink();
if (!portlet) {
console.error(`${ANR}: ポートレットリンクの作成に失敗しました。`);
return;
}
createStyleTag(Config.merge());
$('head').append('<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.css">');
idList = new IdList();
portlet.addEventListener('click', Reporter.new);
}).catch((...err) => {
console.warn(err);
mw.notify(ANR + ': モジュールの読み込みに失敗しました。', { type: 'error' });
});
}
});
}
}
/**
}
* Load the library.
if (!anrConfig.fontSize) {
* @param dev Whether to load the dev version of the library.
switch(mw.config.get('skin')) {
case 'vector':
* @returns
*/
case 'vector-2022':
function loadLibrary(dev = false) {
case 'minerva':
anrConfig.fontSize = '80%';
const libName = 'ext.gadget.WpLibExtra' + (dev ? 'Dev' : '');
break;
const loadLocal = () => {
return mw.loader.using(libName)
case 'monobook':
anrConfig.fontSize = '110%';
.then((require) => {
break;
lib = require(libName);
if (typeof (lib && lib.version) !== 'string') { // Validate the library
case 'timeless':
console.error(`${ANR}: ライブラリの読み込みに失敗しました。`);
anrConfig.fontSize = '90%';
break;
return false;
default:
}
anrConfig.fontSize = '80%';
return true;
})
.catch((...err) => {
console.error(err);
return false;
});
};
if (dev) {
return mw.loader.getScript('https://test.wikipedia.org/w/load.php?modules=' + libName).then(loadLocal).catch((...err) => {
console.error(err);
return false;
});
}
else {
return loadLocal();
}
}
}
/**
}
* Get the first heading and content body, replacing the latter with a 'now loading' message.
if (!anrConfig.dropdownFontSize) {
* @returns
switch(mw.config.get('skin')) {
case 'vector':
*/
function loadConfigInterface() {
case 'vector-2022':
case 'minerva':
// Change the document's title
document.title = 'ANReporterConfig' + ' - ' + mw.config.get('wgSiteName');
anrConfig.dropdownFontSize = '0.9em';
break;
// Get the first heading and content body
case 'monobook':
const $heading = $('.mw-first-heading');
anrConfig.dropdownFontSize = '1.03em';
const $content = $('.mw-body-content');
break;
if (!$heading.length || !$content.length) {
return { $heading: null, $content: null };
case 'timeless':
}
anrConfig.dropdownFontSize = '0.94em';
break;
// Set up the elements
$heading.text(ANR + 'の設定');
default:
$content.empty().append(document.createTextNode('インターフェースを読み込み中'), getImage('load', 'margin-left: 0.5em;'));
anrConfig.dropdownFontSize = '0.9em';
return { $heading, $content };
}
}
/**
}
* Create the config interface.

* @returns
// ******************** SCRIPT BODY ********************
*/

(function(){ // Create a function scope
function createConfigInterface() {
const { $heading, $content } = loadConfigInterface();

if (!$heading || !$content) {
// ******************** VARIABLES ********************
mw.notify('インターフェースの読み込みに失敗しました。', { type: 'error', autoHide: false });

return;
// Debugging Mode
}
var DebugMode = {
// Create a config container
'scriptAd': false, // 'AN Reporter Experimental' if true
const $container = $('<div>').prop('id', 'anrc-container');
'editTarget': false, // 'User:Dragoniez/test' if true
$content.empty().append($container);
'portletLink': false,
// Create the config body
'causeIntentionalError': false,
new Config($container);
'library': false, // Load local dragoLib if true
'drPreviewSections': 'tarSectionsS' // I, S, 3RR, SubpagedLTA
};

var ScriptAd = ' ([[User:Dragoniez/scripts/AN Reporter|' + (DebugMode.scriptAd ? 'AN Reporter Experimental]])' : 'AN Reporter]])'),
PortletLinkText = DebugMode.portletLink ? '報告β' : '報告',
DeveloperLink = '<a href="' + mw.util.getUrl('User talk:Dragoniez/scripts/AN Reporter') + '" target="_blank">開発者</a>',
Library = DebugMode.library ?
'http://127.0.0.1:5500/dragoLib/dragoLib.js' :
'//ja-two.iwiki.icu/w/index.php?title=User:Dragoniez/scripts/dragoLib.js&action=raw&ctype=text/javascript';

// Page names
var ANI = 'Wikipedia:管理者伝言板/投稿ブロック',
ANS = 'Wikipedia:管理者伝言板/投稿ブロック/ソックパペット',
AN3RR = 'Wikipedia:管理者伝言板/3RR',
Iccic = ANS + '/Iccic',
ISECHIKA = ANS + '/いせちか',
KAGE = ANS + '/影武者',
KIYOSHIMA = ANS + '/清島達郎',
SHINJU = ANS + '/真珠王子',
VIP = 'Wikipedia:進行中の荒らし行為',
TEST = '利用者:Dragoniez/test';

/**
* Object to store logids for usernames {user1: logid, user2: logid...}
*/
var Logids = {};

// Related to dialog creation
var userDiv, // What to append when the 'add' button is hit
userCnt = 1; // *ID number of the elements in the appended userDiv
var MainDialogButtons = [{ // Buttons
text: '報告',
click: report
}, {
text: 'プレビュー',
click: preview
}, {
text: '閉じる',
click: function() {
$(this).dialog('close');
}
}
/** Class to create/manipulate the config interface. */
}];
class Config {

/**
// ******************** DOM READY FUNCTION ********************
* Merge and retrieve the ANReporter config.

* @param getDefault If `true`, get the default config. (Default: `false`)
// Wait for the required dependencies to be ready
* @returns
$.when(
* @requires mediawiki.user
$.getScript('https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js'),
*/
$.getScript(Library),
static merge(getDefault = false) {
mw.loader.using('jquery.ui'),
// Default config
$.ready
const cfg = {
).then(function(){
reasons: [],

blockCheck: true,
// Load/Append CSS
duplicateCheck: true,
$('head').append('<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css">'); // For Select2
watchUser: false,
$('head').append(
'<style>' +
watchExpiry: 'infinity',
headerColor: '#FEC493',
'.select2-selection__rendered {' +
'padding: 1px 2px;' +
backgroundColor: '#FFF0E4',
'font-size: 1em;' +
portletlinkPosition: ''
'line-height: normal !important;' +
};
'}' +
if (getDefault) {
'.select2-results__option,' +
return cfg;
'.select2-results__group {' +
}
'padding: 1px 8px;' +
// Objectify the user config
'font-size: ' + anrConfig.dropdownFontSize + ';' +
const strCfg = mw.user.options.get(this.key) || '{}';
'margin: 0;' +
let userCfg;
'}' +
try {
userCfg = JSON.parse(strCfg);
'.select2-container,' +
'.select2-selection--single {' +
}
'height: auto !important;' +
catch (err) {
'}' +
console.warn(err);
'.anr-dialog-label {' +
return cfg;
}
// Merge the configs
return Object.assign(cfg, userCfg);
}
/**
* @param $container The container in which to create config options.
* @requires mediawiki.user
* @requires oojs-ui
* @requires oojs-ui.styles.icons-editing-core
* @requires oojs-ui.styles.icons-moderation
* @requires mediawiki.api - Used to save the config
*/
constructor($container) {
// Transparent overlay of the container used to make elements in it unclickable
this.$overlay = $('<div>').prop('id', 'anrc-container-overlay').hide();
$container.after(this.$overlay);
// Get config
const cfg = Config.merge();
// Fieldset that stores config options
this.fieldset = new OO.ui.FieldsetLayout({
label: 'ダイアログ設定',
id: 'anrc-options'
});
// Create config options
this.reasons = new OO.ui.MultilineTextInputWidget({
id: 'anrc-reasons',
placeholder: '理由ごとに改行',
rows: 8,
value: cfg.reasons.join('\n')
});
this.blockCheck = new OO.ui.CheckboxInputWidget({
id: 'anrc-blockcheck',
selected: cfg.blockCheck
});
this.duplicateCheck = new OO.ui.CheckboxInputWidget({
id: 'anrc-duplicatecheck',
selected: cfg.duplicateCheck
});
this.watchUser = new OO.ui.CheckboxInputWidget({
id: 'anrc-watchuser',
selected: cfg.watchUser
});
this.watchExpiry = new OO.ui.DropdownWidget({
id: 'anrc-watchexpiry',
menu: {
items: [
new OO.ui.MenuOptionWidget({
data: 'infinity',
label: '無期限'
}),
new OO.ui.MenuOptionWidget({
data: '1 week',
label: '1週間'
}),
new OO.ui.MenuOptionWidget({
data: '2 weeks',
label: '2週間'
}),
new OO.ui.MenuOptionWidget({
data: '1 month',
label: '1か月'
}),
new OO.ui.MenuOptionWidget({
data: '3 months',
label: '3か月'
}),
new OO.ui.MenuOptionWidget({
data: '6 months',
label: '6か月'
}),
new OO.ui.MenuOptionWidget({
data: '1 year',
label: '1年'
}),
]
}
});
this.watchExpiry.getMenu().selectItemByData(cfg.watchExpiry);
this.headerColor = new OO.ui.TextInputWidget({
id: 'anrc-headercolor',
value: cfg.headerColor,
placeholder: 'カラー名またはHEXコードを入力'
});
this.backgroundColor = new OO.ui.TextInputWidget({
id: 'anrc-backgroundcolor',
value: cfg.backgroundColor,
placeholder: 'カラー名またはHEXコードを入力'
});
this.portletlinkPosition = new OO.ui.TextInputWidget({
id: 'anrc-portletlinkposition',
value: cfg.portletlinkPosition,
placeholder: '「報告」リンクの生成位置を随意入力'
});
// Add the config options to the fieldset
this.fieldset.addItems([
new OO.ui.FieldLayout(this.reasons, {
label: '定型理由',
align: 'top',
help: '登録した定型理由はドロップダウンからコピーできます。'
}),
new OO.ui.FieldLayout(this.blockCheck, {
label: '報告前にブロック状態をチェック',
align: 'inline'
}),
new OO.ui.FieldLayout(this.duplicateCheck, {
label: '報告前に重複報告をチェック',
align: 'inline'
}),
new OO.ui.FieldLayout(this.watchUser, {
label: '報告対象者をウォッチ',
align: 'inline'
}),
new OO.ui.FieldLayout(this.watchExpiry, {
label: 'ウォッチ期間',
align: 'top'
}),
new OO.ui.FieldLayout(this.headerColor, {
label: 'ヘッダー色',
align: 'top',
help: new OO.ui.HtmlSnippet('ダイアログのヘッダー色を指定 (見本: ' +
'<span id="anrc-headercolor-demo" class="anrc-colordemo">ヘッダー色</span>' +
')'),
helpInline: true
}),
new OO.ui.FieldLayout(this.backgroundColor, {
label: '背景色',
align: 'top',
help: new OO.ui.HtmlSnippet('ダイアログの背景色を指定 (見本: ' +
'<span id="anrc-backgroundcolor-demo" class="anrc-colordemo">背景色</span>' +
')'),
helpInline: true
}),
new OO.ui.FieldLayout(this.portletlinkPosition, {
label: 'ポートレットID (上級)',
align: 'top',
help: new OO.ui.HtmlSnippet('<a href="https://doc.wikimedia.org/mediawiki-core/REL1_41/js/#!/api/mw.util-method-addPortletLink" target="_blank">mw.util.addPortletLink</a>の' +
'<code style="font-family: inherit;">portletId</code>を指定します。未指定または値が無効の場合、使用中のスキンに応じて自動的にリンクの生成位置が決定されます。')
}),
]);
// Append the fieldset to the container (do this here and get DOM elements in it)
$container.append(this.fieldset.$element);
const $headerColorDemo = $('#anrc-headercolor-demo').css('background-color', cfg.headerColor);
const $backgroundColorDemo = $('#anrc-backgroundcolor-demo').css('background-color', cfg.backgroundColor);
// Event listeners
let headerColorTimeout;
this.headerColor.$input.off('input').on('input', function () {
// Change the background color of span that demonstrates the color of the dialog header
clearTimeout(headerColorTimeout);
headerColorTimeout = setTimeout(() => {
$headerColorDemo.css('background-color', this.value);
}, 500);
});
let backgroundColorTimeout;
this.backgroundColor.$input.off('input').on('input', function () {
// Change the background color of span that demonstrates the color of the dialog body
clearTimeout(backgroundColorTimeout);
backgroundColorTimeout = setTimeout(() => {
$backgroundColorDemo.css('background-color', this.value);
}, 500);
});
// Buttons
const $buttonGroup1 = $('<div>').addClass('anrc-buttonwrapper');
const resetButton = new OO.ui.ButtonWidget({
label: 'リセット',
id: 'anrc-reset',
icon: 'undo',
flags: 'destructive'
});
resetButton.$element.off('click').on('click', () => {
this.reset();
});
$buttonGroup1.append(resetButton.$element);
const $buttonGroup2 = $('<div>').addClass('anrc-buttonwrapper');
this.saveButton = new OO.ui.ButtonWidget({
label: '設定を保存',
id: 'anrc-save',
icon: 'bookmarkOutline',
flags: ['primary', 'progressive']
});
this.saveButton.$element.off('click').on('click', () => {
this.save();
});
$buttonGroup2.append(this.saveButton.$element);
// Append the buttons to the container
$container.append($buttonGroup1, $buttonGroup2);
}
/**
* Reset the options to their default values.
*/
reset() {
OO.ui.confirm('設定をリセットしますか?').then((confirmed) => {
if (!confirmed) {
mw.notify('キャンセルしました。');
return;
}
const defaultCfg = Config.merge(true);
this.reasons.setValue('');
this.blockCheck.setSelected(defaultCfg.blockCheck);
this.duplicateCheck.setSelected(defaultCfg.duplicateCheck);
this.watchUser.setSelected(defaultCfg.watchUser);
this.watchExpiry.getMenu().selectItemByData(defaultCfg.watchExpiry);
this.headerColor.setValue(defaultCfg.headerColor).$input.trigger('input');
this.backgroundColor.setValue(defaultCfg.backgroundColor).$input.trigger('input');
this.portletlinkPosition.setValue('');
mw.notify('設定をリセットしました。', { type: 'success' });
});
}
/**
* Set the visibility of the overlay div and toggle accesibility to DOM elements in the config body.
* @param show
*/
setOverlay(show) {
this.$overlay.toggle(show);
}
/**
* Save the config.
* @requires mediawiki.api
*/
save() {
this.setOverlay(true);
// Change the save button's label
const $label = $('<span>');
$label.append(getImage('load', 'margin-right: 1em;'));
const textNode = document.createTextNode('設定を保存しています...');
$label.append(textNode);
this.saveButton.setIcon(null).setLabel($label);
// Get config
const reasons = this.reasons.getValue().split('\n').reduce((acc, r) => {
const rsn = lib.clean(r);
if (rsn && !acc.includes(rsn)) {
acc.push(rsn);
}
return acc;
}, []);
this.reasons.setValue(reasons.join('\n'));
const cfg = {
reasons,
blockCheck: this.blockCheck.isSelected(),
duplicateCheck: this.duplicateCheck.isSelected(),
watchUser: this.watchUser.isSelected(),
watchExpiry: this.watchExpiry.getMenu().findSelectedItem().getData(),
headerColor: this.headerColor.getValue(),
backgroundColor: this.backgroundColor.getValue(),
portletlinkPosition: this.portletlinkPosition.getValue()
};
const strCfg = JSON.stringify(cfg);
// Save config
new mw.Api().saveOption(Config.key, strCfg)
.then(() => {
mw.user.options.set(Config.key, strCfg);
return null;
})
.catch((code, err) => {
console.warn(err);
return code;
})
.then((err) => {
if (err) {
mw.notify(`保存に失敗しました。(${err})`, { type: 'error' });
}
else {
mw.notify('保存しました。', { type: 'success' });
}
this.saveButton.setIcon('bookmarkOutline').setLabel('設定を保存');
this.setOverlay(false);
});
}
}
/**
* The key of `mw.user.options`.
*/
Config.key = 'userjs-anreporter';
/**
* Create a Reporter portlet link.
* @returns The Reporter portlet link.
*/
function createPortletLink() {
const cfg = Config.merge();
let portletlinkPosition = '';
if (cfg.portletlinkPosition) {
if (document.getElementById(cfg.portletlinkPosition)) {
portletlinkPosition = cfg.portletlinkPosition;
}
else {
mw.notify(`AN Reporter: "${cfg.portletlinkPosition}" はポートレットリンクの生成位置として不正なIDです。`, { type: 'error' });
}
}
if (!portletlinkPosition) {
switch (mw.config.get('skin')) {
case 'vector':
case 'vector-2022':
portletlinkPosition = 'p-views';
break;
case 'minerva':
portletlinkPosition = 'p-personal';
break;
default: // monobook, timeless, or something else
portletlinkPosition = 'p-cactions';
}
}
const portlet = mw.util.addPortletLink(portletlinkPosition, '#', '報告', 'ca-anr', '管理者伝言板に利用者を報告');
return portlet || null;
}
/**
* Create a /<style> tag for the script.
*/
function createStyleTag(cfg) {
let fontSize;
let select2FontSize;
switch (mw.config.get('skin')) {
case 'vector':
case 'vector-2022':
case 'minerva':
fontSize = '80%';
select2FontSize = '0.9em';
break;
case 'monobook':
fontSize = '110%';
select2FontSize = '1.03em';
break;
case 'timeless':
fontSize = '90%';
select2FontSize = '0.94em';
break;
default:
fontSize = '80%';
select2FontSize = '0.9em';
}
const style = document.createElement('style');
style.textContent =
// Config
'#anrc-container {' +
'position: relative;' +
'}' +
'#anrc-container-overlay {' + // Overlay of the config body, used to make elements in it unclickable
'width: 100%;' +
'height: 100%;' +
'position: absolute;' +
'top: 0;' +
'left: 0;' +
'z-index: 10;' +
'}' +
'#anrc-options {' + // Border around fieldset
'padding: 1em;' +
'margin-bottom: 1em;' +
'border: 1px solid silver;' +
'}' +
'.anrc-colordemo {' + // Demo color span, change inline to inline-block
'display: inline-block;' +
'display: inline-block;' +
'width: 8ch;' +
'border: 1px solid silver;' +
'}' +
'}' +
'.anr-dialog-select,' +
'.anrc-buttonwrapper:not(:last-child),' + // Margin below buttons
'.anr-dialog-input {' +
'#anr-dialog-progress-field tr:not(:last-child) {' +
'border: 1px solid #d3d3d3;' +
'margin-bottom: 0.5em;' +
'}' +
// Dialog
'.anr-dialog {' +
'font-size: ' + fontSize + ';' +
'}' +
'.anr-dialog hr {' +
'margin: 0.8em 0;' +
'background-color: #ccc;' +
'}' +
'.anr-dialog input[type="text"],' +
'.anr-dialog textarea,' +
'.anr-dialog select {' +
'border: 1px solid #777;' +
'border-radius: 1%;' +
'border-radius: 1%;' +
'background-color: white;' +
'background-color: white;' +
'padding: 2px 4px;' +
'padding: 2px 4px;' +
'}' +
'box-sizing: border-box;' +
'.anr-dialog-button {' +
'}' +
'color: black;' +
'.anr-dialog input[type="button"],' +
'font-weight: normal;' +
'#anr-dialog-configlink {' +
'border: 1px solid #d3d3d3;' +
'display: inline-block;' +
'margin-left: auto;' +
'margin-right: 0;' +
'cursor: pointer;' +
'padding: 1px 6px;' +
'border: 1px solid #777;' +
'border-radius: 3px;' +
'background-color: #f8f9fa;' +
'box-shadow: 1px 1px #cccccc;' +
'box-sizing: border-box;' +
'}' +
'.anr-dialog input[type="button"]:hover,' +
'#anr-dialog-configlink:hover {' +
'background-color: white;' +
'background-color: white;' +
'padding: 0.2em 0.5em;' +
'}' +
'border-radius: 10%;' +
'#anr-dialog-configlink-wrapper {' +
'}' +
'text-align: right;' +
'.anr-dialog-textarea {' +
'}' +
'#anr-dialog-configlink > span {' +
'vertical-align: middle;' +
'line-height: initial;' +
'}' +
'.anr-hidden {' + // Used to show/hide elements on the dialog (by Reporter.toggle)
'display: none;' +
'}' +
'#anr-dialog-preview-content,' +
'#anr-dialog-drpreview-content {' +
'padding: 1em;' +
'}' +
'#anr-dialog-optionfield,' + // The immediate child of #anr-dialog-content
'#anr-dialog-progress-field {' +
'padding: 1em;' +
'margin: 0;' +
'border: 1px solid #ccc;' +
'}' +
'#anr-dialog-optionfield > legend,' +
'#anr-dialog-progress-field > legend {' +
'font-weight: bold;' +
'padding-bottom: 0;' +
'}' +
'.anr-option-row:not(:last-child) {' + // Margin below every option row
'margin-bottom: 0.15em;' +
'}' +
'.anr-option-row > .anr-option-row-inner:not(.anr-hidden):first-child {' +
'margin-top: 0.15em;' +
'}' +
'.anr-option-userpane-wrapper {' +
'position: relative;' +
'}' +
'.anr-option-userpane-overlay {' +
'width: 100%;' +
'width: 100%;' +
'box-sizing: border-box;' +
'height: 100%;' +
'}' +
'position: absolute;' +
'.anr-dialog-needmargin {' +
'top: 0;' +
'left: 0;' +
'z-index: 10;' +
'}' +
'.anr-option-row-withselect2 {' +
'margin: 0.3em 0;' +
'margin: 0.3em 0;' +
'}' +
'}' +
'#anr-modal-dialog input {' +
'.anr-option-label {' + // The label div of a row
'margin-right: 0.3em;' +
'margin-right: 1em;' +
'float: left;' + // For a juxtaposed div to fill the remaining space
'}' +
'</style>'
'}' +
'.anr-option-wrapper {' +
);
'overflow: hidden;' + // Implicit width of 100% (for the child element below)

'}' +
// Run the script only if the user is autoconfirmed and the page is not an edit page
'#anr-option-reason, ' +
if (dragoLib.inGroup('autoconfirmed') && mw.config.get('wgAction') !== 'edit') {
// Add a portletlink for ANR
'#anr-option-comment,' +
'.anr-juxtaposed {' + // Assigned by Reporter.wrapElement.
$(mw.util.addPortletLink(anrConfig.portletlinkPosition, '#', PortletLinkText, 'ca-anr', '管理者伝言板に利用者を報告', null, '#ca-move')).click(openAnrDialog);
'width: 100%;' + // Fill the remaining space ("float" and "overflow" are essential for this to work)
}
'}' +

'.select2-container,' + // Set up the font size of select2 options
});
'.anr-select2 .select2-selection--single {' +

'height: auto !important;' +
// ******************** MAIN FUNCTIONS ********************
'}' +

'.anr-select2 .select2-selection__rendered {' +
function openAnrDialog(e) {
'padding: 1px 2px;' +
e.preventDefault();
'font-size: 1em;' +

'line-height: normal !important;' +
// The whole html contour
'}' +
var ModalHtml =
'.anr-select2 .select2-results__option,' +
'<div id="anr-modal-dialog" title="AN Reporter" style="max-height: 80vh;">' +
'<div id="anr-modal-header">' +
'.anr-select2 .select2-results__group {' +
'<h2>利用者を報告</h2>' +
'padding: 1px 8px;' +
'</div>' +
'font-size: ' + select2FontSize + ';' +
'<div id="anr-modal-body">' +
'margin: 0;' +
'<form>' +
'}' +
'<div id="anr-target-div" class="anr-dialog-needmargin">' +
'.anr-disabledanchor {' + // Disabled anchor
'pointer: none;' +
'<label for="anr-target-options" id="anr-target-options-label" class="anr-dialog-label">報告先</label>' +
'<select id="anr-target-options" class="anr-dialog-select">' +
'pointer-events: none;' +
'<option selected disabled hidden>選択してください</option>' +
'color: gray;' +
'<option>' + ANI + '</option>' +
'text-decoration: line-through;' +
'<option>' + ANS + '</option>' +
'}' +
'<option>' + AN3RR + '</option>' +
'.anr-option-usertype {' + // UserAN type selector in user pane
'</select>' +
'float: right;' +
'<div id="anr-target-pagelink-div" style="display: none;">' +
'margin-left: 0.3em;' +
'}' +
'<label class="anr-emptylabel anr-dialog-label" for="anr-target-pagelink"></label>' +
'<a id="anr-target-pagelink" href="" target="_blank">報告先を確認</a>' +
'.anr-option-invalidid,' +
'</div>' +
'.anr-option-usertype-none {' +
'</div>' +
'border: 2px solid red;' +
'<div id="anr-section-i-div" class="anr-dialog-needmargin" style="display: none;">' +
'border-radius: 3px;' +
'<label for="anr-section-i-select" class="anr-dialog-label">節</label>' +
'}' +
'<select id="anr-section-i-select" class="anr-dialog-select">' +
'.anr-option-removable > .anr-option-label {' + // Change cursor for the label of a user pane that's removable
'cursor: pointer;' +
'<option selected disabled hidden class="anr-section-options-initial">選択してください</option>' +
'<option id="anr-section-i-options-date"></option>' +
'}' +
'<option>不適切な利用者名</option>' +
'.anr-option-removable > .anr-option-label:hover {' +
'<option>公開アカウント</option>' +
'background-color: #80ccff;' + // Bluish on hover
'}' +
'<option>公開プロキシ・ゾンビマシン・ボット・不特定多数</option>' +
'.anr-checkbox {' +
'<option>犯罪行為またはその疑いのある投稿</option>' +
'</select>' +
'margin-right: 0.5em;' +
'</div>' +
'}' +
'.anr-dialog label {' + // Get 'vertical-align' to work, ensuring itself as a block element
'<div id="anr-section-s-div" class="anr-dialog-needmargin" style="display: none;">' +
'<label for="anr-section-s-select" class="anr-dialog-label">節</label>' +
'display: inline-block;' +
'<select id="anr-section-s-select" class="anr-dialog-select">' +
'}' +
'<option selected disabled hidden class="anr-section-options-initial">選択してください</option>' +
'.anr-dialog label > .anr-checkbox,' +
'.anr-dialog label > .anr-checkbox-label {' +
'<optgroup label="系列が立てられていないもの">' +
'<option>著作権侵害・犯罪予告</option>' +
'vertical-align: middle;' +
'<option>名誉毀損・なりすまし・個人情報</option>' +
'}' +
'<option>妨害編集・いたずら</option>' +
'.anr-option-hideuser > label {' +
'<option>その他</option>' +
'margin-left: 0.2em;' +
'</optgroup>' +
'}' +
'<optgroup id="anr-section-s-lta" label="LTA">' +
'.anr-option-blockstatus > a,' +
// getSectionTitles()
'#anr-dialog-progress-error-message {' +
'</optgroup>' +
'color: mediumvioletred;' +
'</select>' +
'}' +
'</div>' +
'#anr-dialog-progress-field img {' +
'<div id="anr-user-div" class="anr-dialog-needmargin">' +
'margin: 0 0.5em;' +
'<div id="anr-user1-div">' +
'}' +
'<div id="anr-user1-input-div">' +
'#anr-dialog-progress-field ul {' +
'<label for="anr-user1-input" class="anr-dialog-label">利用者</label>' +
'margin-top: 0;' +
'}' +
'<input id="anr-user1-input" class="anr-dialog-input" style="width: 34ch;">' +
'<select disabled id="anr-user1-select" class="anr-dialog-select">' +
'#anr-dialog-preview-body > div,' +
'<option class="anr-opt-UNL">UNL</option>' +
'#anr-dialog-drpreview-body > div {' +
'<option class="anr-opt-User2">User2</option>' +
'border: 1px solid silver;' +
'<option class="anr-opt-IP2">IP2</option>' +
'padding: 0.2em 0.5em;' +
'<option class="anr-opt-logid">logid</option>' +
'background: white;' +
'<option class="anr-opt-diff">diff</option>' +
'}' +
'#anr-dialog-preview-body .autocomment a {' + // Change the color of the section link in summary
'<option selected class="anr-opt-none">none</option>' +
'</select>' +
'color: gray;' +
'</div>' +
'}' +
// Dialog colors
'<div id="anr-user1-checkbox-div" style="display: none;">' +
'<label class="anr-emptylabel anr-dialog-label"></label>' +
'.anr-dialog.ui-dialog-content,' +
'<input type="checkbox" id="anr-user1-checkbox">' +
'.anr-dialog.ui-corner-all,' +
'<label for="anr-user1-checkbox">利用者名を隠す</label>' +
'.anr-dialog.ui-draggable,' +
'</div>' +
'.anr-dialog.ui-resizable,' +
'<div id="anr-user1-idlink-div" style="display: none;">' +
'.anr-dialog .ui-dialog-buttonpane {' +
`background: ${cfg.backgroundColor};` +
'<label for="anr-user1-idlink" class="anr-dialog-label"></label>' +
'<a id="anr-user1-idlink" href="" target="_blank"></a>' +
'}' +
'</div>' +
'.anr-dialog .ui-dialog-titlebar.ui-widget-header,' +
'<div id="anr-user1-blockstatus-div" style="display: none;">' +
'.anr-dialog .ui-dialog-titlebar-close {' +
`background: ${cfg.headerColor} !important;` +
'<label for="anr-user1-blockstatus" class="anr-dialog-label"></label>' +
'}' +
'<a id="anr-user1-blockstatus" href="" target="_blank" style="color: MediumVioletRed;">ブロックあり</a>' +
'</div>' +
'.anr-preview-duplicate {' +
'</div>' +
`background-color: ${cfg.headerColor};` +
'<div id="anr-btn-div">' +
'}';
document.head.appendChild(style);
'<button type="button" id="anr-addBtn" class="anr-dialog-button">追加</button>' +
'</div>' +
'</div>' +
'<div id="anr-viplist-div" class="anr-dialog-needmargin" style="width: 100%; display: none;">' +
'<label for="anr-viplist-select" class="anr-dialog-label">VIP</label>' +
'<select id="anr-viplist-select">' +
'<optgroup style="display: none;">' + // Adjust font size
'<option selected disabled hidden>コピーする場合は選択してください</option>' +
// getLtaList()
'</optgroup>' +
'</select>' +
'</div>' +
'<div id="anr-ltalist-div" class="anr-dialog-needmargin" style="width: 100%; display: none;">' +
'<label for="anr-ltalist-select" class="anr-dialog-label">LTA</label>' +
'<select id="anr-ltalist-select">' +
'<optgroup style="display: none;">' + // Adjust font size
'<option selected disabled hidden>コピーする場合は選択してください</option>' +
// getSectionTitles()
'</optgroup>' +
'</select>' +
'</div>' +
'<div id="anr-predefinedreasons-div" class="anr-dialog-needmargin" style="display: none;">' +
'<label for="anr-predefinedreasons-select" class="anr-dialog-label">定型文</label>' +
'<select id="anr-predefinedreasons-select">' +
'<optgroup style="display: none;">' + // Adjust font size
'<option selected value="">定型文を使用する場合は選択してください</option>' +
'</optgroup>' +
'</select>' +
'</div>' +
'<div id="anr-reason-div" class="anr-dialog-needmargin">' +
'<label for="anr-reason-text" class="anr-dialog-label">理由</label>' +
'<textarea id="anr-reason-text" class="anr-dialog-textarea" rows="6"></textarea>' +
'</div>' +
'<div id="anr-summary-div" class="anr-dialog-needmargin">' +
'<input id="anr-summary-checkbox" type="checkbox">' +
'<label for="anr-summary-checkbox">要約にコメントを追加</label><br/>' +
'<div id="anr-summary-text" style="display: none; width: 100%;">' +
'<input id="anr-summary-copypdreason" type="button" style="display: none; margin: 0.3em 0;" value="定形文をコピー"></input>' +
'<textarea class="anr-dialog-textarea" rows="3"></textarea>' +
'</div>' +
'</div>' +
'<div id="anr-checkbox-div" class="anr-dialog-needmargin">' +
'<input checked id="anr-blockstatus-checkbox" type="checkbox">' +
'<label for="anr-blockstatus-checkbox">報告前にブロック状態をチェック</label>' +
'<br>' +
'<input checked id="anr-duplicatereport-checkbox" type="checkbox">' +
'<label for="anr-duplicatereport-checkbox">報告前に重複報告をチェック</label>' +
'<br>' +
'<input checked id="anr-watchlist-checkbox" type="checkbox">' +
'<label for="anr-watchlist-checkbox">報告対象者をウォッチリストに追加</label>' +
'</div>' +
'</form>' +
'</div>' +
'</div>';

// Add the frame div to the page
$('body').append(ModalHtml);

// Show dialog
$('#anr-modal-dialog').dialog({
'dialogClass': 'anr-dialog-main',
'resizable': false,
'height': 'auto',
'width': 'auto',
'modal': true,
'open': initializeAnrDialog,
'buttons': MainDialogButtons
});

}

// Function to initialze the modal dialog
function initializeAnrDialog(){

userDiv = $('#anr-user1-div').prop('outerHTML'); // A div of the same structure is appended when the 'add' button is hit
userCnt = 1;

dragoLib.dialogCSS($('.anr-dialog-main'), anrConfig.headerColor, anrConfig.backgroundColor, anrConfig.fontSize); // Initialize the design of the dialog
getSectionTitles();
getLtaList();
getPredefinedReasons(); // Show the select box for predefined reasons

// Add to wathchlist?
if (anrConfig.addToWatchlist === false) $('#anr-watchlist-checkbox').prop('checked', false);

// Get the name of the user to report if it can be retrieved from the page
var username = mw.config.get('wgRelevantUserName'); // Note: This does not pick up IP ranges

// Workaround to pick up IP ranges
if (!username && mw.config.get('wgCanonicalSpecialPageName') === 'Contributions') {
var relUsername = $('#firstHeading').text().replace('の投稿記録', '');
if (mw.util.isIPAddress(relUsername, true)) username = relUsername;
}

// Exit function if the current user is on his/her own page or username has remained undefined or null
if (!username || username === mw.config.get('wgUserName')) return;

// Initialize the username input and type dropdown
var inputID = '#anr-user1-input',
selectID = '#anr-user1-select',
checkboxDivID = '#anr-user1-checkbox-div';

$(inputID).val(username); // Fill the input with the username
$(selectID).prop('disabled', false); // enable dropdown

if (mw.util.isIPAddress(username, true)) { // if IP

$(selectID).children('.anr-opt-UNL').prop('hidden', true);
$(selectID).children('.anr-opt-User2').prop('hidden', true);
$(selectID).children('.anr-opt-IP2').prop({'hidden': false, 'selected': true});
$(selectID).children('.anr-opt-logid').prop('hidden', true);
$(selectID).children('.anr-opt-diff').prop('hidden', true);
$(selectID).children('.anr-opt-none').prop('hidden', false);
$(checkboxDivID).css('display', 'none'); // hide 'hide username' checkbox
toggleBlockStatusLink(inputID, false, false);

} else { // if user

$(selectID).children('.anr-opt-UNL').prop({'hidden': false, 'selected': true});
$(selectID).children('.anr-opt-User2').prop('hidden', false);
$(selectID).children('.anr-opt-IP2').prop('hidden', true);
$(selectID).children('.anr-opt-logid').prop('hidden', true);
$(selectID).children('.anr-opt-diff').prop('hidden', true);
$(selectID).children('.anr-opt-none').prop('hidden', false);
$(checkboxDivID).css('display', 'block'); // show 'hide username' checkbox
toggleBlockStatusLink(inputID, false, false);

}
}
/**

* The IdList class. Administrates username-ID conversions.
}
*/

class IdList {
/**
constructor() {
* Get the section titles of WP:AN/S and WP:VIP, sort them out, and show then on the dialog as dropdown options
/**
* @returns {jQuery.Promise}
* The list object of objects, keyed by usernames.
*/
*
function getSectionTitles() {
* The usernames are formatted by `lib.clean` and underscores in it are represented by spaces.

var def = new $.Deferred();
*/
this.list = {};

var $label = $('#anr-target-options-label'); // Label of '報告先'
$label.append(dragoLib.toggleLoadingSpinner('add')); // Show a loading spinner while trying to get sections on WP:AN/S

// Get the latest revisions of the two pages including their contents
dragoLib.getLatestRevision([ANS, VIP]).then(function(lr) {

if (!lr || lr.length === 0) {
dragoLib.toggleLoadingSpinner('remove');
return def.reject(alert('WP:AN/Sのセクション情報の取得に失敗しました。ダイアログを開き直すと改善する場合があります。'));
}
}
/**

// Create an object
* Get event IDs of a user.
var pages = {
* @param username
ANS: {
* @returns
content: undefined,
*/
getIds(username) {
sections: undefined,
exclude: [
username = User.formatName(username);
for (const user in this.list) {
'系列が立てられていないもの',
'著作権侵害・犯罪予告',
if (user === username) {
'名誉毀損・なりすまし・個人情報',
const { logid, diffid } = this.list[user];
'妨害編集・いたずら',
if (typeof logid === 'number' || typeof diffid === 'number') {
'その他',
return $.Deferred().resolve(Object.assign({}, this.list[user]));
'A. 最優先',
}
'暫定A',
}
'休止中A',
'B. 優先度高',
'暫定B',
'休止中B',
'C. 優先度中',
'暫定C',
'休止中C',
'D. 優先度低',
'暫定D',
'休止中D',
'N. 未分類',
'サブページなし',
'休止中N'
]
},
VIP: {
content: undefined,
sections: undefined,
exclude: [
'記述について',
'急を要する二段階',
'配列',
'ブロック等の手段',
'このページに利用者名を加える',
'注意と選択',
'警告の方法',
'未登録(匿名・IP)ユーザーの場合',
'登録済み(ログイン)ユーザーの場合',
'警告中',
'関連項目'
]
}
}
};
return this.fetchIds(username);
}

/**
// Get the content of each page from the resnpose of the API request
* Search for the oldest account creation logid and the diffid of the newest edit of a user.
lr.forEach(function(obj) {
* @param username
if (obj.title === ANS) pages.ANS.content = obj.content;
* @returns
if (obj.title === VIP) pages.VIP.content = obj.content;
});
*/
fetchIds(username) {

// Get setion titles of WP:AN/S and add them to the dropdown
const ret = {};
return new mw.Api().get({
if (pages.ANS.content) pages.ANS.sections = dragoLib.parseContentBySection(pages.ANS.content);
var sectionTitles;
action: 'query',
list: 'logevents|usercontribs',
if (pages.ANS.sections && pages.ANS.sections.length !== 0) {
leprop: 'ids',
sectionTitles = pages.ANS.sections.filter(function(obj) {
return obj.title && $.inArray(obj.title, pages.ANS.exclude) === -1;
letype: 'newusers',
}).map(function(obj) {
ledir: 'newer',
return '<option>' + obj.title + '</option>';
lelimit: 1,
letitle: 'User:' + username,
uclimit: 1,
ucuser: username,
ucprop: 'ids',
formatversion: '2'
}).then((res) => {
let resLgev, resUc;
const logid = res && res.query && (resLgev = res.query.logevents) && resLgev[0] && resLgev[0].logid;
const diffid = res && res.query && (resUc = res.query.usercontribs) && resUc[0] && resUc[0].revid;
if (logid) {
ret.logid = logid;
}
if (diffid) {
ret.diffid = diffid;
}
if (logid || diffid) {
this.list[username] = Object.assign({}, ret);
}
return ret;
}).catch((_, err) => {
console.error(err);
return ret;
});
});
if (sectionTitles.length !== 0) $('#anr-section-s-lta').append(sectionTitles.join(''));
dragoLib.toggleLoadingSpinner('remove');
}
}
/**

// Get setion titles of WP:VIP and add them to the dropdown
* Get a username from a log/diff ID.
* @param id
if (pages.VIP.content) pages.VIP.sections = dragoLib.parseContentBySection(pages.VIP.content);
var sectionTitles;
* @param type
* @returns
if (pages.VIP.sections && pages.VIP.sections.length !== 0) {
*/
sectionTitles = pages.VIP.sections.filter(function(obj) {
getUsername(id, type) {
return obj.title && $.inArray(obj.title, pages.VIP.exclude) === -1 && obj.level === 3;
// Attempt to convert the ID without making an HTTP request
}).map(function(obj) {
const registeredUsername = this.getRegisteredUsername(id, type);
return '<option>' + obj.title + '</option>';
if (registeredUsername) {
return $.Deferred().resolve(registeredUsername);
}
// Attempt to convert the ID through an HTTP request
const fetcher = type === 'logid' ? this.scrapeUsername : this.fetchEditorName;
return fetcher(id).then((username) => {
if (username) {
username = User.formatName(username);
if (!this.list[username]) {
this.list[username] = {};
}
this.list[username][type] = id;
}
return username;
});
});
}
if (sectionTitles.length !== 0) {
/**
$('#anr-viplist-select')
* Attempt to convert an ID to a username based on the current username-ID list (no HTTP request).
.css('width', $('#anr-target-options').innerWidth())
.select2()
* @param id
* @param type
.children('optgroup').append(sectionTitles.join(''));
* @returns
$('#anr-viplist-div').css('display', 'block');
*/
dragoLib.centerDialog('#anr-modal-dialog');
getRegisteredUsername(id, type) {
for (const user in this.list) {
const relId = this.list[user][type];
if (relId === id) {
return user;
}
}
}
return null;
}
}
/**

* Scrape [[Special:Log]] by a logid and attempt to get the associated username (if any).
def.resolve();
* @param logid
});
* @returns
*/

scrapeUsername(logid) {
return def.promise();
const url = mw.util.getUrl('特別:ログ', { logid: logid.toString() });

return $.get(url)
}
.then((html) => {

const $newusers = $(html).find('.mw-logline-newusers').last();
/**
if ($newusers.length) {
* Get a list of LTA names
switch ($newusers.data('mw-logaction')) {
* @returns {jQuery.Promise}
case 'newusers/create':
*/
case 'newusers/autocreate':
function getLtaList() {
case 'newusers/create2': // Created by an existing user

case 'newusers/byemail': // Created by an existing user and password sent off
var def = new $.Deferred();
return $newusers.children('a.mw-userlink').eq(0).text();

case 'newusers/forcecreatelocal':
// Prepare an independent function as a workaround for when more than 500 names match
return $newusers.children('a').last().text().replace(/^利用者:/, '');
var ltalist = [];
default:
var query = function(apcontinue) {
}
var deferred = new $.Deferred();
}
return null;

new mw.Api().get({
})
action: 'query',
.catch((...err) => {
list: 'allpages',
console.log(err);
apprefix: 'LTA:',
return null;
apnamespace: '0',
apfilterredir: 'redirects',
aplimit: 'max',
apcontinue: apcontinue,
formatversion: '2'
}).then(function(res) {

var resPages;
if (!res || !res.query || !(resPages = res.query.allpages)) return deferred.resolve();

resPages.forEach(function(obj) {
if (obj.title !== 'LTA:' && obj.title.match(/^LTA:/) && obj.title.indexOf('/') === -1) ltalist.push(obj.title.replace(/^LTA:/, ''));
});
});
}

if (res.continue) {
/**
* Convert a revision ID to a username.
query(res.continue.apcontinue).then(function() {
deferred.resolve();
* @param diffid
* @returns
*/
fetchEditorName(diffid) {
return new mw.Api().get({
action: 'query',
prop: 'revisions',
revids: diffid,
formatversion: '2'
}).then((res) => {
const resPg = res && res.query && res.query.pages;
if (!resPg || !resPg.length)
return null;
const resRev = resPg[0].revisions;
const user = Array.isArray(resRev) && !!resRev.length && resRev[0].user;
return user || null;
}).catch((_, err) => {
console.log(err);
return null;
});
}
}
/**
* The Reporter class. Manipulates the ANR dialog.
*/
class Reporter {
/**
* Initializes a `Reporter` instance. This constructor only creates the base components of the dialog, and
* asynchronous procedures are externally handled by {@link new}.
*/
constructor() {
this.cfg = Config.merge();
Reporter.blockStatus = {}; // Reset
// Create dialog contour
this.$dialog = $('<div>');
this.$dialog
.css('max-height', '70vh')
.dialog({
dialogClass: 'anr-dialog',
title: ANR,
resizable: false,
height: 'auto',
width: 'auto',
modal: true,
close: function () {
// Destory the dialog and its contents when closed by any means
$(this).empty().dialog('destroy');
}
});
// Create button that redirects the user to the config page
const $config = $('<div>');
$config.prop('id', 'anr-dialog-configlink-wrapper');
const $configLink = $('<label>')
.prop('id', 'anr-dialog-configlink')
.append(getImage('gear', 'margin-right: 0.5em;'), $('<span>').text('設定'))
.off('click').on('click', () => {
window.open(mw.util.getUrl('特別:ANReporterConfig'), '_blank');
});
$config.append($configLink);
this.$dialog.append($config);
// Create progress container
this.$progress = $('<div>');
this.$progress
.prop('id', 'anr-dialog-progress')
.css('padding', '1em') // Will be removed in Reporter.new
.append(document.createTextNode('読み込み中'), getImage('load', 'margin-left: 0.5em;'));
this.$dialog.append(this.$progress);
// Create option container
this.$content = $('<div>');
this.$content.prop('id', 'anr-dialog-content');
this.$dialog.append(this.$content);
// Create fieldset
this.$fieldset = $('<fieldset>');
this.$fieldset.prop({
id: 'anr-dialog-optionfield',
innerHTML: '<legend>利用者を報告</legend>'
});
this.$content.append(this.$fieldset);
// Create target page option
const $pageWrapper = Reporter.createRow();
const $pageLabel = Reporter.createRowLabel($pageWrapper, '報告先');
this.$page = $('<select>');
this.$page
.addClass('anr-juxtaposed') // Important for the dropdown to fill the remaining space
.prop('innerHTML', '<option selected disabled hidden value="">選択してください</option>' +
'<option>' + ANI + '</option>' +
'<option>' + ANS + '</option>' +
'<option>' + AN3RR + '</option>')
.off('change').on('change', () => {
this.switchSectionDropdown();
});
const $pageDropdownWrapper = Reporter.wrapElement($pageWrapper, this.$page); // As important as above
this.$fieldset.append($pageWrapper);
Reporter.verticalAlign($pageLabel, $pageDropdownWrapper);
// Create target page anchor
const $pageLinkWrapper = Reporter.createRow();
Reporter.createRowLabel($pageLinkWrapper, '');
this.$pageLink = $('<a>');
this.$pageLink
.addClass('anr-disabledanchor') // Disable the anchor by default
.text('報告先を確認')
.prop('target', '_blank');
$pageLinkWrapper.append(this.$pageLink);
this.$fieldset.append($pageLinkWrapper);
// Create section option for ANI and AN3RR
this.$sectionWrapper = Reporter.createRow();
const $sectionLabel = Reporter.createRowLabel(this.$sectionWrapper, '節');
this.$section = $('<select>');
this.$section
.prop({
innerHTML: '<option selected disabled hidden value="">選択してください</option>',
disabled: true
})
.off('change').on('change', () => {
this.setPageLink();
});
const $sectionDropdownWrapper = Reporter.wrapElement(this.$sectionWrapper, this.$section);
this.$fieldset.append(this.$sectionWrapper);
Reporter.verticalAlign($sectionLabel, $sectionDropdownWrapper);
// Create section option for ANS
this.$sectionAnsWrapper = Reporter.createRow(true);
const $sectionAnsLabel = Reporter.createRowLabel(this.$sectionAnsWrapper, '節');
this.$sectionAns = $('<select>');
this.$sectionAns
.prop('innerHTML', '<option selected disabled hidden value="">選択してください</option>' +
'<optgroup label="系列が立てられていないもの">' +
'<option>著作権侵害・犯罪予告</option>' +
'<option>名誉毀損・なりすまし・個人情報</option>' +
'<option>妨害編集・いたずら</option>' +
'<option>その他</option>' +
'</optgroup>')
.off('change').on('change', () => {
this.setPageLink();
});
const $sectionAnsDropdownWrapper = Reporter.wrapElement(this.$sectionAnsWrapper, this.$sectionAns);
this.$fieldset.append(this.$sectionAnsWrapper);
Reporter.select2(this.$sectionAns);
Reporter.verticalAlign($sectionAnsLabel, $sectionAnsDropdownWrapper);
// Create an 'add' button
this.$fieldset.append(document.createElement('hr'));
const $addButtonWrapper = Reporter.createRow();
this.$addButton = $('<input>');
this.$addButton.prop('type', 'button').val('追加');
$addButtonWrapper.append(this.$addButton);
this.$fieldset.append($addButtonWrapper);
this.$fieldset.append(document.createElement('hr'));
// Create a user pane
this.Users = [
new User($addButtonWrapper, { removable: false })
];
this.$addButton.off('click').on('click', () => {
// eslint-disable-next-line @typescript-eslint/no-this-alias
const _this = this;
new User($addButtonWrapper, {
addCallback(User) {
const minWidth = User.$label.outerWidth() + 'px';
$.each([User.$wrapper, User.$hideUserWrapper, User.$idLinkWrapper, User.$blockStatusWrapper], (_, $wrapper) => {
$wrapper.children('.anr-option-label').css('min-width', minWidth);
});
_this.Users.push(User);
},
removeCallback(User) {
const idx = _this.Users.findIndex((U) => U.id === User.id);
if (idx !== -1) { // Should never be -1
const U = _this.Users[idx];
U.$wrapper.remove();
_this.Users.splice(idx, 1);
}
}
});
});
} else {
});
deferred.resolve();
const dialogWith = this.$fieldset.outerWidth(true);
this.$fieldset.css('width', dialogWith); // Assign an absolute width to $content
this.$progress.css('width', dialogWith);
Reporter.centerDialog(this.$dialog); // Recenter the dialog because the width has been changed
/**
* (Bound to the change event of a \<select> element.)
*
* Copy the selected value to the clipboard and reset the selection.
*/
const copyThenResetSelection = function () {
lib.copyToClipboard(this.value, 'ja');
this.selectedIndex = 0;
};
// Create VIP copier
this.$vipWrapper = Reporter.createRow(true);
const $vipLabel = Reporter.createRowLabel(this.$vipWrapper, 'VIP');
this.$vip = $('<select>');
this.$vip
.prop('innerHTML', '<option selected disabled hidden value="">選択してコピー</option>')
.off('change').on('change', copyThenResetSelection);
const $vipDropdownWrapper = Reporter.wrapElement(this.$vipWrapper, this.$vip);
this.$fieldset.append(this.$vipWrapper);
Reporter.select2(this.$vip);
Reporter.verticalAlign($vipLabel, $vipDropdownWrapper);
// Create LTA copier
this.$ltaWrapper = Reporter.createRow(true);
const $ltaLabel = Reporter.createRowLabel(this.$ltaWrapper, 'LTA');
this.$lta = $('<select>');
this.$lta
.prop('innerHTML', '<option selected disabled hidden value="">選択してコピー</option>')
.off('change').on('change', copyThenResetSelection);
const $ltaDropdownWrapper = Reporter.wrapElement(this.$ltaWrapper, this.$lta);
this.$fieldset.append(this.$ltaWrapper);
Reporter.select2(this.$lta);
Reporter.verticalAlign($ltaLabel, $ltaDropdownWrapper);
// Create predefined reason selector
const $predefinedWrapper = Reporter.createRow(true);
const $predefinedLabel = Reporter.createRowLabel($predefinedWrapper, '定型文');
this.$predefined = $('<select>');
this.$predefined
.prop('innerHTML', '<option selected disabled hidden value="">選択してコピー</option>')
.append($('<optgroup>')
.css('display', 'none')
.prop('innerHTML', this.cfg.reasons.map((el) => '<option>' + el + '</option>').join('')))
.off('change').on('change', copyThenResetSelection);
const $predefinedDropdownWrapper = Reporter.wrapElement($predefinedWrapper, this.$predefined);
this.$fieldset.append($predefinedWrapper);
Reporter.select2(this.$predefined);
Reporter.verticalAlign($predefinedLabel, $predefinedDropdownWrapper);
// Create reason field
const $reasonWrapper = Reporter.createRow();
Reporter.createRowLabel($reasonWrapper, '理由');
this.$reason = $('<textarea>');
this.$reason.prop({
id: 'anr-option-reason',
rows: 5,
placeholder: '署名不要'
});
$reasonWrapper.append(this.$reason);
this.$fieldset.append($reasonWrapper);
// Create "add comment" option
const addCommentElements = createLabelledCheckbox('要約にコメントを追加', { checkboxId: 'anr-option-addcomment' });
this.$addComment = addCommentElements.$checkbox;
this.$fieldset.append(addCommentElements.$wrapper);
this.$comment = $('<textarea>');
this.$comment.prop({
id: 'anr-option-comment',
rows: 2
});
addCommentElements.$wrapper.append(this.$comment);
this.$addComment.off('change').on('change', () => {
Reporter.toggle(this.$comment, this.$addComment.prop('checked'));
}).trigger('change');
// Create "block check" option
const checkBlockElements = createLabelledCheckbox('報告前にブロック状態をチェック', { checkboxId: 'anr-option-checkblock' });
this.$checkBlock = checkBlockElements.$checkbox;
this.$checkBlock.prop('checked', this.cfg.blockCheck);
this.$fieldset.append(checkBlockElements.$wrapper);
// Create "duplicate check" option
const checkDuplicatesElements = createLabelledCheckbox('報告前に重複報告をチェック', { checkboxId: 'anr-option-checkduplicates' });
this.$checkDuplicates = checkDuplicatesElements.$checkbox;
this.$checkDuplicates.prop('checked', this.cfg.duplicateCheck);
this.$fieldset.append(checkDuplicatesElements.$wrapper);
// Create "watch user" option
const watchUserElements = createLabelledCheckbox('報告対象者をウォッチ', { checkboxId: 'anr-option-watchuser' });
this.$watchUser = watchUserElements.$checkbox;
this.$watchUser.prop('checked', this.cfg.watchUser);
this.$fieldset.append(watchUserElements.$wrapper);
this.$watchExpiry = $('<select>');
this.$watchExpiry
.prop({
id: 'anr-option-watchexpiry',
innerHTML: '<option value="infinity">無期限</option>' +
'<option value="1 week">1週間</option>' +
'<option value="2 weeks">2週間</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>'
})
.val(this.cfg.watchExpiry);
const $watchExpiryWrapper = $('<div>');
$watchExpiryWrapper
.prop({ id: 'anr-option-watchexpiry-wrapper' })
.css({
marginLeft: this.$watchUser.outerWidth(true) + 'px',
marginTop: '0.3em'
})
.append(document.createTextNode('期間: '), this.$watchExpiry);
watchUserElements.$wrapper.append($watchExpiryWrapper);
this.$watchUser.off('change').on('change', () => {
Reporter.toggle($watchExpiryWrapper, this.$watchUser.prop('checked'));
}).trigger('change');
// Set all the row labels to the same width
Reporter.setWidestWidth($('.anr-option-label'));
// Make some wrappers invisible
Reporter.toggle(this.$sectionAnsWrapper, false);
Reporter.toggle(this.$vipWrapper, false);
Reporter.toggle(this.$ltaWrapper, false);
if (this.$predefined.find('option').length < 2) {
Reporter.toggle($predefinedWrapper, false);
}
}
Reporter.toggle(this.$content, false);

}).catch(function(code, err) {
deferred.resolve(console.error(err.error.info));
});

return deferred.promise();

};

query().then(function() {

if (ltalist.length === 0) return def.resolve();

ltalist = ltalist.map(function(lta) {
return '<option>' + lta + '</option>';
}).sort();
$('#anr-ltalist-select')
.css('width', $('#anr-target-options').innerWidth())
.select2()
.children('optgroup').append(ltalist.join(''));
$('#anr-ltalist-div').css('display', 'block');
dragoLib.centerDialog('#anr-modal-dialog');
def.resolve();

});

return def.promise();

}

// Function to show the select div for predefined report reasons if they're predefined
function getPredefinedReasons() {
var pdReasons = anrConfig.predefinedReasons;
if (typeof pdReasons !== 'undefined' && !$.isEmptyObject(pdReasons)) { // If the user has fixed reasons prepared

var $reasons = $('#anr-predefinedreasons-select');
$reasons.css('width', $('#anr-target-options').innerWidth()).select2();
for (var key in pdReasons) {
$reasons.children('optgroup').append('<option>' + pdReasons[key] + '</option>');
}
}
/**
$('#anr-predefinedreasons-div').css('display', 'block');
* Taken several HTML elements, set the width that is widest among the elements to all of them.
dragoLib.centerDialog('#anr-modal-dialog');
* @param $elements

* @returns The width.
}
*/
}
static setWidestWidth($elements) {

// eslint-disable-next-line @typescript-eslint/no-explicit-any
// Show/hide a button in the summary field depending on the value selected in the predefined reasons dropdown
const optionsWidths = Array.prototype.map.call($elements, (el) => el.offsetWidth // Collect the widths of all the elements
$(document).off('change', '#anr-predefinedreasons-select').on('change', '#anr-predefinedreasons-select', function() {
var selected = $(this).val();
);
const optionWidth = Math.max(...optionsWidths); // Get the max value
var $btn = $('#anr-summary-copypdreason');
$elements.css('min-width', optionWidth); // Set the value to all
if (selected) {
return optionWidth;
$btn.css('display', 'inline-block');
} else {
}
$btn.css('display', 'none');
/**
* Toggle the visibility of an element by (de)assigning the `anr-hidden` class.
}
* @param $element The element of which to toggle the visibility.
dragoLib.centerDialog('#anr-modal-dialog');
* @param show Whether to show the element.
});
* @returns The passed element.

*/
// Copy the predefined reason into the summary field when the relevant button is clicked
static toggle($element, show) {
$(document).off('click', '#anr-summary-copypdreason').on('click', '#anr-summary-copypdreason', function() {
return $element.toggleClass('anr-hidden', !show);
var $summary = $('#anr-summary-text').children('textarea'),
}
selectedVal = $('#anr-predefinedreasons-select').find('option').filter(':selected').val();
/**
$summary.val($summary.val() + selectedVal);
* Create a \<div> that works as a Reporter row.
});
* ```html

* <!-- hasSelect2: false -->
// Function to check information typed into the form
* <div class="anr-option-row"></div>
function editPrep() {
* <!-- hasSelect2: true -->

* <div class="anr-option-row-withselect2"></div>
// Get all input values and UserAN types, and check for duplicates
var users = [],
* ```
* @param hasSelect2 `false` by default.
types = [],
duplicates = [];
* @returns The created row.
*/
$('#anr-user-div :text').each(function(){ // Loop through all inputs
static createRow(hasSelect2 = false) {

const $row = $('<div>');
var type = $('#' + $(this).attr('id').replace('input', 'select')).children('option').filter(':selected').text(), // UserAN type
$row.addClass(!hasSelect2 ? 'anr-option-row' : 'anr-option-row-withselect2');
inputVal = dragoLib.trim2($(this).val()); // Username
if (!inputVal) return;
return $row;
}

var username, logid;
/**
* Create a \<div> that works as a left-aligned label.
if (type === 'logid' && (username = dragoLib.getKeyByValue(Logids, (logid = inputVal)))) { // if t=logid and the logid can be converted to a username
* ```html

* <div class="anr-option-label">labelText</div>
// If either of the username or the logid is already in the array 'users' and if they have yet to be listed as duplicates
* ```
if (($.inArray(username, users) !== -1 || $.inArray(logid, users)) !== -1 && $.inArray(username, duplicates) === -1 && $.inArray(logid, duplicates) === -1) {
* @param $appendTo The element to which to append the label.
duplicates.push(username, logid); // List both the username and the logid as duplicates
* @param labelText The text of the label (technically, the innerHTML). If an empty string is passed, `&nbsp;` is used.
* @returns The created label.
*/
static createRowLabel($appendTo, labelText) {
const $label = $('<div>');
$label.addClass('anr-option-label');
if (typeof labelText === 'string') {
$label.prop('innerHTML', labelText || '&nbsp;');
}
}
else {
} else { // If t!=logid or t=logid but a username can't be obtained
$label.append(labelText);

// If the username is already in the array 'users' (and if it hasn't been listed as a duplicate)
if ($.inArray((username = inputVal), users) !== -1 && $.inArray(username, duplicates) === -1) {
duplicates.push(username); // List the username as a duplicate
}
}
$appendTo.append($label);

return $label;
}
}
/**

* Compare the outerHeight of a row label div and that of a sibling div, and if the former is smaller than the latter,
users.push(inputVal); // Push the username into the array
types.push(type); // Push the UserAN type into the array
* assign `padding-top` to the former.
*

* Note: **Both elements must be visible when this function is called**.
});
* @param $label

// Get the name of the section to edit
* @param $sibling
*/
var pageToEdit = $('#anr-target-options').children('option').filter(':selected').text(),
static verticalAlign($label, $sibling) {
sectionToEdit = '選択してください',
reportToANS = false;
const labelHeight = $label.outerHeight();
const siblingHeight = $sibling.outerHeight();

if ($label.text() && labelHeight < siblingHeight) {
if (pageToEdit === ANI) { // If WP:AN/I is selected as the target page to edit
$label.css('padding-top', ((siblingHeight - labelHeight) / 2) + 'px');

}
sectionToEdit = $('#anr-section-i-select').children('option').filter(':selected').text();

// Update the target section for cases in which the date has changed since the date-dependent section was chosen
if (sectionToEdit.match(/^\d{4}年\d{1,2}月\d{1,2}日 - \d{1,2}日新規報告$/)) {
var sectionIDate = dragoLib.getSection5('報告', false);
sectionToEdit = sectionIDate;
$('#anr-section-i-options-date').text(sectionIDate);
}
}
/**

* Wrap a (non-block) element (next to a row label) with a div. This is for the element to fill the remaining space.
} else if (pageToEdit === ANS) { // If WP:AN/S is selected as the target page to edit
* ```html

* <div class="anr-option-row">
reportToANS = true;
* <div class="anr-option-label"></div> <!-- float: left; -->
var sectionANS = $('#anr-section-s-select').find('option').filter(':selected').text();
* <div class="anr-option-wrapper"> <!-- overflow: hidden; -->
switch(sectionANS) {
* <element class="anr-juxtaposed">...</element> <!-- width: 100%; -->
case 'Iccic系 (Iccic)':
pageToEdit = Iccic;
* </div>
sectionToEdit = '新規依頼';
* </div>
break;
* ```
* @param $appendTo The element to which to append the wrapper div.
case 'いせちか系 (ISECHIKA)':
pageToEdit = ISECHIKA;
* @param $element The element to wrap.
* @returns The wrapper div.
sectionToEdit = '新規依頼';
break;
*/
static wrapElement($appendTo, $element) {
case '影武者系(KAGE)':
pageToEdit = KAGE;
const $wrapper = $('<div>');
sectionToEdit = '新規依頼';
$wrapper.addClass('anr-option-wrapper');
break;
$element.addClass('anr-juxtaposed');
case '清島達郎系 (清島、KIYOSHIMA)':
$wrapper.append($element);
pageToEdit = KIYOSHIMA;
$appendTo.append($wrapper);
sectionToEdit = '新規依頼';
return $wrapper;
break;
case '真珠王子系(SHINJU)':
pageToEdit = SHINJU;
sectionToEdit = '新規依頼';
break;
default:
sectionToEdit = sectionANS;
}
}
/**

* Set up `select2` to a dropdown.
} else if (pageToEdit === AN3RR) { // If WP:AN/3RR is selected as the target page to edit
* @param $dropdown

sectionToEdit = '3RR';
*/
static select2($dropdown) {

$dropdown.select2({
}
width: '100%', // Without this, the right end of the dropdown overflows

dropdownCssClass: 'anr-select2' // This needs select2.full.js
// The reason of the report
});
var fixedReason = $('#anr-predefinedreasons-select').find('option').filter(':selected').val();
var reason = fixedReason + dragoLib.trim2($('#anr-reason-text').val());

// Check if necessary fields are filled
if (pageToEdit === '選択してください' || sectionToEdit === '選択してください' || reason === '' || users.length === 0) {
return alert('必須項目が入力・選択されていません');
}

// Duplicate warning
if (duplicates.length !== 0) { // If the inputs have duplicates in them
var confirmMsg =
'以下の利用者について、重複入力がある可能性があります。\n\n' + duplicates.join(', ') + '\n\n' +
'続行する場合は OK を、フォームに戻る場合は Cancel を押してください';
if (!confirm(confirmMsg)) return;
}

// If the reason doesn't contain a signature, add one
if (reason.substring(reason.length - 4) !== '~~~~') reason += '--~~~~';

// Get edit summary
var summaryLinks = types.map(function(type, i) {
var username = users[i];
switch (type) { // Get appropriate links depending on the UserAN type
case 'UNL':
case 'User2':
case 'IP2':
return '[[特別:投稿記録/' + username + '|' + username + ']]';
case 'logid':
return '[[特別:転送/logid/' + username + '|Logid/' + username + ']]';
case 'diff':
return '[[特別:差分/' + username + '|差分/' + username + ']]の投稿者';
default:
return username;
}
}
/**
}).filter(function(link, i, arr) {
* Bring a jQuery UI dialog to the center of the viewport.
return arr.indexOf(link) === i; // Remove duplicates
* @param $dialog
});
* @param absoluteCenter Whether to apply `center` instead of `top+5%`, defaulted to `false`.
if (summaryLinks.length > 5) {
*/
summaryLinks = summaryLinks.slice(0, 5).join(', ') + ', 他' + summaryLinks.slice(5).length + 'アカウント';
static centerDialog($dialog, absoluteCenter = false) {
} else {
summaryLinks = summaryLinks.join(', ');
$dialog.dialog({
position: {
}
my: absoluteCenter ? 'center' : 'top',
var customSummary = dragoLib.trim2($('#anr-summary-text').children('textarea').val()),
at: absoluteCenter ? 'center' : 'top+5%',
editSummarySection = '/*' + sectionToEdit + '*/',
of: window
editSummary = editSummarySection + '+' + summaryLinks + (customSummary ? ' - ' + customSummary : '') + ScriptAd;

// Get text to add to the page
var reportText = '',
UserAN = '{{UserAN|t=TYPE|USER}}';
users.forEach(function(user, i) {
if (i !== 0) reportText += '\n';
reportText += '\* ' + UserAN.replace('TYPE', types[i]).replace('USER', (user.indexOf('=') === -1 ? user : '1=' + user));
});
reportText += reportText.indexOf('\n') === -1 ? ' - ' + reason : '\n: ' + reason;

// Return values
return {
'users': users,
'types': types,
'pageToEdit': DebugMode.editTarget ? TEST : pageToEdit,
'sectionToEdit': sectionToEdit,
'wikiPagename': DebugMode.editTarget ? TEST + '#' + sectionToEdit : pageToEdit + '#' + sectionToEdit,
'reportToANS': reportToANS,
'editSummary': editSummary,
'reportText': reportText
};

}

// Function for the 'preview' button of the dialog
function preview() {

// Check if the necessary fields are filled and get edit information
var ep = editPrep();
if (!ep) return;

// Preview dialog contour
var previewDiv =
'<div id="anr-preview-dialog" title="AN Reporter Preview" style="max-height: 80vh;">' +
'<div id="anr-preview-header" style="padding: 0.5em;">' +
'<p id="anr-preview-loading">' +
'プレビューを読み込み中' + dragoLib.toggleLoadingSpinner('add') +
'</p>' +
'<p id="anr-preview-warning" style="display: none;">' +
'注意1: このプレビュー上のリンクは全て新しいタブで開かれます<br>' +
'注意2: 報告先が <a href="' + mw.util.getUrl('WP:AN/S#OTH') + '" target="_blank">WP:AN/S#その他</a> の場合、' +
'このプレビューには表示されませんが「他M月D日」のヘッダーは必要に応じて自動挿入されます' +
'</p>' +
'</div>' +
'<div id="anr-preview-body" style="display: none; font-size: 1.1em; padding-top: 1em; border-top: 1px solid silver;">' +
'<div id="anr-preview-text" style="border: 1px solid silver; padding: 0.2em 0.5em; background: white;">' +
// previewHtml
'</div>' +
'<div id="anr-preview-summary" style="margin-top: 0.8em; border: 1px solid silver; padding: 0.2em 0.5em; background: white;">' +
// summaryHtml
'</div>' +
'</div>' +
'</div>';

// Show preview dialog
$('body').append(previewDiv);
$('#anr-preview-dialog').dialog({
'dialogClass': 'anr-dialog-preview',
'height': 'auto',
'width': $('#content').width() * 0.8,
'modal': true,
'open': function(){

// Initialize the design of the dialog
dragoLib.dialogCSS($('.anr-dialog-preview'), anrConfig.headerColor, anrConfig.backgroundColor, anrConfig.fontSize);

// Convert text on the dialog to html
dragoLib.getParsedHtml(ep.reportText, ep.editSummary).then(function(parsed) {
if (parsed) {

var previewHtml = parsed.htmltext,
summaryHtml = parsed.htmlsummary.replace(/API/g, ep.pageToEdit);
$('#anr-preview-text').append(previewHtml);
$('#anr-preview-summary').append(summaryHtml);
$('.autocomment a').css('color', 'gray'); // Change color of section spec in summary
$('#anr-preview-dialog a').attr('target', '_blank'); // Open all links on a new tab
$('#anr-preview-body').css('display', 'block');
$('#anr-preview-loading').remove();
$('#anr-preview-warning').css('display', 'inline');
dragoLib.centerDialog('#anr-preview-dialog');

} else {
$('#anr-preview-loading').text('プレビューの読み込みに失敗しました').css('color', 'MediumVioletRed');
dragoLib.centerDialog('#anr-preview-dialog');
setTimeout(function(){
$('#anr-preview-dialog').dialog('close');
}, 5000);
}
}

});
});
}

},
/**
* Create a new Reporter dialog. This static method handles asynchronous procedures that are necessary
'buttons': [{
'text': '閉じる',
* after calling the constructor.
'click': function(){
* @param e
$(this).dialog('close');
*/
}
static new(e) {
// Cancel portletlink click event
}]
e.preventDefault();
});
// Create a Reporter dialog

const R = new Reporter();
}
// Get a username associated with the current page if any

const heading = document.querySelector('.mw-first-heading') ||
// Function for the 'report' button of the dialog
document.querySelector('.firstHeading') ||
function report() {
document.querySelector('#firstHeading');

const relevantUser = mw.config.get('wgRelevantUserName') ||
// Check if the necessary fields are filled and get edit information
mw.config.get('wgCanonicalSpecialPageName') === 'Contributions' && heading && heading.textContent && extractCidr(heading.textContent);
var ep = editPrep();
const U = R.Users[0];
if (!ep) return;
U.$input.val(relevantUser || '');
const def = U.processInputChange();
// Change dialog content
// Process additional asynchronous procedures for Reporter
$('#anr-modal-dialog')
$.when(lib.Wikitext.newFromTitle(ANS), lib.getVipList(), lib.getLtaList())
.dialog({
'width': $(this).innerWidth(), // Absolute width
.then((Wkt, vipList, ltaList) => {
'buttons': [] // Hide buttons
// Initialize the ANS section dropdown
})
if (Wkt) {
const exclude = [
.append('<div class="anr-editing">') // Append div to show edit status
'top',
.find('form').css('display', 'none'); // Hide dialog content
'系列が立てられていないもの',

'著作権侵害・犯罪予告',
// Add user pages to watchlist if the checkbox is checked
'名誉毀損・なりすまし・個人情報',
if ($('#anr-watchlist-checkbox').is(':checked')) {
'妨害編集・いたずら',
var pagenames = [], type, user;
for (var i = 0; i < ep.types.length; i++) {
'その他',
type = ep.types[i];
'A. 最優先',
user = ep.users[i];
'暫定A',
if (type === 'User2' || type === 'UNL' || type === 'IP2') {
'休止中A',
if ($.inArray('利用者:' + user, pagenames) === -1) pagenames.push('利用者:' + user);
'B. 優先度高',
} else if (type === 'logid') {
'暫定B',
var username;
'休止中B',
if ((username = dragoLib.getKeyByValue(Logids, user))) {
'C. 優先度中',
if ($.inArray('利用者:' + username, pagenames) === -1) pagenames.push('利用者:' + username);
'暫定C',
'休止中C',
'D. 優先度低',
'暫定D',
'休止中D',
'N. 未分類',
'サブページなし',
'休止中N'
];
const optgroup = document.createElement('optgroup');
optgroup.label = 'LTA';
Wkt.parseSections().forEach(({ title }) => {
if (!exclude.includes(title)) {
const option = document.createElement('option');
option.textContent = title;
optgroup.appendChild(option);
}
});
if (optgroup.querySelector('option')) {
R.$sectionAns[0].add(optgroup);
}
else {
mw.notify('WP:AN/Sのセクション情報の取得に失敗しました。節構成が変更された、またはスクリプトのバグの可能性があります。', { type: 'error' });
}
}
}
else {
mw.notify('WP:AN/Sのセクション情報の取得に失敗しました。ダイアログを開き直すと改善する場合があります。', { type: 'error' });
}
// Initialize the VIP copier dropdown
if (vipList.length) {
const optgroup = document.createElement('optgroup');
optgroup.style.display = 'none'; // Wrap with optgroup to adjust font size
vipList.forEach((vip) => {
const option = document.createElement('option');
option.textContent = vip;
option.value = '[[WP:VIP#' + vip + ']]';
optgroup.appendChild(option);
});
R.$vip[0].add(optgroup);
Reporter.toggle(R.$vipWrapper, true);
}
// Initialize the LTA copier dropdown
if (ltaList.length) {
const optgroup = document.createElement('optgroup');
optgroup.style.display = 'none'; // Wrap with optgroup to adjust font size
ltaList.forEach((lta) => {
const option = document.createElement('option');
option.textContent = lta;
option.value = '[[LTA:' + lta + ']]';
optgroup.appendChild(option);
});
R.$lta[0].add(optgroup);
Reporter.toggle(R.$ltaWrapper, true);
}
def.then(() => {
Reporter.toggle(R.$progress, false);
R.$progress.css('padding', '');
Reporter.toggle(R.$content, true);
R.setMainButtons();
});
});
}
/**
* Set the main dialog buttons.
*/
setMainButtons() {
this.$dialog.dialog({
buttons: [
{
text: '報告',
click: () => this.report()
},
{
text: 'プレビュー',
click: () => this.preview()
},
{
text: '閉じる',
click: () => this.close()
}
]
});
}
/**
* Close the Reporter dialog (will be destroyed).
*/
close() {
this.$dialog.dialog('close');
}
/**
* Get `YYYY年MM月D1日 - D2日新規依頼`, relative to the current day.
* @param getLast Whether to get the preceding section, defaulted to `false`.
* @returns
*/
static getCurrentAniSection(getLast = false) {
const d = new Date();
let subtract;
if (getLast) {
if (d.getDate() === 1 || d.getDate() === 2) {
subtract = 3;
}
else if (d.getDate() === 31) {
subtract = 6;
}
else {
subtract = 5;
}
d.setDate(d.getDate() - subtract);
}
const multiplier = Math.ceil(d.getDate() / 5); // 1 to 7
let lastDay, startDay;
if (multiplier < 6) {
lastDay = 5 * multiplier; // 5, 10, 15, 20, 25
startDay = lastDay - 4; // 1, 6, 11, 16, 21
}
else {
lastDay = Reporter.getLastDay(d.getFullYear(), d.getMonth()); // 28-31
startDay = 26;
}
}
return `${d.getFullYear()}年${d.getMonth() + 1}月${startDay}日 - ${lastDay}日新規報告`;
}
}
dragoLib.watchPages(pagenames);
/**
* Get the last day of a given month in a given year.
}
* @param year A 4-digit year.

* @param month The month as a number between 0 and 11 (January to December).
// Report
* @returns
reportUsers(ep);
*/

static getLastDay(year, month) {
}
return new Date(year, month + 1, 0).getDate();

var generateButtons = function(callback, ep) {
return [{
'text': '続行',
'click': function(){
$(this).dialog({'buttons': [] });
eval(callback); // ep is used in the callback
}
}
}, {
/**
* Get the page to which to forward the report.
'text': '戻る',
'click': function(){
* @returns
*/
$(this).find('form').css('display', 'block');
$('.anr-editing').remove();
getPage() {
$(this).dialog({
return this.$page.val() || null;
'width': 'auto',
}
/**
'buttons': MainDialogButtons
* Set an href to {@link $pageLink}. If {@link $page} is not selected, disable the anchor.
* @returns
*/
setPageLink() {
const page = this.getPage();
if (page) {
this.$pageLink
.removeClass('anr-disabledanchor')
.prop('href', mw.util.getUrl(page + (this.getSection(true) || '')));
}
else {
this.$pageLink
.addClass('anr-disabledanchor')
.prop('href', '');
}
return this;
}
/**
* Get the selected section.
* @param addHash Add '#' to the beginning when there's a value to return. (Default: `false`)
* @returns
*/
getSection(addHash = false) {
let ret = null;
switch (this.getPage()) {
case ANI:
ret = this.$section.val() || null;
break;
case ANS:
ret = this.$sectionAns.val() || null;
break;
case AN3RR:
ret = '3RR';
break;
default: // Section not selected
}
return ret && (addHash ? '#' : '') + ret;
}
/**
* Switch the section dropdown options in accordance with the selection in the page dropdown.
* This method calls {@link setPageLink} when done.
* @returns
*/
switchSectionDropdown() {
const page = this.getPage();
if (page) {
switch (page) {
case ANI:
this.$section.prop('disabled', false).empty();
addOptions(this.$section, [
{ text: '選択してください', value: '', disabled: true, selected: true, hidden: true },
{ text: Reporter.getCurrentAniSection() },
{ text: '不適切な利用者名' },
{ text: '公開アカウント' },
{ text: '公開プロキシ・ゾンビマシン・ボット・不特定多数' },
{ text: '犯罪行為またはその疑いのある投稿' }
]);
Reporter.toggle(this.$sectionWrapper, true);
Reporter.toggle(this.$sectionAnsWrapper, false);
this.setPageLink();
break;
case ANS:
this.$sectionAns.val('').trigger('change'); // For select2. This triggers `setPageLink`.
Reporter.toggle(this.$sectionWrapper, false);
Reporter.toggle(this.$sectionAnsWrapper, true);
break;
case AN3RR:
this.$section.prop({
disabled: false,
innerHTML: '<option>3RR</option>'
});
Reporter.toggle(this.$sectionWrapper, true);
Reporter.toggle(this.$sectionAnsWrapper, false);
this.setPageLink();
}
}
else {
this.$section.prop({
disabled: true,
innerHTML: '<option disabled selected hidden value="">選択してください</option>'
});
Reporter.toggle(this.$sectionWrapper, true);
Reporter.toggle(this.$sectionAnsWrapper, false);
this.setPageLink();
}
return this;
}
/**
* Evaluate a username, classify it into a type, and check the block status of the relevant user.
* @param username Automatically formatted by {@link User.formatName}.
* @returns
*/
static getBlockStatus(username) {
username = User.formatName(username);
const isIp = mw.util.isIPAddress(username, true);
const bkpara = {};
if (!username || !isIp && User.containsInvalidCharacter(username)) { // Blank or invalid
return $.Deferred().resolve({
usertype: 'other',
blocked: null
});
}
else if (Reporter.blockStatus[username]) {
return $.Deferred().resolve(Object.assign({}, Reporter.blockStatus[username]));
}
else if (isIp) {
bkpara.bkip = username;
}
else {
bkpara.bkusers = username;
}
const params = Object.assign({
action: 'query',
list: 'users|blocks',
ususers: username,
formatversion: '2'
}, bkpara);
return new mw.Api().get(params)
.then((res) => {
const resUs = res && res.query && res.query.users;
const resBl = res && res.query && res.query.blocks;
if (resUs && resBl) {
const ret = {
usertype: isIp ? 'ip' : resUs[0].userid !== void 0 ? 'user' : 'other',
blocked: !!resBl.length
};
Reporter.blockStatus[username] = Object.assign({}, ret);
return ret;
}
else {
throw new Error('APIリクエストにおける不明なエラー');
}
})
.catch((_, err) => {
console.error(err);
mw.notify('ユーザー情報の取得に失敗しました。', { type: 'error' });
return {
usertype: 'other',
blocked: null
};
});
});
}
}
// -- Methods related to the dialog buttons of "report" and "preview" --
}, {
'text': '中止',
/**
'click': function(){
* Collect option values.
$(this).dialog('close');
* @returns `null` if there's some error.
*/
collectData() {
// -- Check first for required fields --
const page = this.getPage();
const section = this.getSection();
const shiftClick = $.Event('click');
shiftClick.shiftKey = true;
let hasInvalidId = false;
const users = this.Users.reduceRight((acc, User) => {
const inputVal = User.getName();
const selectedType = User.getType();
if (!inputVal) { // Username is blank
User.$label.trigger(shiftClick); // Remove the user pane
}
else if (['logid', 'diffid'].includes(selectedType) && !/^\d+$/.test(inputVal)) { // Invalid ID
hasInvalidId = true;
}
else { // Valid
acc.push({
user: inputVal,
type: selectedType
});
}
return acc;
}, []).reverse();
let reason = this.$reason.val();
reason = lib.clean(reason.replace(/[\s-~]*$/, '')); // Remove signature (if any)
this.$reason.val(reason);
// Look for errors
const $errList = $('<ul>');
if (!page) {
$errList.append($('<li>').text('報告先のページ名が未指定'));
}
if (!section) {
$errList.append($('<li>').text('報告先のセクション名が未指定'));
}
if (!users.length) {
$errList.append($('<li>').text('報告対象者が未指定'));
}
if (hasInvalidId) {
$errList.append($('<li>').text('数字ではないID'));
}
if (!reason) {
$errList.append($('<li>').text('報告理由が未指定'));
}
const errLen = $errList.children('li').length;
if (errLen) {
const $err = $('<div>')
.text('以下のエラーを修正してください。')
.append($errList);
mw.notify($err, { type: 'error', autoHideSeconds: errLen > 2 ? 'long' : 'short' });
return null;
}
// -- Collect secondary data --
reason += '--~~~~'; // Add signature to reason
const summary = this.$addComment.prop('checked') ? lib.clean(this.$comment.val()) : '';
const blockCheck = this.$checkBlock.prop('checked');
const duplicateCheck = this.$checkDuplicates.prop('checked');
const watchUser = this.$watchUser.prop('checked');
const watch = watchUser ? this.$watchExpiry.val() : null;
// Return
return {
page: formatANTEST() || page,
section: section,
users,
reason,
summary,
blockCheck,
duplicateCheck,
watch
};
}
}
}];
/**
* Perform bilateral username-ID conversions against usernames collected from the Reporter dialog, in order to:
};
* - find multiple occurrences of the same user in different formats (returned as `users` property), and

* - create an array of all the collected usernames, in which user-denoting IDs are "sanitized" into real usernames
async function reportUsers(ep) {
* (returned as `info` property).

*
// Check the block status of the reportees if the checkbox is checked
var blocked = [];
* @param data
* @returns
if ($('#anr-blockstatus-checkbox').is(':checked')) {
*/
blocked = await preeditBlockStatusQuery(ep); // Query who's blocked
processIds(data) {
}
// Loop through all the input values for sanitization

const registeredUsers = [];
if (blocked.length !== 0) { // If any of the reportees is blocked
const promisifiedInfo = data.users.map((obj) => {

// Update dialog buttons
switch (obj.type) {
case 'UNL':
$('#anr-modal-dialog').dialog({
'buttons': generateButtons('reportUsers2(ep)', ep)
case 'User2':
registeredUsers.push(obj.user);
});
// Proceed without break

// eslint-disable-next-line no-fallthrough
} else { // If no one is blocked
reportUsers2(ep);
case 'IP2':
return $.Deferred().resolve(obj.user); // Username-denoting by nature
}
case 'none':
}
return $.Deferred().resolve(null); // This isn't a username-denoting value

case 'logid':
async function reportUsers2(ep) {
case 'diffid':

// Conversion of IDs to username-denoting values (null on failure)
// Check duplicate reports if the checkbox is checked
return idList.getUsername(parseInt(obj.user), obj.type);
if ($('#anr-duplicatereport-checkbox').is(':checked')) {
var dr = await preeditDuplicateReportQuery(ep);
}
}
}, []);
return $.when(...promisifiedInfo).then((...info) => {
if (typeof dr === 'undefined') var dr = {};
// Create an array of arrays of duplicate usernames

const checkedIndexes = [];
switch(dr.wikitext) {
const users = info.reduce((acc, username, i, arr) => {
case null: // Error occurred
return;
if (!username)
case undefined: // The checkbox is unchecked or no duplicate report found
return acc;
// Usernames just converted from IDs aren't in the registeredUsers array yet
reportUsers3(ep);
if (!registeredUsers.includes(username))
return;
registeredUsers.push(username);
default: // Possible duplicate reports present
// Create an inner array as necessary

// Update dialog buttons
if (!checkedIndexes.includes(i)) {
$('#anr-modal-dialog').dialog({
const ret = [];
for (let j = i; j < arr.length; j++) { // Check array elements from the current index
'buttons': [{
if (j === i && j !== arr.lastIndexOf(username) || j !== i && arr[j] === username) { // Found a duplicate username
'text': '確認',
'click': function() {
checkedIndexes.push(j);
previewDuplicateReports(dr.wikitext, dr.dupUsernames);
const { user, type } = data.users[j];
const dup = type === 'logid' ? 'Logid/' + user : // If the username is displayed as an ID on the dialog,
type === 'diffid' ? '差分/' + user : // list the user with the ID as a duplicate
user;
if (!ret.includes(dup))
ret.push(dup);
}
}
if (ret.length)
acc.push(ret);
}
}
}]
return acc;
.concat(generateButtons('reportUsers3(ep)', ep))
}, []);
// Return
if (registeredUsers.length) {
// If any registered user is to be reported, convert their names to IDs by calling IdList.getIds,
// which registers username-ID correspondences into its class property of "list". We do this here
// because we want to know associated IDs for every user for the sake of checkDuplicateReports.
const deferreds = registeredUsers.reduce((acc, u, i, arr) => {
if (arr.indexOf(u) === i) { // If not duplicate
acc.push(idList.getIds(u));
}
return acc;
}, []);
return $.when(...deferreds).then(() => ({ users, info })); // Resolve ProcessedIds when username -> ID conversions are done
}
else {
return { users, info };
}
});
});
}

}
/**
* Create the report text and summary out of the return values of {@link collectData} and {@link processIds}.

* @param data The (null-proof) return value of {@link collectData}.
}
* @param info The partial return value of {@link processIds}.

* @returns The report text and summary.
async function reportUsers3(ep) {
*/

createReport(data, info) {
// Get the latest revision
// Create UserANs and summary links
var msg = '<p>最新版を取得しています' + dragoLib.toggleLoadingSpinner('add') + '</p>';
const templates = [];
$('.anr-editing').append(msg);
const links = [];
var lr = await dragoLib.getLatestRevision(ep.pageToEdit);
for (let i = 0; i < data.users.length; i++) {
if (!lr || lr.length === 0) return queryFailed(ep);
lr = lr[0];
const obj = data.users[i];
const Temp = new lib.Template('UserAN').addArgs([

{
// Parse the content by section and get the content and number of the section to which the report will be added
name: 't',
var sections = dragoLib.parseContentBySection(lr.content);
value: obj.type
var sectionNum, wikitext;
},
if (sections && sections.length !== 0) {
sections.some(function(obj) {
{
if (obj.title === ep.sectionToEdit) {
name: '1',
sectionNum = obj.index;
value: obj.user,
wikitext = obj.content;
forceUnnamed: true
return true;
}
]);
templates.push(Temp);
switch (obj.type) {
case 'UNL':
case 'User2':
case 'IP2':
// If this username is the first occurrence in the "info" array in which IDs have been converted to usernames
if (info.indexOf(info[i]) === i) {
links.push(`[[特別:投稿記録/${obj.user}|${obj.user}]]`);
}
break;
case 'logid':
// The ID failed to be converted to a username or the converted username is the first occurrence and not a duplicate
if (info[i] === null || info.indexOf(info[i]) === i) {
links.push(`[[特別:転送/logid/${obj.user}|Logid/${obj.user}]]`);
}
break;
case 'diffid':
if (info[i] === null || info.indexOf(info[i]) === i) {
links.push(`[[特別:差分/${obj.user}|差分/${obj.user}]]の投稿者`);
}
break;
default: // none
if (info[i] === null || info.indexOf(info[i]) === i) {
links.push(obj.user);
}
}
}
}
});
// Create the report text
let text = '';
}
templates.forEach((Temp, i) => {
if (!sectionNum) return sectionNotFound(ep);
text += `${i === 0 ? '' : '\n'}* ${Temp.toString()}`;

// Create report text
});
text += templates.length > 1 ? '\n:' : ' - ';
var reportText;
text += data.reason;
if (ep.reportToANS) { // If the target is WP:AN/S
// Create the report summary

let summary = '';
// Add div if the target section is 'その他' but lacks div for the current date
const fixed = [
var miscHeader = '{{bgcolor|#eee|{{Visible anchor|他' + dragoLib.today() + '}}|div}}';
`/*${data.section}*/+`,
if (ep.sectionToEdit === 'その他' && wikitext.indexOf(miscHeader) === -1) ep.reportText = '; ' + miscHeader + '\n\n' + ep.reportText;
ad

// Get the report text to submit
];
const fixedLen = fixed.join('').length; // The length of the fixed summary
var sockInfo = dragoLib.findTemplates(wikitext, 'SockInfo/M'); // Array
const summaryComment = data.summary ? ' - ' + data.summary : '';
if (sockInfo.length === 1) { // One section on WP:AN/S should have one SockInfo
for (let i = 0; i < Math.min(5, links.length); i++) { // Loop the reportee links
sockInfo = sockInfo[0];
const userLinks = links.slice(0, i + 1).join(', ') + // The first "i + 1" links
var sockInfoNoClosure = dragoLib.trim2(sockInfo.substring(0, sockInfo.length - 2));
(links.slice(i + 1).length ? `, ほか${links.slice(i + 1).length}アカウント` : ''); // and the number of the remaining links if any
reportText = wikitext.replace(sockInfo, sockInfoNoClosure + '\n\n' + ep.reportText + '\n\n}}');
const totalLen = fixedLen + userLinks.length + summaryComment.length; // The total length of the summary
} else { // There's a problem with SockInfo
msg = // Show error and quit the procedure
if (i === 0 && totalLen > 500) { // The summary exceeds the word count limit only with the first link
const maxLen = 500 - fixedLen - userLinks.length;
dragoLib.toggleLoadingSpinner('remove') +
const trunc = summaryComment.slice(0, maxLen - 3) + '...'; // Truncate the additional comment
'<p style="color: MediumVioletRed">取得に失敗しました</p>' +
const augFixed = fixed.slice(); // Copy the fixed summary array
'<p>報告先セクションに{{SockInfo/M}}がない、または複数個あるため報告場所を特定できませんでした</p>' +
augFixed.splice(1, 0, userLinks, trunc); // Augment the copied array by inserting the first user link and the truncated comment
manualEdit(ep);
summary = augFixed.join(''); // Join the array elements and that will be the whole of the summary
$('.anr-editing').append(msg);
editDone(ep, true);
break;
return;
}
else if (totalLen > 500) {
// The word count limit is exceeded when we add a non-first link
// In this case, use the summary created in the last loop
break;
}
else { // If the word count limit isn't exceeded in the first loop, the code always reaches this block
const augFixed = fixed.slice();
augFixed.splice(1, 0, userLinks, summaryComment);
summary = augFixed.join('');
}
}
return { text, summary };
}
}
// The 3 methods above are used both in "report" and "preview" (the former needs additional functions, and they are defined below).

/**
} else { // If the target is WP:AN/I or WP:AN/3RR
* Preview the report.
reportText = dragoLib.trim2(wikitext) + '\n\n' + ep.reportText;
}
* @returns
*/

preview() {
msg = '<p style="color: MediumSeaGreen">取得に成功しました</p>' +
const data = this.collectData();
'<p>報告を試みています' + dragoLib.toggleLoadingSpinner('move') + '</p>';
if (!data)
$('.anr-editing').append(msg);
return;

const $preview = $('<div>')
// Edit
const result = await new mw.Api().post({
.css({
action: 'edit',
maxHeight: '70vh',
title: ep.pageToEdit,
maxWidth: '80vw'
section: sectionNum,
})
text: reportText,
.dialog({
dialogClass: 'anr-dialog anr-dialog-preview',
basetimestamp: lr.basetimestamp,
starttimestamp: lr.curtimestamp,
title: ANR + ' - Preview',
summary: ep.editSummary,
height: 'auto',
width: 'auto',
token: DebugMode.causeIntentionalError ? '' : mw.user.tokens.get('csrfToken'),
format: 'json'
modal: true,
}).then(function(res) {
close: function () {
// Destory the dialog and its contents when closed by any means
if (res && res.edit) {
if (res.edit.result === 'Success') return true;
$(this).empty().dialog('destroy');
}
});
const $previewContent = $('<div>')
.prop('id', 'anr-dialog-preview-content')
.text('読み込み中')
.append(getImage('load', 'margin-left: 0.5em;'));
$preview.append($previewContent);
this.processIds(data).then(({ info }) => {
const { text, summary } = this.createReport(data, info);
new mw.Api().post({
action: 'parse',
title: data.page,
text,
summary,
prop: 'text|modules|jsconfigvars',
pst: true,
disablelimitreport: true,
disableeditsection: true,
disabletoc: true,
contentmodel: 'wikitext',
formatversion: '2'
}).then((res) => {
const resParse = res && res.parse;
const content = resParse.text;
const comment = resParse.parsedsummary;
if (content && comment) {
if (resParse.modules.length) {
mw.loader.load(resParse.modules);
}
if (resParse.modulestyles.length) {
mw.loader.load(resParse.modulestyles);
}
const $header = $('<div>')
.prop('id', 'anr-dialog-preview-header')
.append($('<p>' +
'注意1: このプレビュー上のリンクは全て新しいタブで開かれます<br>' +
'注意2: 報告先が <a href="' + mw.util.getUrl('WP:AN/S#OTH') + '" target="_blank">WP:AN/S#その他</a> の場合、' +
'このプレビューには表示されませんが「他M月D日」のヘッダーは必要に応じて自動挿入されます' +
'</p>'));
const $body = $('<div>').prop('id', 'anr-dialog-preview-body');
$body.append($(content), $('<div>')
.css('margin-top', '0.8em')
.append($(comment)));
$previewContent
.empty()
.append($header, $('<hr>'), $body)
.find('a').prop('target', '_blank'); // Open all links on a new tab
$preview.dialog({
buttons: [
{
text: '閉じる',
click: () => {
$preview.dialog('close');
}
}
]
});
Reporter.centerDialog($preview, true);
}
else {
throw new Error('action=parseのエラー');
}
}).catch((_, err) => {
console.log(err);
$previewContent
.empty()
.text('プレビューの読み込みに失敗しました。')
.append(getImage('cross', 'margin-left: 0.5em;'));
$preview.dialog({
buttons: [
{
text: '閉じる',
click: () => {
$preview.dialog('close');
}
}
]
});
});
});
}
}
return false;
/**
* Submit the report.
}).catch(function(code, err) {
return err.error.info;
* @returns
});
*/
report() {
dragoLib.toggleLoadingSpinner('remove');
// Collect dialog data and check for errors
switch(result) {
case true: // Edit succeeded
const data = this.collectData();
if (!data)
$('.anr-editing').append('<p style="color: MediumSeaGreen">報告が完了しました</p>');
editDone(ep, false);
return;
break;
// Create progress dialog
case false: // Unknown error occurred
this.$progress.empty();
Reporter.toggle(this.$content, false);
msg = '<p style="color: MediumVioletRed">不明なエラーが発生しました</p>' + manualEdit(ep);
$('.anr-editing').append(msg);
Reporter.toggle(this.$progress, true);
editDone(ep, true);
this.$dialog.dialog({ buttons: [] });
const $progressField = $('<fieldset>').prop('id', 'anr-dialog-progress-field');
break;
this.$progress.append($progressField);
default: // Known error occurred
$progressField.append($('<legend>').text('報告の進捗'), $('<div>').prop('id', 'anr-dialog-progress-icons').append(getImage('check'), document.createTextNode('処理通過'), getImage('exclamation'), document.createTextNode('要確認'), getImage('bar'), document.createTextNode('スキップ'), getImage('clock'), document.createTextNode('待機中'), getImage('cross'), document.createTextNode('処理失敗')), $('<hr>'));
msg = '<p style="color: MediumVioletRed">報告に失敗しました</p>' +
'<p>詳細: ' + result + '</p>' +
const $progressTable = $('<table>');
manualEdit(ep);
$progressField.append($progressTable);
$('.anr-editing').append(msg);
const $dupUsersRow = $('<tr>');
editDone(ep, true);
$progressTable.append($dupUsersRow);
const $dupUsersLabel = $('<td>').append(getImage('load'));
}
const $dupUsersText = $('<td>').text('利用者名重複');

$dupUsersRow.append($dupUsersLabel, $dupUsersText);
}
const $dupUsersListRow = $('<tr>');

$progressTable.append($dupUsersListRow);
/**
const $dupUsersListText = $('<td>');
* Function to check block status before edit
$dupUsersListRow.append($('<td>'), $dupUsersListText);
* @returns {Array} [] if no one is blocked, [user1, user2...] if someone is blocked
const $dupUsersList = $('<ul>');
*/
$dupUsersListText.append($dupUsersList);
async function preeditBlockStatusQuery(ep) {
Reporter.toggle($dupUsersListRow, false);

const $blockedUsersRow = $('<tr>');
// Can't check block status if the input values are only of t=diff or t=none
$progressTable.append($blockedUsersRow);
var proceed;
const $blockedUsersLabel = $('<td>').append(getImage(data.blockCheck ? 'clock' : 'bar'));
for (let i = 0; i < ep.types.length; i++) {
const $blockedUsersText = $('<td>').text('既存ブロック');
if (ep.types[i] !== 'diff' && ep.types[i] !== 'none') {
$blockedUsersRow.append($blockedUsersLabel, $blockedUsersText);
proceed = true;
break;
const $blockedUsersListRow = $('<tr>');
$progressTable.append($blockedUsersListRow);
const $blockedUsersListText = $('<td>');
$blockedUsersListRow.append($('<td>'), $blockedUsersListText);
const $blockedUsersList = $('<ul>');
$blockedUsersListText.append($blockedUsersList);
Reporter.toggle($blockedUsersListRow, false);
const $dupReportsRow = $('<tr>');
$progressTable.append($dupReportsRow);
const $dupReportsLabel = $('<td>').append(getImage(data.duplicateCheck ? 'clock' : 'bar'));
const $dupReportsText = $('<td>').text('重複報告');
$dupReportsRow.append($dupReportsLabel, $dupReportsText);
const $dupReportsButtonRow = $('<tr>');
$progressTable.append($dupReportsButtonRow);
const $dupReportsButtonCell = $('<td>');
$dupReportsButtonRow.append($('<td>'), $dupReportsButtonCell);
Reporter.toggle($dupReportsButtonRow, false);
const $reportRow = $('<tr>');
$progressTable.append($reportRow);
const $reportLabel = $('<td>').append(getImage('clock'));
const $reportText = $('<td>').text('報告');
$reportRow.append($reportLabel, $reportText);
const $errorWrapper = $('<div>').prop('id', 'anr-dialog-progress-error');
$progressField.append($errorWrapper);
const $errorMessage = $('<p>').prop('id', 'anr-dialog-progress-error-message');
const $errorReportText = $('<textarea>');
$errorReportText.prop({
id: 'anr-dialog-progress-error-text',
rows: 5,
disabled: true
});
const $errorReportSummary = $('<textarea>');
$errorReportSummary.prop({
id: 'anr-dialog-progress-error-summary',
rows: 3,
disabled: true
});
$errorWrapper.append($('<hr>'), $errorMessage, $('<label>').text('手動編集用'), $errorReportText, $errorReportSummary);
Reporter.toggle($errorWrapper, false);
// Process IDs that need to be converted to usernames
this.processIds(data).then(({ users, info }) => {
// Post-procedure of username-ID conversions and duplicate username check
(() => {
const def = $.Deferred();
if (!users.length) {
$dupUsersLabel.empty().append(getImage('check'));
def.resolve(true);
}
else {
$dupUsersLabel.empty().append(getImage('exclamation'));
users.forEach((arr) => {
const $li = $('<li>').text(arr.join(', '));
$dupUsersList.append($li);
});
Reporter.toggle($dupUsersListRow, true);
this.$dialog.dialog({
buttons: [
{
text: '続行',
click: () => {
Reporter.toggle($dupUsersListRow, false);
this.$dialog.dialog({ buttons: [] });
def.resolve(true);
}
},
{
text: '戻る',
click: () => {
Reporter.toggle(this.$progress, false);
Reporter.toggle(this.$content, true);
this.setMainButtons();
def.resolve(false);
}
},
{
text: '閉じる',
click: () => {
this.close();
def.resolve(false);
}
}
]
});
mw.notify('利用者名の重複を検出しました。', { type: 'warn' });
}
return def.promise();
})()
.then((duplicateUsernamesResolved) => {
if (!duplicateUsernamesResolved)
return;
const deferreds = [];
if (data.blockCheck && data.duplicateCheck) {
$blockedUsersLabel.empty().append(getImage('load'));
$dupReportsLabel.empty().append(getImage('load'));
deferreds.push(this.checkBlocks(info), this.checkDuplicateReports(data, info));
}
else if (data.blockCheck) {
$blockedUsersLabel.empty().append(getImage('load'));
deferreds.push(this.checkBlocks(info), $.Deferred().resolve(void 0));
}
else if (data.duplicateCheck) {
$dupReportsLabel.empty().append(getImage('load'));
deferreds.push($.Deferred().resolve(void 0), this.checkDuplicateReports(data, info));
}
else {
deferreds.push($.Deferred().resolve(void 0), $.Deferred().resolve(void 0));
}
$.when(...deferreds).then((blocked, dup) => {
(() => {
const def = $.Deferred();
let stop = false;
// Process the result of block check
if (blocked) {
if (!blocked.length) {
$blockedUsersLabel.empty().append(getImage('check'));
}
else {
$blockedUsersLabel.empty().append(getImage('exclamation'));
blocked.forEach((user) => {
$blockedUsersList.append($('<li>').append($('<a>')
.prop({
href: mw.util.getUrl('特別:投稿記録/' + user),
target: '_blank'
})
.text(user)));
});
Reporter.toggle($blockedUsersListRow, true);
mw.notify('ブロック済みの利用者を検出しました。', { type: 'warn' });
stop = true;
}
}
// Process the result of duplicate report check
if (dup instanceof lib.Wikitext) {
$dupReportsLabel.empty().append(getImage('check'));
}
else if (typeof dup === 'string') {
$dupReportsLabel.empty().append(getImage('exclamation'));
$dupReportsButtonCell.append($('<input>')
.prop('type', 'button')
.val('確認')
.off('click').on('click', () => {
this.previewDuplicateReports(data, dup);
}));
Reporter.toggle($dupReportsButtonRow, true);
mw.notify('重複報告を検出しました。', { type: 'warn' });
stop = true;
}
else if (dup === false || dup === null) {
$dupReportsLabel.empty().append(getImage('cross'));
mw.notify(`重複報告チェックに失敗しました。(${dup === null ? '通信エラー' : 'ページ非存在'})`, { type: 'error' });
stop = true;
}
if (!stop && dup instanceof lib.Wikitext) {
def.resolve(dup);
}
else if (!stop) {
def.resolve(void 0);
}
else {
this.$dialog.dialog({
buttons: [
{
text: '続行',
click: () => {
Reporter.toggle($blockedUsersListRow, false);
Reporter.toggle($dupReportsButtonRow, false);
this.$dialog.dialog({ buttons: [] });
def.resolve(void 0);
}
},
{
text: '戻る',
click: () => {
Reporter.toggle(this.$progress, false);
Reporter.toggle(this.$content, true);
this.setMainButtons();
def.reject(); // Reject
}
},
{
text: '閉じる',
click: () => {
this.close();
def.reject(); // Reject
}
}
]
});
}
return def.promise();
})()
.done((inheritedWkt) => {
// Recheck the target section for ANI
if (data.page === ANI && data.section === Reporter.getCurrentAniSection(true)) { // If the date range has changed since it was selected in the dropdown
this.switchSectionDropdown().$section.prop('selectedIndex', 1); // Update selection
data.section = this.getSection();
}
// Create report text and summary
$reportLabel.empty().append(getImage('load'));
const report = this.createReport(data, info);
let reportText = report.text;
const summary = report.summary;
/**
* Handle an error thrown on an edit attempt.
* @param err
*/
const errorHandler = (err) => {
console.error(err);
$reportLabel.empty().append(getImage('cross'));
$errorMessage.text(err.message);
$errorReportText.val(reportText);
$errorReportSummary.val(summary.replace(new RegExp(mw.util.escapeRegExp(ad) + '$'), ''));
Reporter.toggle($errorWrapper, true);
mw.notify('報告に失敗しました。', { type: 'error' });
this.$dialog.dialog({
buttons: [
{
text: '再試行',
click: () => this.report()
},
{
text: '報告先',
click: () => {
window.open(this.$pageLink.prop('href'), '_blank');
}
},
{
text: '戻る',
click: () => {
Reporter.toggle(this.$progress, false);
Reporter.toggle(this.$content, true);
this.setMainButtons();
}
},
{
text: '閉じる',
click: () => {
this.close();
}
}
]
});
};
// Create a Wikitext instance for the report
const $when = inheritedWkt ?
$.when($.Deferred().resolve(inheritedWkt)) :
$.when(lib.Wikitext.newFromTitle(data.page));
$when.then((Wkt) => {
// Validate the Wikitext instance
if (Wkt === false) {
throw new Error(`ページ「${data.page}」が見つかりませんでした。`);
}
else if (Wkt === null) {
throw new Error('通信エラーが発生しました。');
}
// Get the index of the section to edit
let sectionIdx = -1;
let sectionContent = '';
for (const { title, index, content } of Wkt.parseSections()) {
if (title === data.section) {
sectionIdx = index;
sectionContent = content;
break;
}
}
if (sectionIdx === -1) {
throw new Error(`節「${data.section}」が見つかりませんでした。`);
}
// Create a new content for the section to edit
if (data.page === ANS || formatANTEST(true) === ANS) { // ANS
// Add div if the target section is 'その他' but lacks div for the current date
const d = new Date();
const today = (d.getMonth() + 1) + '月' + d.getDate() + '日';
const miscHeader = '{{bgcolor|#eee|{{Visible anchor|他' + today + '}}|div}}';
if (data.section === 'その他' && !sectionContent.includes(miscHeader)) {
reportText = '; ' + miscHeader + '\n\n' + reportText;
}
// Get the report text to submit
const sockInfoArr = new lib.Wikitext(sectionContent).parseTemplates({
namePredicate: (name) => name === 'SockInfo/M',
recursivePredicate: (Temp) => !Temp || Temp.getName('clean') !== 'SockInfo/M'
});
if (!sockInfoArr.length) {
throw new Error(`節「${data.section}」内にテンプレート「SockInfo/M」が存在しないため報告場所を特定できませんでした。`);
}
else if (sockInfoArr.length > 1) {
throw new Error(`節「${data.section}」内にテンプレート「SockInfo/M」が複数個あるため報告場所を特定できませんでした。`);
}
const sockInfo = sockInfoArr[0];
sectionContent = sockInfo.replaceIn(sectionContent, {
with: sockInfo.renderOriginal().replace(/\s*?\}{2}$/, '') + '\n\n' + reportText + '\n\n}}'
});
}
else { // ANI or AN3RR
sectionContent = lib.clean(sectionContent) + '\n\n' + reportText;
}
// Send action=watch requests in the background (if relevant)
this.watchUsers(data, info);
// Edit page
const { basetimestamp, curtimestamp } = Wkt.getRevision();
new mw.Api().postWithEditToken({
action: 'edit',
title: data.page,
section: sectionIdx,
text: sectionContent,
summary,
basetimestamp,
curtimestamp,
formatversion: '2'
}).then((res) => {
if (res && res.edit && res.edit.result === 'Success') {
$reportLabel.empty().append(getImage('check'));
mw.notify('報告が完了しました。', { type: 'success' });
this.$dialog.dialog({
buttons: [
{
text: '報告先',
click: () => {
window.open(this.$pageLink.prop('href'), '_blank');
}
},
{
text: '閉じる',
click: () => {
this.close();
}
}
]
});
}
else {
errorHandler(new Error('報告に失敗しました。(不明なエラー)'));
}
}).catch((code, err) => {
console.warn(err);
errorHandler(new Error(`報告に失敗しました。(${code})`));
});
}).catch(errorHandler);
});
});
});
});
}
}
}
/**
* Check the block statuses of the reportees.
if (!proceed) {
* @param userInfoArray The `info` property array of the return value of {@link processIds}.
$('.anr-editing').append('<p>ブロックチェックはスキップされました</p>');
* @returns An array of blocked users and IPs.
return [];
}
*/
checkBlocks(userInfoArray) {

const users = [];
// Update message on the dialog
const ips = [];
var msg = '<p>報告対象者のブロック情報を取得しています' + dragoLib.toggleLoadingSpinner('add') + '</p>';
for (const user of userInfoArray) {
$('.anr-editing').append(msg);
if (!user) {

// Do nothing
// Extract users and IPs from the array
}
const usersForBlockCheck = [];
for (let i = 0; i < ep.users.length; i++) {
else if (mw.util.isIPAddress(user, true)) {
const inputVal = ep.users[i];
if (!ips.includes(user))
switch(ep.types[i]) {
ips.push(user);
case 'UNL':
}
else if (User.containsInvalidCharacter(user)) {
case 'User2':
case 'IP2':
// Do nothing
}
if ($.inArray(inputVal, usersForBlockCheck) === -1) usersForBlockCheck.push(inputVal);
break;
else {
case 'logid':
if (!users.includes(user))
let username;
users.push(user);
if ((username = dragoLib.getKeyByValue(Logids, inputVal)) !== undefined) { // If the logid can be converted to a username
if ($.inArray(username, usersForBlockCheck) === -1) usersForBlockCheck.push(username);
}
}
break;
default: // Do nothing if t=diff or t=none (impossible to check block status)
}
}

// Check if any of the users is blocked
const blocked = await dragoLib.getRestricted(usersForBlockCheck);

// If any of the users is blocked
if (blocked.length !== 0) {

// Update message on the dialog
msg =
dragoLib.toggleLoadingSpinner('remove') +
'<p style="color: MediumVioletRed">ブロック済みの利用者を検出しました</p>';
$('.anr-editing').append(msg);
// Update block status links on the dialog
$('#anr-user-div :text').each(function() { // Loop through all inputs
const inputID = '#' + $(this).attr('id');
const inputVal = dragoLib.trim2($(inputID).val());
const $bsLinkDiv = $(inputID.replace('input', 'blockstatus-div'));
const $bsLink = $(inputID.replace('input', 'blockstatus'));

$bsLinkDiv.css('display', 'none'); // Temporarily hide the div
if ($.inArray(inputVal, blocked) !== -1) {
$bsLink.attr('href', mw.util.getUrl('特別:投稿記録/' + inputVal));
$bsLinkDiv.css('display', 'block');
}
}
const processUsers = (usersArr) => {
});
if (!usersArr.length) {

return $.Deferred().resolve([]);
} else {
}

// Update message on the dialog
return lib.massRequest({
msg =
action: 'query',
dragoLib.toggleLoadingSpinner('remove') +
list: 'blocks',
bkusers: usersArr,
'<p style="color: MediumSeaGreen">ブロック済みの利用者は検出されませんでした</p>';
bklimit: 'max',
$('.anr-editing').append(msg);
formatversion: '2'

}, 'bkusers')
}
.then((response) => {
return blocked;
return response.reduce((acc, res) => {

const resBk = res && res.query && res.query.blocks;
}
(resBk || []).forEach(({ user }) => {

if (user) {
/**
acc.push(user);
* Function to check duplicate reports
}
* @returns {Promise<{wikitext: string, dupUsernames: Array}>} wikitext === null if error occurs, undefined if no duplicate report is found,
});
* SECTIONTEXT to fetch preview from if there're potential duplicate reports. If SECTIONTEXT is returned, dupUsernames ===
return acc;
* [username1, username2...], without logids that can be converted to usernames.
}, []);
*/
});
async function preeditDuplicateReportQuery(ep) {
};

const processIps = (ipsArr) => {
// Update message on the dialog
if (!ipsArr.length) {
var msg = '<p>重複報告情報を取得しています' + dragoLib.toggleLoadingSpinner('add') + '</p>';
return $.Deferred().resolve([]);
$('.anr-editing').append(msg);
}

return lib.massRequest({
// The sections in which to search for duplicate reports
action: 'query',
const tarSectionsI = [
dragoLib.getSection5('報告', true),
list: 'blocks',
bkip: ipsArr,
dragoLib.getSection5('報告', false),
'不適切な利用者名',
bklimit: 1,
formatversion: '2'
'公開アカウント',
}, 'bkip', 1)
'公開プロキシ・ゾンビマシン・ボット・不特定多数',
.then((response) => {
'犯罪行為またはその疑いのある投稿'
return response.reduce((acc, res, i) => {
];
const resBk = res && res.query && res.query.blocks;
const tarSectionsS = [
if (resBk && resBk[0]) {
'著作権侵害・犯罪予告',
acc.push(ipsArr[i]);
'名誉毀損・なりすまし・個人情報',
'妨害編集・いたずら',
}
'その他'
return acc;
];
}, []);
const tarSections3RR = ['3RR'];
});
};
const tarSectionsSubpagedLTA = ['新規依頼'];
return $.when(processUsers(users), processIps(ips)).then((blockedUsers, blockedIps) => {

const blocked = blockedUsers.concat(blockedIps);
var tarSections;
// Update block status info
switch(ep.pageToEdit) {
users.concat(ips).forEach((user) => {
case ANI:
tarSections = tarSectionsI;
if (Reporter.blockStatus[user]) {
Reporter.blockStatus[user].blocked = blocked.includes(user);
break;
case ANS:
}
tarSections = tarSectionsS;
});
break;
this.Users.forEach((U) => {
U.processTypeChange(); // Toggle the visibility of block status links
case AN3RR:
tarSections = tarSections3RR;
});
break;
return blocked;
case Iccic:
});
case ISECHIKA:
case KAGE:
case KIYOSHIMA:
case SHINJU:
tarSections = tarSectionsSubpagedLTA;
break;
case TEST: // For debugging
eval('tarSections = ' + DebugMode.drPreviewSections);
break;
default: // Error: Target pagename not defined
msg =
dragoLib.toggleLoadingSpinner('remove') +
'<p style="color: MediumVioletRed">致命的なエラーが発生しました</p>' +
'<p>' + DeveloperLink + 'に、<u>' + ep.wikiPagename + '</u>への報告においてこのエラーが発生したことの報告をお願いします。</p>' +
manualEdit(ep);
$('.anr-editing').append(msg);
editDone(ep, true);
return {'wikitext': null};
}
if ($.inArray(ep.sectionToEdit, tarSections) === -1) tarSections.push(ep.sectionToEdit);

// Get sections and the whole wikitext of the page to which to report
const parsed = await dragoLib.parsePage(ep.pageToEdit, tarSections);
if (parsed) {
// Error handler for when pageToEdit doesn't have sections that it's supposed to have
if (parsed.wikitext.length !== tarSections.length) {
sectionNotFound(ep);
return {'wikitext': null};
}
}
} else { // API query failed
/**
* Check for duplicate reports.
return {'wikitext': null};
* @param data The return value of {@link collectData}.
}
* @param info The partial return value of {@link processIds}.

* @returns `string` if duplicate reports are found, a `Wikitext` instance if no duplicate reports are found,
// Get usernames for duplicate report check
* `false` if the page isn't found, and `null` if there's an issue with the connection.
const usersDR = ep.users; // Input values without duplicates: usersDR will be used for duplicate report check
*/
for (let i = 0; i < ep.types.length; i++) { // Loop through all the input values
checkDuplicateReports(data, info) {
let type = ep.types[i], username, logid, ip;
return lib.Wikitext.newFromTitle(data.page).then((Wkt) => {
switch(type) {
case 'UNL': // Registered users need duplicate report check also for their logids
// Wikitext instance failed to be initialized
case 'User2':
if (!Wkt)
return Wkt; // false or null
if (logid = Logids[username = usersDR[i]]) { // If the object knows the required logid, just push it into the array
usersDR.push(logid);
// Find UserANs that contain duplicate reports
const UserANs = Wkt.parseTemplates({
} else { // If not, get the logid from the API and push it into the array (if the response isn't undefined)
if (logid = await getLogid(username)) {
namePredicate: (name) => name === 'UserAN',
Logids[username] = logid; // Save the logid into the object
recursivePredicate: (Temp) => !Temp || Temp.getName('clean') !== 'UserAN',
usersDR.push(logid);
hierarchy: [
['1', 'user', 'User'],
['t', 'type', 'Type'],
['状態', 's', 'status', 'Status']
],
templatePredicate: (Temp) => {
// Get 1= and t= parameter values of this UserAN
let param1 = '';
let paramT = 'User2';
let converted = null;
for (const { name, value } of Temp.args) {
if (value) {
if (name === '2') {
return false; // Ignore closed ones
}
else if (/^(1|[uU]ser)$/.test(name)) {
param1 = value;
}
else if (/^(t|[tT]ype)$/.test(name)) {
if (/^(unl|usernolink)$/i.test(value)) {
paramT = 'UNL';
}
else if (/^ip(user)?2$/i.test(value)) {
paramT = 'IP2';
}
else if (/^log(id)?$/i.test(value)) {
paramT = 'logid';
}
else if (/^diff(id)?$/i.test(value)) {
paramT = 'diffid';
}
else if (/^none$/i.test(value)) {
paramT = 'none';
}
}
}
}
if (!param1) {
return false;
}
else {
param1 = User.formatName(param1);
}
if (['logid', 'diffid'].includes(paramT)) {
// Ensure the 1= param value is of numerals if the t= param value is 'logid' or 'diffid'
if (!/^\d+$/.test(param1)) {
return false;
}
else {
// If the script user has ever converted the ID to an username, get the username
converted = idList.getRegisteredUsername(parseInt(param1), paramT);
}
}
// Evaluation
const isDuplicate = data.users.some(({ user, type }) => {
switch (paramT) {
case 'UNL':
case 'User2':
case 'IP2':
case 'none':
return user === param1 && /^(UNL|User2|IP2|none)$/.test(type) || info.includes(param1);
case 'logid':
case 'diffid':
return user === param1 && type === paramT || converted && info.includes(converted);
}
});
return isDuplicate;
}
}
});
if (!UserANs.length)
return Wkt;
// Highlight the duplicate UserANs
let wikitext = Wkt.wikitext;
const spanStart = '<span class="anr-preview-duplicate">';
UserANs.reverse().forEach((Temp) => {
wikitext = Temp.replaceIn(wikitext, { with: spanStart + Temp.renderOriginal() + '</span>' });
});
if (wikitext === Wkt.wikitext)
return Wkt;
// The sections in which to search for duplicate reports
const tarSectionsAll = {
[ANI]: [
Reporter.getCurrentAniSection(true),
Reporter.getCurrentAniSection(false),
'不適切な利用者名',
'公開アカウント',
'公開プロキシ・ゾンビマシン・ボット・不特定多数',
'犯罪行為またはその疑いのある投稿'
],
[ANS]: [
'著作権侵害・犯罪予告',
'名誉毀損・なりすまし・個人情報',
'妨害編集・いたずら',
'その他'
],
[AN3RR]: ['3RR']
};
const testKey = formatANTEST(true);
const tarSections = tarSectionsAll[(testKey || data.page)];
if (!tarSections) {
console.error(`"tarSectionsAll['${data.page}']" is undefined.`);
}
}
else if ((data.page === ANS || testKey === ANS) && !tarSections.includes(data.section)) {
break;
case 'IP2': // IPv6s need to be case-insensitive
tarSections.push(data.section);
}
if (mw.util.isIPv6Address(ip = usersDR[i], true) && ip.match(/[A-Z]/i)) { // If the IP is an IPv6 and if it contains alphabets
if (ip.match(/[A-Z]/)) { // If the IPv6 is in uppercase, push its lowercase ver, if in lowercase, push the uppercase ver
// Filter out the content of the relevant sections
usersDR.push(ip.toLowerCase());
const ret = new lib.Wikitext(wikitext).parseSections().reduce((acc, { title, content }) => {
} else {
if (tarSections.includes(title) && content.includes(spanStart)) {
usersDR.push(ip.toUpperCase());
acc.push(content.trim());
}
}
return acc;
}, []);
if (!ret.length) {
return Wkt;
}
}
break;
else {
case 'logid': // The corresponding username needs to be checked
return ret.join('\n\n');
}
if (username = dragoLib.getKeyByValue(Logids, logid = usersDR[i])) usersDR.push(username);
break;
});
default: // t=diff or t=none: no need to do anything because the relevant input value is already in the array
}
}
}
/**
* Preview duplicate reports.

* @param data The return value of {@link collectData}.
// Extract UserAN templates and find duplicate reports
* @param wikitext The wikitext to parse as HTML.
const dupTemplates = [], dupUsernames = [];
*/
const stringContainsElementInArray = function(str, arr) {
previewDuplicateReports(data, wikitext) {
for (let i = 0; i < arr.length; i++) {
if (str.indexOf(arr[i]) !== -1) {
// Create preview dialog
return arr[i];
const $preview = $('<div>')
}
.css({
maxHeight: '70vh',
maxWidth: '80vw'
})
.dialog({
dialogClass: 'anr-dialog anr-dialog-drpreview',
title: ANR + ' - Duplicate report preview',
height: 'auto',
width: 'auto',
modal: true,
close: function () {
// Destory the dialog and its contents when closed by any means
$(this).empty().dialog('destroy');
}
});
const $previewContent = $('<div>')
.prop('id', 'anr-dialog-drpreview-content')
.text('読み込み中')
.append(getImage('load', 'margin-left: 0.5em;'));
$preview.append($previewContent);
// Parse wikitext to HTML
new mw.Api().post({
action: 'parse',
title: data.page,
text: wikitext,
prop: 'text',
disablelimitreport: true,
disableeditsection: true,
disabletoc: true,
formatversion: '2'
}).then((res) => {
const content = res && res.parse && res.parse.text;
if (content) {
// Append the parsed HTML to the preview dialog
const $body = $('<div>').prop('id', 'anr-dialog-drpreview-body');
$body.append(content);
$previewContent
.empty()
.append($body)
.find('a').prop('target', '_blank'); // Open all links on a new tab
$preview.dialog({
buttons: [
{
text: '閉じる',
click: () => {
$preview.dialog('close');
}
}
]
});
// Center the preview dialog and scroll to the first duplicate report
Reporter.centerDialog($preview, true);
Reporter.centerDialog($preview, true); // Necessary to call this twice for some reason
$('.anr-dialog-drpreview').children('.ui-dialog-content').eq(0).scrollTop($('.anr-preview-duplicate').position().top);
}
else {
throw new Error('action=parseのエラー');
}
}).catch((_, err) => {
console.log(err);
$previewContent
.empty()
.text('プレビューの読み込みに失敗しました。')
.append(getImage('cross', 'margin-left: 0.5em;'));
$preview.dialog({
buttons: [
{
text: '閉じる',
click: () => {
$preview.dialog('close');
}
}
]
});
});
}
}
};
/**
* Watch user pages on report. If `data.watch` isn't a string (i.e. not a watch expiry), the method
for (let i = parsed.wikitext.length -1; i >= 0; i--) { // Loop through all section contents
* will not send any API request of `action=watch`.

* @param data The return value of {@link collectData}.
const templates = dragoLib.findTemplates(parsed.wikitext[i], 'UserAN'); // Extract UserAN templates as an array
* @param info The partial return value of {@link processIds}.
let dupUsername, duplicateFound;
* @returns

*/
if (templates.length !== 0) { // If the section content contains at least one UserAN
watchUsers(data, info) {
for (let j = templates.length -1; j >= 0; j--) { // Loop through all the occurences of UserAN
if (!data.watch) {
if (dupUsername = stringContainsElementInArray(templates[j], usersDR)) { // If there's a duplciate report
return;
if ($.inArray(templates[j], dupTemplates) === -1) dupTemplates.push(templates[j]); // List the UserAN as a duplicate
}
if ($.inArray(dupUsername, dupUsernames) === -1) dupUsernames.push(dupUsername); // List the duplicate username
duplicateFound = true;
const users = info.reduce((acc, val) => {
if (val) {
const username = '利用者:' + val;
if (!acc.includes(username)) {
acc.push(username);
}
}
}
return acc;
}, []);
if (!users.length) {
return;
}
}
new mw.Api().watch(users, data.watch);
}
}
if (!duplicateFound) parsed.wikitext.splice(i, 1); // Remove the section text from the array if it doesn't involve duplicate reports

}
}
/**

* Storage of the return value of {@link getBlockStatus}.
// Return text and update dialog
*
if (parsed.wikitext.length === 0) { // If there's no duplicate report
* This property is initialized every time when the constructor is called. This per se would tempt one to make the method non-static,

* but this isn't an option because the property is accessed by {@link getBlockStatus}, which is a static method.
msg = '<p style="color: MediumSeaGreen">重複報告は検出されませんでした' + dragoLib.toggleLoadingSpinner('remove') + '</p>';
*/
$('.anr-editing').append(msg); // Update message on the dialog
Reporter.blockStatus = {};
return; // Return undefined
let userPaneCnt = 0;

/**
} else { // If there're duplicate reports
* The User class. An instance of this handles a User field row on the Reporter dialog.

*/
msg = '<p style="color: MediumVioletRed">重複報告の可能性があります' + dragoLib.toggleLoadingSpinner('remove') + '</p>';
class User {
$('.anr-editing').append(msg);
/**

* Create a user pane of the Reporter dialog with the following structure.
// Highlight all the duplciate UserAN occurences
* ```html
var wikitext = parsed.wikitext.join(''); // Merge the separate sections
* <div class="anr-option-row anr-option-userpane-wrapper">
for (let i = 0; i < dupTemplates.length; i++) {
* <div class="anr-option-label">利用者</div> <!-- float: left; -->
wikitext = dragoLib.replaceAll2(wikitext, dupTemplates[i], '<span style="background-color: ' + anrConfig.headerColor + '">' + dupTemplates[i] + '</span>');
* <div class="anr-option-usertype"> <!-- float: right; -->
* <select>...</select>
* </div>
* <div class="anr-option-wrapper"> <!-- overflow: hidden; -->
* <input class="anr-option-username anr-juxtaposed"> <!-- width: 100%; -->
* </div>
* <!-- row boundary -->
* <div class="anr-option-row-inner anr-option-hideuser-wrapper">
* <div class="anr-option-label">&nbsp;</div> <!-- float: left; -->
* <div class="anr-option-hideuser">
* <label>
* <input class="anr-checkbox">
* <span class="anr-checkbox-label">利用者名を隠す</span>
* </label>
* </div>
* </div>
* <div class="anr-option-row-inner anr-option-idlink-wrapper">
* <div class="anr-option-label">&nbsp;</div>
* <div class="anr-option-idlink">
* <a></a>
* </div>
* </div>
* <div class="anr-option-row-inner anr-option-blockstatus-wrapper">
* <div class="anr-option-label">&nbsp;</div>
* <div class="anr-option-blockstatus">
* <a>ブロックあり</a>
* </div>
* </div>
* </div>
* <!-- ADD BUTTON HERE -->
* ```
* @param $next The element before which to create a user pane.
* @param options
*/
constructor($next, options) {
options = Object.assign({ removable: true }, options || {});
// Create user pane row
this.$wrapper = Reporter.createRow();
this.$wrapper.addClass('anr-option-userpane-wrapper');
this.$overlay = $('<div>');
this.$overlay.addClass('anr-option-userpane-overlay');
Reporter.toggle(this.$overlay, false);
this.$wrapper.append(this.$overlay);
// Append a label div
this.id = 'anr-dialog-userpane-' + (userPaneCnt++);
this.$label = Reporter.createRowLabel(this.$wrapper, '利用者').prop('id', this.id);
if (options.removable) {
this.$wrapper.addClass('anr-option-removable');
this.$label
.prop('title', 'SHIFTクリックで除去')
.off('click').on('click', (e) => {
if (e.shiftKey) { // Remove the user pane when the label is shift-clicked
this.$wrapper.remove();
if (options && options.removeCallback) {
options.removeCallback(this);
}
}
});
}
// Append a type dropdown
const $typeWrapper = $('<div>').addClass('anr-option-usertype');
this.$type = addOptions($('<select>'), ['UNL', 'User2', 'IP2', 'logid', 'diffid', 'none'].map((el) => ({ text: el })));
this.$type // Initialize
.prop('disabled', true) // Disable
.off('change').on('change', () => {
this.processTypeChange();
})
.children('option').eq(5).prop('selected', true); // Select 'none'
$typeWrapper.append(this.$type);
this.$wrapper.append($typeWrapper);
// Append a username input
this.$input = $('<input>');
let inputTimeout;
this.$input
.addClass('anr-option-username') // Currently not used for anything
.prop({
type: 'text',
placeholder: '入力してください'
})
.off('input').on('input', () => {
clearTimeout(inputTimeout);
inputTimeout = setTimeout(() => {
this.processInputChange();
}, 350);
});
const $userWrapper = Reporter.wrapElement(this.$wrapper, this.$input);
$next.before(this.$wrapper);
let selectHeight;
if ((selectHeight = this.$type.height()) > this.$input.height()) {
this.$input.height(selectHeight);
}
Reporter.verticalAlign(this.$label, $userWrapper);
// Append a hide-user checkbox
this.$hideUserWrapper = Reporter.createRow();
this.$hideUserWrapper.removeAttr('class').addClass('anr-option-row-inner anr-option-hideuser-wrapper');
Reporter.createRowLabel(this.$hideUserWrapper, '');
const hideUserElements = createLabelledCheckbox('利用者名を隠す', { alterClasses: ['anr-option-hideuser'] });
this.$hideUser = hideUserElements.$checkbox;
this.$hideUser.off('change').on('change', () => {
this.processHideUserChange();
});
this.$hideUserLabel = hideUserElements.$label;
this.$hideUserWrapper.append(hideUserElements.$wrapper);
this.$wrapper.append(this.$hideUserWrapper);
Reporter.toggle(this.$hideUserWrapper, false);
// Append an ID link
this.$idLinkWrapper = Reporter.createRow();
this.$idLinkWrapper.removeAttr('class').addClass('anr-option-row-inner anr-option-idlink-wrapper');
Reporter.createRowLabel(this.$idLinkWrapper, '');
this.$idLink = $('<a>');
this.$idLink.prop('target', '_blank');
this.$idLinkWrapper.append($('<div>').addClass('anr-option-idlink').append(this.$idLink));
this.$wrapper.append(this.$idLinkWrapper);
Reporter.toggle(this.$idLinkWrapper, false);
// Append a block status link
this.$blockStatusWrapper = Reporter.createRow();
this.$blockStatusWrapper.removeAttr('class').addClass('anr-option-row-inner anr-option-blockstatus-wrapper');
Reporter.createRowLabel(this.$blockStatusWrapper, '');
this.$blockStatus = $('<a>');
this.$blockStatus.prop('target', '_blank').text('ブロックあり');
this.$blockStatusWrapper.append($('<div>').addClass('anr-option-blockstatus').append(this.$blockStatus));
this.$wrapper.append(this.$blockStatusWrapper);
Reporter.toggle(this.$blockStatusWrapper, false);
if (options.addCallback) {
options.addCallback(this);
}
}
}
/**

* Format a username by calling `lib.clean`, replacing underscores with spaces, and capitalizing the first letter.
// Return wikitext to fetch preview from
* If the username is an IPv6 address, all letters will be captalized.
return {
'wikitext': wikitext,
* @param username
'dupUsernames': dupUsernames
* @returns The formatted username.
};
*/
static formatName(username) {

let user = lib.clean(username.replace(/_/g, ' '));
}
if (mw.util.isIPv6Address(user, true)) {

user = user.toUpperCase();
}

// Function to show error message if sections that must be there are not found
function sectionNotFound(ep) {
const msg =
dragoLib.toggleLoadingSpinner('remove') +
'<p style="color: MediumVioletRed">取得に失敗しました</p>' +
'<p>指定されたセクションが見つかりませんでした</p>' +
'<br>' +
'<p>考えられる原因:</p>' +
'<p>1. 編集先のページの節構成が変更された</p>' +
'<p>2. 通信に失敗した</p>' +
'<p>3. スクリプトのバグ</p>' +
manualEdit(ep);
$('.anr-editing').append(msg);
editDone(ep, true);
}

// Function to generate the html for the manual edit helper tab
function manualEdit(ep) {
const meHtml =
'<br>' +
'<p>手動編集用:</p>' +
'<textarea disabled class="anr-dialog-textarea" rows="3">' + ep.reportText + '</textarea>' +
'<textarea disabled class="anr-dialog-textarea" rows="2" style="margin-top: 0.5em;">' + ep.editSummary.replace(ScriptAd, '') + '</textarea>';
return meHtml;
}

/**
* Action for when edit is done (in any way)
* @param {Object} ep
* @param {boolean} editFailed
*/
function editDone(ep, editFailed) {

const btns = [], $dialog = $('#anr-modal-dialog');

// Button to jump to the report page
if (editFailed || mw.config.get('wgPageName') !== ep.pageToEdit) { // Show the button if the edit failed or if the user is NOT on the page
const destBtn = {
'text': '報告先',
'click': function(){
window.open(mw.util.getUrl(ep.wikiPagename), '_blank');
}
}
else if (!/^[\u10A0-\u10FF]/.test(user)) { // ucFirst, except for Georgean letters
};
btns.push(destBtn);
user = mwString.ucFirst(user);
}

// Button to close the dialog (always shown)
const closeBtn = {
'text': '閉じる',
'click': function(){
$dialog.dialog('close');
var curPage = mw.config.get('wgPageName');
if (curPage === ANI ||
curPage === ANS ||
curPage === AN3RR ||
curPage === Iccic ||
curPage === ISECHIKA ||
curPage === KAGE ||
curPage === KIYOSHIMA ||
curPage === SHINJU ||
curPage === TEST)
{
location.reload(true);
}
}
return user;
}
}
};
/**
* Get the username in the textbox (underscores are replaced by spaces).
btns.push(closeBtn);
* @returns

// Show the button(s) on the dialog
*/
getName() {
$dialog.dialog({'buttons': btns});
return User.formatName(this.$input.val()) || null;
if (editFailed) dragoLib.centerDialog('#anr-modal-dialog');

}

/**
* Function to preview duplicate reports on a new dialog
* @param {string} wikitext wikitext for preview (inherited from preeditDuplicateReportQuery)
* @param {Array} dupUsernames usernames found to be duplicate reports (inherited from preeditDuplicateReportQuery)
*/
function previewDuplicateReports(wikitext, dupUsernames) {

// Duplicate usernames to show on the dialog (logids are to be shown in parentheses)
const usernames = [];
for (let i = 0; i < dupUsernames.length; i++) {
let username, logid;
if (username = dragoLib.getKeyByValue(Logids, logid = dupUsernames[i])) { // if the dupUsername is a logid and that can be converted to a username
usernames.push(username + ' (' + logid + ')');
} else if (logid = Logids[username = dupUsernames[i]]) { // if the dupUsername is a username and that can be converted to a logid
usernames.push(username + ' (' + logid + ')');
} else {
usernames.push(username); // if else, just push the username into the array
}
}
}
/**
* Set a value into the username input. Note that this method does not call {@link processInputChange}.

// Create dialog
* @param val
* @returns
const duplicateReportPreviewDiv =
*/
'<div id="anr-drpreview-dialog" title="AN Reporter Duplicate Report Preview" style="max-height: 80vh;">' +
setName(val) {
' <div id="anr-drpreview-header" style="padding: 0.5em;">' +
' <p id="anr-drpreview-loading">' +
this.$input.val(val);
return this;
' 読み込み中' + dragoLib.toggleLoadingSpinner('add') +
' </p>' +
}
/**
* Get the UserAN type selected in the dropdown.
' <p id="anr-drpreview-userlist" style="display: none; font-size: larger">' +
* @returns
' <span style="font-weight: bold">重複報告の可能性のある値:</span>' +
' <br>' +
*/
usernames.join(', ') +
getType() {
' </p>' +
return this.$type.val();
' </div>' +
}
/**
' <div id="anr-drpreview-body" style="display: none; font-size: 1.1em; padding: 0.5em; border: 1px solid silver; background-color: white;">' +
* Select a type in the UserAN type dropdown. Note that this method does not call {@link processTypeChange}.
// Added when the dialog is opened
' </div>' +
* @param type
'</div>';
* @returns
*/
$('body').append(duplicateReportPreviewDiv);
setType(type) {

this.$type.val(type);
// Show preview dialog
return this;
$('#anr-drpreview-dialog').dialog({
}
'dialogClass': 'anr-dialog-drpreview',
'height': 'auto',
/**
* Change the hidden state of the options in the type dropdown.
'width': $('#content').width() * 0.8,
* @param types An array of type options to make visible. The element at index 0 will be selected.
'modal': true,
'open': function(){
* @returns
*/

setTypeOptions(types) {
// Initialize the design of the dialog
this.$type.children('option').each((_, opt) => {
dragoLib.dialogCSS($('.anr-dialog-drpreview'), anrConfig.headerColor, anrConfig.backgroundColor, anrConfig.fontSize);
// Set up the UserAN type dropdown

// Convert the wikitext to an html form
const idx = types.indexOf(opt.value);
opt.hidden = idx === -1; // Show/hide options
dragoLib.getParsedHtml(wikitext.trim(), '').then(function(parsed) {
if (parsed) {
if (idx === 0) {
$('#anr-drpreview-body').append(parsed.htmltext);
opt.selected = true; // Select types[0]
$('#anr-drpreview-dialog a').attr('target', '_blank'); // Open all links on a new tab
$('#anr-drpreview-body').css('display', 'block');
$('#anr-drpreview-loading').remove();
$('#anr-drpreview-userlist').css('display', 'inline');
dragoLib.centerDialog('#anr-drpreview-dialog');
} else {
$('#anr-drpreview-loading').text('読み込みに失敗しました').css('color', 'MediumVioletRed');
dragoLib.centerDialog('#anr-drpreview-dialog');
setTimeout(function(){
$('#anr-drpreview-dialog').dialog('close');
}, 10000);
}
}
});
});
return this;

},
}
'buttons': [{
/**
* Update the visibility of auxiliary wrappers when the selection is changed in the type dropdown.
'text': '閉じる',
'click': function(){
* @returns
$(this).dialog('close');
*/
processTypeChange() {
const selectedType = this.processAuxiliaryElements().getType();
this.$type.toggleClass('anr-option-usertype-none', false);
switch (selectedType) {
case 'UNL':
case 'User2':
Reporter.toggle(this.$hideUserWrapper, true);
Reporter.toggle(this.$idLinkWrapper, false);
Reporter.toggle(this.$blockStatusWrapper, !!this.$blockStatus.text());
break;
case 'IP2':
Reporter.toggle(this.$hideUserWrapper, false);
Reporter.toggle(this.$idLinkWrapper, false);
Reporter.toggle(this.$blockStatusWrapper, !!this.$blockStatus.text());
break;
case 'logid':
case 'diffid':
Reporter.toggle(this.$hideUserWrapper, true);
Reporter.toggle(this.$idLinkWrapper, true);
Reporter.toggle(this.$blockStatusWrapper, !!this.$blockStatus.text());
break;
default: // 'none'
Reporter.toggle(this.$hideUserWrapper, false);
Reporter.toggle(this.$idLinkWrapper, false);
Reporter.toggle(this.$blockStatusWrapper, false);
this.$type.toggleClass('anr-option-usertype-none', !this.$type.prop('disabled'));
}
}
}]
return this;
});
}
/**

* Update the properties of auxiliary elements in the user pane.
}
* - Toggle the application of a red border on the username input.

* - Toggle the checked and disabled states of the hideuser checkbox.
// Function to show message when edit attempt is done
* - Change the display text, the href, and the disabled state of the event ID link.
function queryFailed(ep) {
* - Set up the display text and the href of the block status link (by {@link processBlockStatus}).
const msg =
* @returns
dragoLib.toggleLoadingSpinner('remove') +
*/
'<p style="color: MediumVioletRed">取得に失敗しました</p>' +
manualEdit(ep);
processAuxiliaryElements() {
const selectedType = this.getType();
$('.anr-editing').append(msg);
const inputVal = this.getName() || '';
editDone(ep, true);
const clss = 'anr-option-invalidid';
}
if (['logid', 'diffid'].includes(selectedType)) {

// Set up $input, $hideUser, and $idLink
// Function to manipulate dropdown options for UserAN types (also maniputes show/hide of checkbox)
const isNotNumber = !/^\d*$/.test(inputVal);
var updateTypeDropdownTimeout;
this.$input.toggleClass(clss, isNotNumber);
function updateTypeDropdown(inputID) {
this.$hideUser.prop({

disabled: isNotNumber,
const tarVal = dragoLib.trim2($(inputID).val()); // The value typed into the input
checked: true
const selectID = inputID.replace('input', 'select'); // #anr-userX-select
});
const checkboxDivID = inputID.replace('input', 'checkbox-div'); // #anr-userX-checkbox-div
const idTitle = (selectedType === 'logid' ? '特別:転送/logid/' : '特別:差分/') + inputVal;
const checkboxID = inputID.replace('input', 'checkbox'); // #anr-userX-checkbox
this.$idLink
const idlinkDivID = inputID.replace('input', 'idlink-div'); // #anr-userX-idlink-div
.text(idTitle)

.prop('href', mw.util.getUrl(idTitle))
clearTimeout(updateTypeDropdownTimeout); // Run the async function only once when there's been no input change for 0.35 seconds
.toggleClass('anr-disabledanchor', isNotNumber);
updateTypeDropdownTimeout = setTimeout(async function(){
// Set up $blockStatus

if (tarVal === '') { // if the field is blanked
if (!isNotNumber) {
const username = idList.getRegisteredUsername(parseInt(inputVal), selectedType);

if (username) {
$(selectID).prop('disabled', true).children('.anr-opt-none').prop('selected', true); // Disable dropdown and select 'none'
this.processBlockStatus(username);
$(checkboxDivID).css('display', 'none'); // Hide 'hide username' checkbox
$(checkboxID).prop('checked', false); // Uncheck the checkbox
}
$(idlinkDivID).css('display', 'none'); // Hide logid/diff link
else {
this.$blockStatus.text('');
toggleBlockStatusLink(inputID, true, false);
}

} else { // if the field is filled
}
}

if (mw.util.isIPAddress(tarVal, true)) { // if IP
else {
this.$input.toggleClass(clss, false);

$(selectID).prop('disabled', false); // enable dropdown (repeated to prevent a strange lag)
this.$hideUser.prop({
disabled: false,
$(selectID).children('.anr-opt-UNL').prop('hidden', true);
checked: false
$(selectID).children('.anr-opt-User2').prop('hidden', true);
});
$(selectID).children('.anr-opt-IP2').prop({'hidden': false, 'selected': true});
$(selectID).children('.anr-opt-logid').prop('hidden', true);
this.$idLink.toggleClass('anr-disabledanchor', false);
$(selectID).children('.anr-opt-diff').prop('hidden', true);
this.processBlockStatus(inputVal);
$(selectID).children('.anr-opt-none').prop('hidden', false);
$(checkboxDivID).css('display', 'none'); // hide 'hide username' checkbox
$(checkboxID).prop('checked', false); // uncheck the checkbox
$(idlinkDivID).css('display', 'none');
toggleBlockStatusLink(inputID, false, false);

} else if (await dragoLib.userExists(tarVal)) { // if user

$(selectID).prop('disabled', false); // enable dropdown (repeated to prevent a strange lag)
$(selectID).children('.anr-opt-UNL').prop({'hidden': false, 'selected': true});
$(selectID).children('.anr-opt-User2').prop('hidden', false);
$(selectID).children('.anr-opt-IP2').prop('hidden', true);
$(selectID).children('.anr-opt-logid').prop('hidden', true);
$(selectID).children('.anr-opt-diff').prop('hidden', true);
$(selectID).children('.anr-opt-none').prop('hidden', false);
$(checkboxDivID).css('display', 'block'); // show 'hide username' checkbox
$(checkboxID).prop('checked', false); // uncheck the checkbox
$(idlinkDivID).css('display', 'none');
toggleBlockStatusLink(inputID, false, false);

} else { // if something else (like random numbers or strings)

$(selectID).prop('disabled', false); // enable dropdown (repeated to prevent a strange lag)
$(selectID).children('.anr-opt-UNL').prop('hidden', true);
$(selectID).children('.anr-opt-User2').prop('hidden', true);
$(selectID).children('.anr-opt-IP2').prop('hidden', true);
$(selectID).children('.anr-opt-logid').prop('hidden', false);
$(selectID).children('.anr-opt-diff').prop('hidden', false);
$(selectID).children('.anr-opt-none').prop({'hidden': false, 'selected': true});
$(checkboxDivID).css('display', 'none'); // hide 'hide username' checkbox
$(idlinkDivID).css('display', 'none');
toggleBlockStatusLink(inputID, true, false);

}
}
return this;

}
}
/**

* Set up the display text and the href of the block status link
}, 350);
* @param username
}
* @returns

*/
/**
processBlockStatus(username) {
* Function to show/hide 'This user has blocks' links
username = User.formatName(username);
* @param {string} inputID the ID of the input
const status = Reporter.blockStatus[username];
* @param {boolean} forceHide if true, just hide the block status link (for when t=diff and t=none; block check impossible)
if (status) {
* @param {boolean} convertLogid if true, try to convert a logid to a username (for when t=logid; username is needed for block check)
if (status.usertype === 'user' || status.usertype === 'ip') {
*/
this.$blockStatus.prop('href', mw.util.getUrl('特別:投稿記録/' + username));
function toggleBlockStatusLink(inputID, forceHide, convertLogid) {
switch (status.blocked) {

case true:
dragoLib.centerDialog('#anr-modal-dialog');
this.$blockStatus.text('ブロックあり');

break;
const inputVal = dragoLib.trim2($(inputID).val()); // The value in the input
case false:
const $bsLinkDiv = $(inputID.replace('input', 'blockstatus-div')); // #anr-userX-blockstatus-div
this.$blockStatus.text('');
const $bsLink = $(inputID.replace('input', 'blockstatus')); // #anr-userX-blockstatus
break;

default: // null
var username, logid;
this.$blockStatus.text('ブロック状態不明');
if (forceHide && convertLogid) { // t=logid
}
// Check if the logid can be converted to a username and if it can, proceed to block check, and if it can't, just hide the block status link
}
if (!(username = dragoLib.getKeyByValue(Logids, logid = inputVal))) {
$bsLinkDiv.css('display', 'none');
else { // other
dragoLib.centerDialog('#anr-modal-dialog');
this.$blockStatus.text('');
return;
}
}
else { // Block status yet to be fetched
this.$blockStatus.text('');
}
return this;
}
}
/**
} else if (forceHide) { // t=diff or t=none
* Evaluate the input value, figure out its user type (and block status if relevant), and change selection
$bsLinkDiv.css('display', 'none'); // Hide the link div
* in the type dropdown (which proceeds to {@link processTypeChange}).
dragoLib.centerDialog('#anr-modal-dialog');
return;
* @returns
} else { // t=UNL, t=User2, or t=IP2
*/
username = inputVal;
processInputChange() {
const def = $.Deferred();
}
const typeMap = {

ip: ['IP2', 'none'],
// Check the block status of the user, and if blocked, update the bsLink, or else, hide the link
user: ['UNL', 'User2', 'none'],
dragoLib.getRestricted([username]).then(function(blocked) {
if (blocked.length !== 0) { // If the user is blocked
other: ['none', 'logid', 'diffid']
};
$bsLink.attr('href', mw.util.getUrl('特別:投稿記録/' + username)); // Update the link
const username = this.getName();
$bsLinkDiv.css('display', 'block'); // Show the link div
} else {
if (!username) { // Blank
$bsLinkDiv.css('display', 'none'); // Hide the link div
this.setType('none').$type.prop('disabled', true); // Disable dropdown and select 'none'
this.processTypeChange();
def.resolve(this);
}
else { // Some username is in the input
Reporter.getBlockStatus(username).then((obj) => {
if (/^\d+$/.test(username) && obj.usertype === 'user') {
typeMap.user.push('logid', 'diffid');
}
this.setTypeOptions(typeMap[obj.usertype]).$type.prop('disabled', false);
this.processTypeChange();
def.resolve(this);
});
}
return def.promise();
}
/**
* Process the change event of the hideuser checkbox and do a username-ID conversion.
* @returns
*/
processHideUserChange() {
// Show a spinner aside the hideuser checkbox label
const $processing = $(getImage('load', 'margin-left: 0.5em;'));
this.$hideUserLabel.append($processing);
this.setOverlay(true);
/*!
* Error handlers. If the catch block is ever reached, there should be some problem with either processInputChange
* or processTypeChange because the hideuser checkbox should be unclickable when the variables would be substituted
* by an unexpected value.
*/
const inputVal = this.getName();
const selectedType = this.getType();
const checked = this.$hideUser.prop('checked');
try {
if (typeof inputVal !== 'string') {
// The username input should never be empty
throw new TypeError('User.getName returned null.');
}
else if (!checked && !['logid', 'diffid'].includes(selectedType)) {
// The type dropdown should have either value when the box can be unchecked
throw new Error('User.getType returned neither "logid" nor "diffid".');
}
else if (!checked && !/^\d+$/.test(inputVal)) {
// The username input should only be of numbers when the box can be unchecked
throw new Error('User.getName returned a non-number.');
}
}
catch (err) {
console.error(err);
mw.notify('変換試行時にエラーが発生しました。スクリプトのバグの可能性があります。', { type: 'error' });
this.$hideUser.prop('checked', !checked);
$processing.remove();
this.setOverlay(false);
return $.Deferred().resolve(this);
}
if (checked) { // username to ID
return idList.getIds(inputVal).then(({ logid, diffid }) => {
if (typeof logid === 'number') {
this.setName(logid.toString()).setTypeOptions(['logid', 'diffid', 'none']).processTypeChange();
mw.notify(`利用者名「${inputVal}」をログIDに変換しました。`, { type: 'success' });
}
else if (typeof diffid === 'number') {
this.setName(diffid.toString()).setTypeOptions(['diffid', 'logid', 'none']).processTypeChange();
mw.notify(`利用者名「${inputVal}」を差分IDに変換しました。`, { type: 'success' });
}
else {
this.$hideUser.prop('checked', !checked);
mw.notify(`利用者名「${inputVal}」をIDに変換できませんでした。`, { type: 'warn' });
}
$processing.remove();
return this.setOverlay(false);
});
}
else { // ID to username
const idTypeJa = selectedType === 'logid' ? 'ログ' : '差分';
return idList.getUsername(parseInt(inputVal), selectedType).then((username) => {
if (username) {
return this.setName(username).processInputChange().then(() => {
mw.notify(`${idTypeJa}ID「${inputVal}」を利用者名に変換しました。`, { type: 'success' });
$processing.remove();
return this.setOverlay(false);
});
}
else {
this.$hideUser.prop('checked', !checked);
mw.notify(`${idTypeJa}ID「${inputVal}」を利用者名に変換できませんでした。`, { type: 'warn' });
$processing.remove();
return this.setOverlay(false);
}
});
}
}
/**
* Toggle the visibility of the overlay.
* @param show
* @returns
*/
setOverlay(show) {
Reporter.toggle(this.$overlay, show);
return this;
}
/**
* Check the validity of a username (by checking the inclusion of `/[@/#<>[\]|{}:]/`).
*
* Note that IP(v6) addresses should not be passed.
* @param username
* @returns
*/
static containsInvalidCharacter(username) {
return /[@/#<>[\]|{}:]/.test(username);
}
}
dragoLib.centerDialog('#anr-modal-dialog');
});

}

// Function to get account creation logid
function getLogid(username) {
return new Promise(function(resolve) {
new mw.Api().get({
'action': 'query',
'list': 'logevents',
'leuser': username,
'ledir': 'newer',
'lelimit': 1,
'formatversion': 2
}).then(function(res){
const resLogev = res.query.logevents;
resolve(resLogev.length === 0 ? undefined : resLogev[0].logid);
});
});
}


// ******************** EVENT HANDLERS ********************

// Copy a VIP/LTA name when the selection is changed
$(document).off('change', '#anr-viplist-select, #anr-ltalist-select').on('change', '#anr-viplist-select, #anr-ltalist-select', function() {
var id = $(this).prop('id'),
selectVal = $('#' + id).find('option').filter(':selected').text().trim();
switch (id) {
case 'anr-viplist-select':
dragoLib.copyToClipboard('[[WP:VIP#' + selectVal + ']]');
break;
case 'anr-ltalist-select':
dragoLib.copyToClipboard('[[LTA:' + selectVal + ']]');
}
}
/**
});
* Get an \<img> tag.

* @param iconType
// Reset dialog when closed
* @param cssText Additional styles to apply (Default styles: `vertical-align: middle; height: 1em; border: 0;`)
$(document)
* @returns
.off('dialogclose', '#anr-modal-dialog, #anr-preview-dialog, #anr-drpreview-dialog')
*/
.on('dialogclose', '#anr-modal-dialog, #anr-preview-dialog, #anr-drpreview-dialog',
function() {
function getImage(iconType, cssText = '') {
const img = (() => {
$(this).remove();
if (iconType === 'load' || iconType === 'check' || iconType === 'cross' || iconType === 'cancel') {
});
return lib.getIcon(iconType);

}
// Dynamically change the content of the section dropdown depending on the value selected in '報告先'
else {
$(document).off('change', '#anr-target-options').on('change', '#anr-target-options', function(){
const tag = document.createElement('img');
const selectedTar = $(this).children('option').filter(':selected').text();
switch (iconType) {
$('.anr-section-options-initial').prop('selected', true); // Reset the dropdown value
case 'gear':
switch(selectedTar) {
tag.src = 'https://upload.wikimedia.org/wikipedia/commons/0/05/OOjs_UI_icon_advanced.svg';
case ANI:
$('#anr-section-i-div').css('display', 'block');
break;
$('#anr-section-s-div').css('display', 'none');
case 'exclamation':
tag.src = 'https://upload.wikimedia.org/wikipedia/commons/c/c6/OOjs_UI_icon_alert-warning-black.svg';
$('#anr-section-i-options-date').text(dragoLib.getSection5('報告', false));
break;
$('#anr-section-i-select').css({'width': $(this).innerWidth()});
$('#anr-target-pagelink-div').css('display', 'block');
case 'bar':
tag.src = 'https://upload.wikimedia.org/wikipedia/commons/e/e5/OOjs_UI_icon_subtract.svg';
$('#anr-target-pagelink').attr('href', mw.util.getUrl(ANI));
break;
break;
case ANS:
case 'clock':
tag.src = 'https://upload.wikimedia.org/wikipedia/commons/8/85/OOjs_UI_icon_clock-progressive.svg';
$('#anr-section-i-div').css('display', 'none');
$('#anr-section-s-div').css('display', 'block');
}
tag.style.cssText = 'vertical-align: middle; height: 1em; border: 0;';
$('#anr-section-s-select').select2({'width': $(this).innerWidth()});
return tag;
$('#anr-target-pagelink-div').css('display', 'block');
}
$('#anr-target-pagelink').attr('href', mw.util.getUrl(ANS));
break;
})();
case AN3RR:
img.style.cssText += cssText;
return img;
$('#anr-section-i-div').css('display', 'none');
$('#anr-section-s-div').css('display', 'none');
$('#anr-target-pagelink-div').css('display', 'block');
$('#anr-target-pagelink').attr('href', mw.util.getUrl(AN3RR));
break;
}
}
/**
dragoLib.centerDialog('#anr-modal-dialog');
* Add \<option>s to a dropdown by referring to object data.
});
* @param $dropdown

* @param data `text` is obligatory, and the other properties are optional.
// Add section name to the '報告先' link when section is specified
* @returns The passed dropdown.
$(document)
*/
.off('change', '#anr-section-i-select, #anr-section-s-select')
function addOptions($dropdown, data) {
.on('change', '#anr-section-i-select, #anr-section-s-select',
data.forEach(({ text, value, disabled, selected, hidden }) => {
function(){
const option = document.createElement('option');
var tarSection = '', tarPage = '';
option.textContent = text;
if ($(this).attr('id') === 'anr-section-i-select') {
tarPage = ANI;
if (value !== undefined) {
option.value = value;
} else if ($(this).attr('id') === 'anr-section-s-select') {
tarPage = ANS;
}
option.disabled = !!disabled;
option.selected = !!selected;
option.hidden = !!hidden;
$dropdown[0].add(option);
});
return $dropdown;
}
}
let checkboxCnt = 0;
if ($(this).find('option').filter(':selected').text() !== '選択してください') {
/**
tarSection = '#' + $(this).find('option').filter(':selected').text();
* Create a labelled checkbox.
$('#anr-target-pagelink').attr('href', mw.util.getUrl(tarPage + tarSection));
* ```html
* <div class="anr-option-row">
* <label>
* <input class="anr-checkbox">
* <span class="anr-checkbox-label">labelText</span>
* </label>
* </div>
* ```
* @param labelText The label text.
* @param options
* @returns
*/
function createLabelledCheckbox(labelText, options = {}) {
const id = options.checkboxId && !document.getElementById(options.checkboxId) ? options.checkboxId : 'anr-checkbox-' + (checkboxCnt++);
const $outerLabel = $('<label>');
$outerLabel.attr('for', id);
const $wrapper = Reporter.createRow();
$wrapper.removeAttr('class').addClass((options.alterClasses || ['anr-option-row']).join(' ')).append($outerLabel);
const $checkbox = $('<input>');
$checkbox
.prop({
id,
type: 'checkbox'
})
.addClass('anr-checkbox');
const $label = $('<span>');
$label.addClass('anr-checkbox-label').text(labelText);
$outerLabel.append($checkbox, $label);
return { $wrapper, $checkbox, $label };
}
}
/**
});
* Extract a CIDR address from text.

*
// When the selection is changed in the type dropdown
* Regular expressions used in this method are adapted from `mediawiki.util`.
$(document).off('change','#anr-user-div select').on('change','#anr-user-div select', function(e){
* - {@link https://doc.wikimedia.org/mediawiki-core/REL1_41/js/#!/api/mw.util-method-isIPv4Address | mw.util.isIPv4Address}

* - {@link https://doc.wikimedia.org/mediawiki-core/REL1_41/js/#!/api/mw.util-method-isIPv6Address | mw.util.isIPv6Address}
const selectID = '#' + e.target.id; // #anr-userX-select
*
const valSelected = $(selectID).children('option').filter(':selected').text(); // Selected type
* @param text
const inputID = selectID.replace('select', 'input'); // #anr-userX-input
* @returns The extracted CIDR, or `null` if there's no match.
const valInput = dragoLib.trim2($(inputID).val()); // The input value
*/
const checkboxDivID = selectID.replace('select', 'checkbox-div'); // #anr-userX-checkbox-div
function extractCidr(text) {
const checkboxID = selectID.replace('select', 'checkbox'); // #anr-userX-checkbox
const v4_byte = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])';
const idlinkDivID = selectID.replace('select', 'idlink-div'); // #anr-userX-idlink-div
const v4_regex = new RegExp('(?:' + v4_byte + '\\.){3}' + v4_byte + '\\/(?:3[0-2]|[12]?\\d)');
const idlinkID = selectID.replace('select', 'idlink'); // #anr-userX-idlink
const v6_block = '\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d)';

const v6_regex = new RegExp('(?::(?::|(?::[0-9A-Fa-f]{1,4}){1,7})|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6}::|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){7})' +
switch(valSelected) {
case 'UNL':
v6_block);
const v6_regex2 = new RegExp('[0-9A-Fa-f]{1,4}(?:::?[0-9A-Fa-f]{1,4}){1,6}' + v6_block);
case 'User2':
let m;
$(checkboxDivID).css('display', 'block');
if ((m = text.match(v4_regex)) ||
toggleBlockStatusLink(inputID, false, false);
break;
(m = text.match(v6_regex)) ||
(m = text.match(v6_regex2)) && /::/.test(m[0]) && !/::.*::/.test(m[0])) {
case 'IP2':
toggleBlockStatusLink(inputID, false, false);
return m[0];
break;
case 'logid':
$(checkboxDivID).css('display', 'block');
$(checkboxID).prop('checked', true);
$(idlinkID).attr('href', mw.util.getUrl('Special:redirect/logid/' + valInput)).text('特別:転送/logid/' + valInput);
toggleBlockStatusLink(inputID, true, true);
break;
case 'diff':
$(checkboxDivID).css('display', 'none');
$(idlinkDivID).css('display', 'block');
$(idlinkID).attr('href', mw.util.getUrl('Special:diff/' + valInput)).text('特別:差分/' + valInput);
toggleBlockStatusLink(inputID, true, false);
break;
default:
$(checkboxDivID).css('display', 'none');
$(idlinkDivID).css('display', 'none');
toggleBlockStatusLink(inputID, true, false);
}

});

// When username is typed in, change dropdown options for UserAN types
$(document).off('input', '#anr-user-div :text').on('input', '#anr-user-div :text', function(e){
const inputID = '#' + e.target.id; // #anr-userX-input
updateTypeDropdown(inputID);
});

// When 'hide username' is clicked, get logid, change dropdown options, show href and so on
$(document).off('change', '#anr-user-div :checkbox').on('change', '#anr-user-div :checkbox', function(e){

const checkboxID = '#' + e.target.id; // #anr-userX-checkbox
const selectID = checkboxID.replace('checkbox', 'select'); // #anr-userX-select
const inputID = checkboxID.replace('checkbox', 'input'); // #anr-userX-input
const inputVal = dragoLib.trim2($(inputID).val());
const idlinkID = checkboxID.replace('checkbox', 'idlink'); // #anr-userX-idlink
const idlinkDivID = checkboxID.replace('checkbox', 'idlink-div'); // #anr-userX-idlink-div

// Function to update type dropdown for logid
const updateTypeDropdownLogid = function(logid) {
$(selectID).children('.anr-opt-UNL').prop('hidden', true);
$(selectID).children('.anr-opt-User2').prop('hidden', true);
$(selectID).children('.anr-opt-IP2').prop('hidden', true);
$(selectID).children('.anr-opt-logid').prop('hidden', false).prop('selected', true);
$(selectID).children('.anr-opt-diff').prop('hidden', false);
$(selectID).children('.anr-opt-none').prop('hidden', false);
$(idlinkDivID).css('display', 'block');
$(idlinkID).attr('href', mw.util.getUrl('Special:redirect/logid/' + logid)).text('特別:転送/logid/' + logid);
toggleBlockStatusLink(inputID, true, true);
}

var logid, username;
if ($(checkboxID).is(':checked')) { // If the checkbox is checked (the input value is a username and this needs to be converted to a logid)

if (logid = Logids[username = inputVal]) { // If the object knows the logid for the user
$(inputID).val(logid); // Replace the username with the logid in the object
updateTypeDropdownLogid(logid);
} else {
(async function() {
logid = await getLogid(username = inputVal); // Get logid from the API
if (!logid) { // If undefined is returned, reject the checking of the checkbox
alert('エラー\n\n取得可能なlogidが存在しません。Logidを手動で入力するか、type=diff または none を使用してください');
$(checkboxID).prop('checked', false);
} else { // If a valid logid is returned
$(inputID).val(logid); // Set the logid to the input
Logids[username] = logid; // Save the logid in the object
updateTypeDropdownLogid(logid);
}
})();
}
}
else {

return null;
} else { // if the checkbox is unchecked (the input value is a logid and this needs to be converted to a username)

if (username = dragoLib.getKeyByValue(Logids, logid = inputVal)) { // Username converted from logid
$(inputID).val(username); // Replace the logid with the username in the object
$(selectID).children('.anr-opt-UNL').prop('hidden', false).prop('selected', true);
$(selectID).children('.anr-opt-User2').prop('hidden', false);
$(selectID).children('.anr-opt-IP2').prop('hidden', true);
$(selectID).children('.anr-opt-logid').prop('hidden', true);
$(selectID).children('.anr-opt-diff').prop('hidden', true);
$(selectID).children('.anr-opt-none').prop('hidden', false);
$(idlinkDivID).css('display', 'none');
toggleBlockStatusLink(inputID, false, false);
} else {
alert('エラー\n\nLogidにはアカウント作成記録以外のものも含まれるため、logidからユーザー名への変換機能は実装していません。' +
'テキストボックス下のリンク先からユーザー名を取得するか、手動入力してください。なお、ユーザー名からlogidへの変換が行われた' +
'場合のみ、その逆の変換が可能です');
$(checkboxID).prop('checked', true);
}
}

}

});

// When the 'add' button is hit, add another input layer
$(document).off('click', '#anr-addBtn').on('click', '#anr-addBtn', function(){
userCnt++;
$('#anr-btn-div').before(dragoLib.replaceAll2(userDiv, '1-', userCnt + '-'));
$('#anr-user' + userCnt + '-div').css('margin-top', '0.2em');
dragoLib.centerDialog('#anr-modal-dialog');
});

// When buttons are moused on and off
$(document).off('mouseover mouseleave', '#anr-modal-dialog form button').on({
'mouseover': function(e) {e.target.style.borderColor = '#999999';},
'mouseleave': function(e) {e.target.style.borderColor = '#d3d3d3';}
}, '#anr-modal-dialog form button');

// When the summary checkbox is (un)checked
$(document).off('change', '#anr-summary-checkbox').on('change', '#anr-summary-checkbox', function(){
const $textarea = $('#anr-summary-text');
if ($(this).is(':checked')) { // Box is checked
$textarea.css('display','inline-block');
} else { // Box is unchecked
$textarea.css('display','none');
$textarea.children('textarea').val('');
}
}
// ******************************************************************************************
dragoLib.centerDialog('#anr-modal-dialog');
// Entry point
});
init();

// ******************************************************************************************
})(); // Closure of anonymous function
})();
//</nowiki>
//</nowiki>

2024年7月16日 (火) 05:03時点における最新版

"use strict";
/*********************************************************************************\
    AN Reporter
    @author [[User:Dragoniez]]
    @version 8.2.0
    @see https://github.com/Dr4goniez/wiki-gadgets/blob/main/src/ANReporter.ts
\*********************************************************************************/
//<nowiki>
/* global mw, $, OO */
(() => {
    // ******************************************************************************************
    // Across-the-board variables
    /** The script name. */
    const ANR = 'AN Reporter';
    const ANI = 'Wikipedia:管理者伝言板/投稿ブロック';
    const ANS = 'Wikipedia:管理者伝言板/投稿ブロック/ソックパペット';
    const AN3RR = 'Wikipedia:管理者伝言板/3RR';
    /**
     * This variable being a string means that we're in a debugging mode. (cf. {@link Reporter.collectData})
     */
    const ANTEST = false;
    /**
     * Format the `ANTEST` variable to a processable page name.
     * @param toWikipedia Whether to format to a page name in the Wikipedia namespace, defaulted to `false`.
     * @returns Always `false` if `ANTEST` is set to `false`, otherwise a formatted page name.
     */
    const formatANTEST = (toWikipedia = false) => {
        if (typeof ANTEST === 'string') {
            return toWikipedia ? eval(ANTEST) : '利用者:DragoTest/test/WP' + ANTEST;
        }
        else {
            return false;
        }
    };
    /**
     * Whether to use the library on testwiki.
     */
    const useDevLibrary = false;
    const ad = ' ([[利用者:Dragoniez/scripts/AN_Reporter|AN Reporter]])';
    let lib;
    let mwString;
    let idList;
    // ******************************************************************************************
    // Main functions
    /** Initialize the script. */
    function init() {
        // Is the user autoconfirmed?
        if ((mw.config.get('wgUserGroups') || []).indexOf('autoconfirmed') === -1) {
            mw.notify('あなたは自動承認されていません。AN Reporterを終了します。', { type: 'warn' });
            return;
        }
        // Shouldn't run on API pages
        if (location.href.indexOf('/api.php') !== -1) {
            return;
        }
        /** Whether the user is on the config page. */
        const onConfig = mw.config.get('wgNamespaceNumber') === -1 && /^(ANReporterConfig|ANRC)$/i.test(mw.config.get('wgTitle'));
        // Load the library and dependent modules, then go on to the main procedure
        loadLibrary(useDevLibrary).then((libReady) => {
            if (!libReady)
                return;
            // Main procedure
            if (onConfig) {
                // If on the config page, create the interface after loading dependent modules
                $(loadConfigInterface); // Show a 'now loading' message as soon as the DOM gets ready
                const modules = [
                    'mediawiki.user', // mw.user.options
                    'oojs-ui',
                    'oojs-ui.styles.icons-editing-core',
                    'oojs-ui.styles.icons-moderation',
                    'mediawiki.api', // mw.Api().saveOption
                ];
                $.when(mw.loader.using(modules), $.ready).then(() => {
                    createStyleTag(Config.merge());
                    createConfigInterface();
                });
            }
            else {
                // If not on the config page, create a portlet link to open the ANR dialog after loading dependent modules
                const modules = [
                    'mediawiki.String', // IdList
                    'mediawiki.user', // mw.user.options
                    'mediawiki.util', // addPortletLink
                    'mediawiki.api', // API queries
                    'mediawiki.Title', // lib
                    'jquery.ui',
                ];
                $.when(mw.loader.using(modules), mw.loader.getScript('https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/js/select2.full.js'), $.ready).then((require) => {
                    mwString = require(modules[0]);
                    const portlet = createPortletLink();
                    if (!portlet) {
                        console.error(`${ANR}: ポートレットリンクの作成に失敗しました。`);
                        return;
                    }
                    createStyleTag(Config.merge());
                    $('head').append('<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/select2/4.0.13/css/select2.css">');
                    idList = new IdList();
                    portlet.addEventListener('click', Reporter.new);
                }).catch((...err) => {
                    console.warn(err);
                    mw.notify(ANR + ': モジュールの読み込みに失敗しました。', { type: 'error' });
                });
            }
        });
    }
    /**
     * Load the library.
     * @param dev Whether to load the dev version of the library.
     * @returns
     */
    function loadLibrary(dev = false) {
        const libName = 'ext.gadget.WpLibExtra' + (dev ? 'Dev' : '');
        const loadLocal = () => {
            return mw.loader.using(libName)
                .then((require) => {
                lib = require(libName);
                if (typeof (lib && lib.version) !== 'string') { // Validate the library
                    console.error(`${ANR}: ライブラリの読み込みに失敗しました。`);
                    return false;
                }
                return true;
            })
                .catch((...err) => {
                console.error(err);
                return false;
            });
        };
        if (dev) {
            return mw.loader.getScript('https://test.wikipedia.org/w/load.php?modules=' + libName).then(loadLocal).catch((...err) => {
                console.error(err);
                return false;
            });
        }
        else {
            return loadLocal();
        }
    }
    /**
     * Get the first heading and content body, replacing the latter with a 'now loading' message.
     * @returns
     */
    function loadConfigInterface() {
        // Change the document's title
        document.title = 'ANReporterConfig' + ' - ' + mw.config.get('wgSiteName');
        // Get the first heading and content body
        const $heading = $('.mw-first-heading');
        const $content = $('.mw-body-content');
        if (!$heading.length || !$content.length) {
            return { $heading: null, $content: null };
        }
        // Set up the elements
        $heading.text(ANR + 'の設定');
        $content.empty().append(document.createTextNode('インターフェースを読み込み中'), getImage('load', 'margin-left: 0.5em;'));
        return { $heading, $content };
    }
    /**
     * Create the config interface.
     * @returns
     */
    function createConfigInterface() {
        const { $heading, $content } = loadConfigInterface();
        if (!$heading || !$content) {
            mw.notify('インターフェースの読み込みに失敗しました。', { type: 'error', autoHide: false });
            return;
        }
        // Create a config container
        const $container = $('<div>').prop('id', 'anrc-container');
        $content.empty().append($container);
        // Create the config body
        new Config($container);
    }
    /** Class to create/manipulate the config interface. */
    class Config {
        /**
         * Merge and retrieve the ANReporter config.
         * @param getDefault If `true`, get the default config. (Default: `false`)
         * @returns
         * @requires mediawiki.user
         */
        static merge(getDefault = false) {
            // Default config
            const cfg = {
                reasons: [],
                blockCheck: true,
                duplicateCheck: true,
                watchUser: false,
                watchExpiry: 'infinity',
                headerColor: '#FEC493',
                backgroundColor: '#FFF0E4',
                portletlinkPosition: ''
            };
            if (getDefault) {
                return cfg;
            }
            // Objectify the user config
            const strCfg = mw.user.options.get(this.key) || '{}';
            let userCfg;
            try {
                userCfg = JSON.parse(strCfg);
            }
            catch (err) {
                console.warn(err);
                return cfg;
            }
            // Merge the configs
            return Object.assign(cfg, userCfg);
        }
        /**
         * @param $container The container in which to create config options.
         * @requires mediawiki.user
         * @requires oojs-ui
         * @requires oojs-ui.styles.icons-editing-core
         * @requires oojs-ui.styles.icons-moderation
         * @requires mediawiki.api - Used to save the config
         */
        constructor($container) {
            // Transparent overlay of the container used to make elements in it unclickable
            this.$overlay = $('<div>').prop('id', 'anrc-container-overlay').hide();
            $container.after(this.$overlay);
            // Get config
            const cfg = Config.merge();
            // Fieldset that stores config options
            this.fieldset = new OO.ui.FieldsetLayout({
                label: 'ダイアログ設定',
                id: 'anrc-options'
            });
            // Create config options
            this.reasons = new OO.ui.MultilineTextInputWidget({
                id: 'anrc-reasons',
                placeholder: '理由ごとに改行',
                rows: 8,
                value: cfg.reasons.join('\n')
            });
            this.blockCheck = new OO.ui.CheckboxInputWidget({
                id: 'anrc-blockcheck',
                selected: cfg.blockCheck
            });
            this.duplicateCheck = new OO.ui.CheckboxInputWidget({
                id: 'anrc-duplicatecheck',
                selected: cfg.duplicateCheck
            });
            this.watchUser = new OO.ui.CheckboxInputWidget({
                id: 'anrc-watchuser',
                selected: cfg.watchUser
            });
            this.watchExpiry = new OO.ui.DropdownWidget({
                id: 'anrc-watchexpiry',
                menu: {
                    items: [
                        new OO.ui.MenuOptionWidget({
                            data: 'infinity',
                            label: '無期限'
                        }),
                        new OO.ui.MenuOptionWidget({
                            data: '1 week',
                            label: '1週間'
                        }),
                        new OO.ui.MenuOptionWidget({
                            data: '2 weeks',
                            label: '2週間'
                        }),
                        new OO.ui.MenuOptionWidget({
                            data: '1 month',
                            label: '1か月'
                        }),
                        new OO.ui.MenuOptionWidget({
                            data: '3 months',
                            label: '3か月'
                        }),
                        new OO.ui.MenuOptionWidget({
                            data: '6 months',
                            label: '6か月'
                        }),
                        new OO.ui.MenuOptionWidget({
                            data: '1 year',
                            label: '1年'
                        }),
                    ]
                }
            });
            this.watchExpiry.getMenu().selectItemByData(cfg.watchExpiry);
            this.headerColor = new OO.ui.TextInputWidget({
                id: 'anrc-headercolor',
                value: cfg.headerColor,
                placeholder: 'カラー名またはHEXコードを入力'
            });
            this.backgroundColor = new OO.ui.TextInputWidget({
                id: 'anrc-backgroundcolor',
                value: cfg.backgroundColor,
                placeholder: 'カラー名またはHEXコードを入力'
            });
            this.portletlinkPosition = new OO.ui.TextInputWidget({
                id: 'anrc-portletlinkposition',
                value: cfg.portletlinkPosition,
                placeholder: '「報告」リンクの生成位置を随意入力'
            });
            // Add the config options to the fieldset
            this.fieldset.addItems([
                new OO.ui.FieldLayout(this.reasons, {
                    label: '定型理由',
                    align: 'top',
                    help: '登録した定型理由はドロップダウンからコピーできます。'
                }),
                new OO.ui.FieldLayout(this.blockCheck, {
                    label: '報告前にブロック状態をチェック',
                    align: 'inline'
                }),
                new OO.ui.FieldLayout(this.duplicateCheck, {
                    label: '報告前に重複報告をチェック',
                    align: 'inline'
                }),
                new OO.ui.FieldLayout(this.watchUser, {
                    label: '報告対象者をウォッチ',
                    align: 'inline'
                }),
                new OO.ui.FieldLayout(this.watchExpiry, {
                    label: 'ウォッチ期間',
                    align: 'top'
                }),
                new OO.ui.FieldLayout(this.headerColor, {
                    label: 'ヘッダー色',
                    align: 'top',
                    help: new OO.ui.HtmlSnippet('ダイアログのヘッダー色を指定 (見本: ' +
                        '<span id="anrc-headercolor-demo" class="anrc-colordemo">ヘッダー色</span>' +
                        ')'),
                    helpInline: true
                }),
                new OO.ui.FieldLayout(this.backgroundColor, {
                    label: '背景色',
                    align: 'top',
                    help: new OO.ui.HtmlSnippet('ダイアログの背景色を指定 (見本: ' +
                        '<span id="anrc-backgroundcolor-demo" class="anrc-colordemo">背景色</span>' +
                        ')'),
                    helpInline: true
                }),
                new OO.ui.FieldLayout(this.portletlinkPosition, {
                    label: 'ポートレットID (上級)',
                    align: 'top',
                    help: new OO.ui.HtmlSnippet('<a href="https://doc.wikimedia.org/mediawiki-core/REL1_41/js/#!/api/mw.util-method-addPortletLink" target="_blank">mw.util.addPortletLink</a>の' +
                        '<code style="font-family: inherit;">portletId</code>を指定します。未指定または値が無効の場合、使用中のスキンに応じて自動的にリンクの生成位置が決定されます。')
                }),
            ]);
            // Append the fieldset to the container (do this here and get DOM elements in it)
            $container.append(this.fieldset.$element);
            const $headerColorDemo = $('#anrc-headercolor-demo').css('background-color', cfg.headerColor);
            const $backgroundColorDemo = $('#anrc-backgroundcolor-demo').css('background-color', cfg.backgroundColor);
            // Event listeners
            let headerColorTimeout;
            this.headerColor.$input.off('input').on('input', function () {
                // Change the background color of span that demonstrates the color of the dialog header
                clearTimeout(headerColorTimeout);
                headerColorTimeout = setTimeout(() => {
                    $headerColorDemo.css('background-color', this.value);
                }, 500);
            });
            let backgroundColorTimeout;
            this.backgroundColor.$input.off('input').on('input', function () {
                // Change the background color of span that demonstrates the color of the dialog body
                clearTimeout(backgroundColorTimeout);
                backgroundColorTimeout = setTimeout(() => {
                    $backgroundColorDemo.css('background-color', this.value);
                }, 500);
            });
            // Buttons
            const $buttonGroup1 = $('<div>').addClass('anrc-buttonwrapper');
            const resetButton = new OO.ui.ButtonWidget({
                label: 'リセット',
                id: 'anrc-reset',
                icon: 'undo',
                flags: 'destructive'
            });
            resetButton.$element.off('click').on('click', () => {
                this.reset();
            });
            $buttonGroup1.append(resetButton.$element);
            const $buttonGroup2 = $('<div>').addClass('anrc-buttonwrapper');
            this.saveButton = new OO.ui.ButtonWidget({
                label: '設定を保存',
                id: 'anrc-save',
                icon: 'bookmarkOutline',
                flags: ['primary', 'progressive']
            });
            this.saveButton.$element.off('click').on('click', () => {
                this.save();
            });
            $buttonGroup2.append(this.saveButton.$element);
            // Append the buttons to the container
            $container.append($buttonGroup1, $buttonGroup2);
        }
        /**
         * Reset the options to their default values.
         */
        reset() {
            OO.ui.confirm('設定をリセットしますか?').then((confirmed) => {
                if (!confirmed) {
                    mw.notify('キャンセルしました。');
                    return;
                }
                const defaultCfg = Config.merge(true);
                this.reasons.setValue('');
                this.blockCheck.setSelected(defaultCfg.blockCheck);
                this.duplicateCheck.setSelected(defaultCfg.duplicateCheck);
                this.watchUser.setSelected(defaultCfg.watchUser);
                this.watchExpiry.getMenu().selectItemByData(defaultCfg.watchExpiry);
                this.headerColor.setValue(defaultCfg.headerColor).$input.trigger('input');
                this.backgroundColor.setValue(defaultCfg.backgroundColor).$input.trigger('input');
                this.portletlinkPosition.setValue('');
                mw.notify('設定をリセットしました。', { type: 'success' });
            });
        }
        /**
         * Set the visibility of the overlay div and toggle accesibility to DOM elements in the config body.
         * @param show
         */
        setOverlay(show) {
            this.$overlay.toggle(show);
        }
        /**
         * Save the config.
         * @requires mediawiki.api
         */
        save() {
            this.setOverlay(true);
            // Change the save button's label
            const $label = $('<span>');
            $label.append(getImage('load', 'margin-right: 1em;'));
            const textNode = document.createTextNode('設定を保存しています...');
            $label.append(textNode);
            this.saveButton.setIcon(null).setLabel($label);
            // Get config
            const reasons = this.reasons.getValue().split('\n').reduce((acc, r) => {
                const rsn = lib.clean(r);
                if (rsn && !acc.includes(rsn)) {
                    acc.push(rsn);
                }
                return acc;
            }, []);
            this.reasons.setValue(reasons.join('\n'));
            const cfg = {
                reasons,
                blockCheck: this.blockCheck.isSelected(),
                duplicateCheck: this.duplicateCheck.isSelected(),
                watchUser: this.watchUser.isSelected(),
                watchExpiry: this.watchExpiry.getMenu().findSelectedItem().getData(),
                headerColor: this.headerColor.getValue(),
                backgroundColor: this.backgroundColor.getValue(),
                portletlinkPosition: this.portletlinkPosition.getValue()
            };
            const strCfg = JSON.stringify(cfg);
            // Save config
            new mw.Api().saveOption(Config.key, strCfg)
                .then(() => {
                mw.user.options.set(Config.key, strCfg);
                return null;
            })
                .catch((code, err) => {
                console.warn(err);
                return code;
            })
                .then((err) => {
                if (err) {
                    mw.notify(`保存に失敗しました。(${err})`, { type: 'error' });
                }
                else {
                    mw.notify('保存しました。', { type: 'success' });
                }
                this.saveButton.setIcon('bookmarkOutline').setLabel('設定を保存');
                this.setOverlay(false);
            });
        }
    }
    /**
     * The key of `mw.user.options`.
     */
    Config.key = 'userjs-anreporter';
    /**
     * Create a Reporter portlet link.
     * @returns The Reporter portlet link.
     */
    function createPortletLink() {
        const cfg = Config.merge();
        let portletlinkPosition = '';
        if (cfg.portletlinkPosition) {
            if (document.getElementById(cfg.portletlinkPosition)) {
                portletlinkPosition = cfg.portletlinkPosition;
            }
            else {
                mw.notify(`AN Reporter: "${cfg.portletlinkPosition}" はポートレットリンクの生成位置として不正なIDです。`, { type: 'error' });
            }
        }
        if (!portletlinkPosition) {
            switch (mw.config.get('skin')) {
                case 'vector':
                case 'vector-2022':
                    portletlinkPosition = 'p-views';
                    break;
                case 'minerva':
                    portletlinkPosition = 'p-personal';
                    break;
                default: // monobook, timeless, or something else
                    portletlinkPosition = 'p-cactions';
            }
        }
        const portlet = mw.util.addPortletLink(portletlinkPosition, '#', '報告', 'ca-anr', '管理者伝言板に利用者を報告');
        return portlet || null;
    }
    /**
     * Create a /<style> tag for the script.
     */
    function createStyleTag(cfg) {
        let fontSize;
        let select2FontSize;
        switch (mw.config.get('skin')) {
            case 'vector':
            case 'vector-2022':
            case 'minerva':
                fontSize = '80%';
                select2FontSize = '0.9em';
                break;
            case 'monobook':
                fontSize = '110%';
                select2FontSize = '1.03em';
                break;
            case 'timeless':
                fontSize = '90%';
                select2FontSize = '0.94em';
                break;
            default:
                fontSize = '80%';
                select2FontSize = '0.9em';
        }
        const style = document.createElement('style');
        style.textContent =
            // Config
            '#anrc-container {' +
                'position: relative;' +
                '}' +
                '#anrc-container-overlay {' + // Overlay of the config body, used to make elements in it unclickable
                'width: 100%;' +
                'height: 100%;' +
                'position: absolute;' +
                'top: 0;' +
                'left: 0;' +
                'z-index: 10;' +
                '}' +
                '#anrc-options {' + // Border around fieldset
                'padding: 1em;' +
                'margin-bottom: 1em;' +
                'border: 1px solid silver;' +
                '}' +
                '.anrc-colordemo {' + // Demo color span, change inline to inline-block
                'display: inline-block;' +
                'border: 1px solid silver;' +
                '}' +
                '.anrc-buttonwrapper:not(:last-child),' + // Margin below buttons
                '#anr-dialog-progress-field tr:not(:last-child) {' +
                'margin-bottom: 0.5em;' +
                '}' +
                // Dialog
                '.anr-dialog {' +
                'font-size: ' + fontSize + ';' +
                '}' +
                '.anr-dialog hr {' +
                'margin: 0.8em 0;' +
                'background-color: #ccc;' +
                '}' +
                '.anr-dialog input[type="text"],' +
                '.anr-dialog textarea,' +
                '.anr-dialog select {' +
                'border: 1px solid #777;' +
                'border-radius: 1%;' +
                'background-color: white;' +
                'padding: 2px 4px;' +
                'box-sizing: border-box;' +
                '}' +
                '.anr-dialog input[type="button"],' +
                '#anr-dialog-configlink {' +
                'display: inline-block;' +
                'margin-left: auto;' +
                'margin-right: 0;' +
                'cursor: pointer;' +
                'padding: 1px 6px;' +
                'border: 1px solid #777;' +
                'border-radius: 3px;' +
                'background-color: #f8f9fa;' +
                'box-shadow: 1px 1px #cccccc;' +
                'box-sizing: border-box;' +
                '}' +
                '.anr-dialog input[type="button"]:hover,' +
                '#anr-dialog-configlink:hover {' +
                'background-color: white;' +
                '}' +
                '#anr-dialog-configlink-wrapper {' +
                'text-align: right;' +
                '}' +
                '#anr-dialog-configlink > span {' +
                'vertical-align: middle;' +
                'line-height: initial;' +
                '}' +
                '.anr-hidden {' + // Used to show/hide elements on the dialog (by Reporter.toggle)
                'display: none;' +
                '}' +
                '#anr-dialog-preview-content,' +
                '#anr-dialog-drpreview-content {' +
                'padding: 1em;' +
                '}' +
                '#anr-dialog-optionfield,' + // The immediate child of #anr-dialog-content
                '#anr-dialog-progress-field {' +
                'padding: 1em;' +
                'margin: 0;' +
                'border: 1px solid #ccc;' +
                '}' +
                '#anr-dialog-optionfield > legend,' +
                '#anr-dialog-progress-field > legend {' +
                'font-weight: bold;' +
                'padding-bottom: 0;' +
                '}' +
                '.anr-option-row:not(:last-child) {' + // Margin below every option row
                'margin-bottom: 0.15em;' +
                '}' +
                '.anr-option-row > .anr-option-row-inner:not(.anr-hidden):first-child {' +
                'margin-top: 0.15em;' +
                '}' +
                '.anr-option-userpane-wrapper {' +
                'position: relative;' +
                '}' +
                '.anr-option-userpane-overlay {' +
                'width: 100%;' +
                'height: 100%;' +
                'position: absolute;' +
                'top: 0;' +
                'left: 0;' +
                'z-index: 10;' +
                '}' +
                '.anr-option-row-withselect2 {' +
                'margin: 0.3em 0;' +
                '}' +
                '.anr-option-label {' + // The label div of a row
                'margin-right: 1em;' +
                'float: left;' + // For a juxtaposed div to fill the remaining space
                '}' +
                '.anr-option-wrapper {' +
                'overflow: hidden;' + // Implicit width of 100% (for the child element below)
                '}' +
                '#anr-option-reason, ' +
                '#anr-option-comment,' +
                '.anr-juxtaposed {' + // Assigned by Reporter.wrapElement.
                'width: 100%;' + // Fill the remaining space ("float" and "overflow" are essential for this to work)
                '}' +
                '.select2-container,' + // Set up the font size of select2 options
                '.anr-select2 .select2-selection--single {' +
                'height: auto !important;' +
                '}' +
                '.anr-select2 .select2-selection__rendered {' +
                'padding: 1px 2px;' +
                'font-size: 1em;' +
                'line-height: normal !important;' +
                '}' +
                '.anr-select2 .select2-results__option,' +
                '.anr-select2 .select2-results__group {' +
                'padding: 1px 8px;' +
                'font-size: ' + select2FontSize + ';' +
                'margin: 0;' +
                '}' +
                '.anr-disabledanchor {' + // Disabled anchor
                'pointer: none;' +
                'pointer-events: none;' +
                'color: gray;' +
                'text-decoration: line-through;' +
                '}' +
                '.anr-option-usertype {' + // UserAN type selector in user pane
                'float: right;' +
                'margin-left: 0.3em;' +
                '}' +
                '.anr-option-invalidid,' +
                '.anr-option-usertype-none {' +
                'border: 2px solid red;' +
                'border-radius: 3px;' +
                '}' +
                '.anr-option-removable > .anr-option-label {' + // Change cursor for the label of a user pane that's removable
                'cursor: pointer;' +
                '}' +
                '.anr-option-removable > .anr-option-label:hover {' +
                'background-color: #80ccff;' + // Bluish on hover
                '}' +
                '.anr-checkbox {' +
                'margin-right: 0.5em;' +
                '}' +
                '.anr-dialog label {' + // Get 'vertical-align' to work, ensuring itself as a block element
                'display: inline-block;' +
                '}' +
                '.anr-dialog label > .anr-checkbox,' +
                '.anr-dialog label > .anr-checkbox-label {' +
                'vertical-align: middle;' +
                '}' +
                '.anr-option-hideuser > label {' +
                'margin-left: 0.2em;' +
                '}' +
                '.anr-option-blockstatus > a,' +
                '#anr-dialog-progress-error-message {' +
                'color: mediumvioletred;' +
                '}' +
                '#anr-dialog-progress-field img {' +
                'margin: 0 0.5em;' +
                '}' +
                '#anr-dialog-progress-field ul {' +
                'margin-top: 0;' +
                '}' +
                '#anr-dialog-preview-body > div,' +
                '#anr-dialog-drpreview-body > div {' +
                'border: 1px solid silver;' +
                'padding: 0.2em 0.5em;' +
                'background: white;' +
                '}' +
                '#anr-dialog-preview-body .autocomment a {' + // Change the color of the section link in summary
                'color: gray;' +
                '}' +
                // Dialog colors
                '.anr-dialog.ui-dialog-content,' +
                '.anr-dialog.ui-corner-all,' +
                '.anr-dialog.ui-draggable,' +
                '.anr-dialog.ui-resizable,' +
                '.anr-dialog .ui-dialog-buttonpane {' +
                `background: ${cfg.backgroundColor};` +
                '}' +
                '.anr-dialog .ui-dialog-titlebar.ui-widget-header,' +
                '.anr-dialog .ui-dialog-titlebar-close {' +
                `background: ${cfg.headerColor} !important;` +
                '}' +
                '.anr-preview-duplicate {' +
                `background-color: ${cfg.headerColor};` +
                '}';
        document.head.appendChild(style);
    }
    /**
     * The IdList class. Administrates username-ID conversions.
     */
    class IdList {
        constructor() {
            /**
             * The list object of objects, keyed by usernames.
             *
             * The usernames are formatted by `lib.clean` and underscores in it are represented by spaces.
             */
            this.list = {};
        }
        /**
         * Get event IDs of a user.
         * @param username
         * @returns
         */
        getIds(username) {
            username = User.formatName(username);
            for (const user in this.list) {
                if (user === username) {
                    const { logid, diffid } = this.list[user];
                    if (typeof logid === 'number' || typeof diffid === 'number') {
                        return $.Deferred().resolve(Object.assign({}, this.list[user]));
                    }
                }
            }
            return this.fetchIds(username);
        }
        /**
         * Search for the oldest account creation logid and the diffid of the newest edit of a user.
         * @param username
         * @returns
         */
        fetchIds(username) {
            const ret = {};
            return new mw.Api().get({
                action: 'query',
                list: 'logevents|usercontribs',
                leprop: 'ids',
                letype: 'newusers',
                ledir: 'newer',
                lelimit: 1,
                letitle: 'User:' + username,
                uclimit: 1,
                ucuser: username,
                ucprop: 'ids',
                formatversion: '2'
            }).then((res) => {
                let resLgev, resUc;
                const logid = res && res.query && (resLgev = res.query.logevents) && resLgev[0] && resLgev[0].logid;
                const diffid = res && res.query && (resUc = res.query.usercontribs) && resUc[0] && resUc[0].revid;
                if (logid) {
                    ret.logid = logid;
                }
                if (diffid) {
                    ret.diffid = diffid;
                }
                if (logid || diffid) {
                    this.list[username] = Object.assign({}, ret);
                }
                return ret;
            }).catch((_, err) => {
                console.error(err);
                return ret;
            });
        }
        /**
         * Get a username from a log/diff ID.
         * @param id
         * @param type
         * @returns
         */
        getUsername(id, type) {
            // Attempt to convert the ID without making an HTTP request
            const registeredUsername = this.getRegisteredUsername(id, type);
            if (registeredUsername) {
                return $.Deferred().resolve(registeredUsername);
            }
            // Attempt to convert the ID through an HTTP request
            const fetcher = type === 'logid' ? this.scrapeUsername : this.fetchEditorName;
            return fetcher(id).then((username) => {
                if (username) {
                    username = User.formatName(username);
                    if (!this.list[username]) {
                        this.list[username] = {};
                    }
                    this.list[username][type] = id;
                }
                return username;
            });
        }
        /**
         * Attempt to convert an ID to a username based on the current username-ID list (no HTTP request).
         * @param id
         * @param type
         * @returns
         */
        getRegisteredUsername(id, type) {
            for (const user in this.list) {
                const relId = this.list[user][type];
                if (relId === id) {
                    return user;
                }
            }
            return null;
        }
        /**
         * Scrape [[Special:Log]] by a logid and attempt to get the associated username (if any).
         * @param logid
         * @returns
         */
        scrapeUsername(logid) {
            const url = mw.util.getUrl('特別:ログ', { logid: logid.toString() });
            return $.get(url)
                .then((html) => {
                const $newusers = $(html).find('.mw-logline-newusers').last();
                if ($newusers.length) {
                    switch ($newusers.data('mw-logaction')) {
                        case 'newusers/create':
                        case 'newusers/autocreate':
                        case 'newusers/create2': // Created by an existing user
                        case 'newusers/byemail': // Created by an existing user and password sent off
                            return $newusers.children('a.mw-userlink').eq(0).text();
                        case 'newusers/forcecreatelocal':
                            return $newusers.children('a').last().text().replace(/^利用者:/, '');
                        default:
                    }
                }
                return null;
            })
                .catch((...err) => {
                console.log(err);
                return null;
            });
        }
        /**
         * Convert a revision ID to a username.
         * @param diffid
         * @returns
         */
        fetchEditorName(diffid) {
            return new mw.Api().get({
                action: 'query',
                prop: 'revisions',
                revids: diffid,
                formatversion: '2'
            }).then((res) => {
                const resPg = res && res.query && res.query.pages;
                if (!resPg || !resPg.length)
                    return null;
                const resRev = resPg[0].revisions;
                const user = Array.isArray(resRev) && !!resRev.length && resRev[0].user;
                return user || null;
            }).catch((_, err) => {
                console.log(err);
                return null;
            });
        }
    }
    /**
     * The Reporter class. Manipulates the ANR dialog.
     */
    class Reporter {
        /**
         * Initializes a `Reporter` instance. This constructor only creates the base components of the dialog, and
         * asynchronous procedures are externally handled by {@link new}.
         */
        constructor() {
            this.cfg = Config.merge();
            Reporter.blockStatus = {}; // Reset
            // Create dialog contour
            this.$dialog = $('<div>');
            this.$dialog
                .css('max-height', '70vh')
                .dialog({
                dialogClass: 'anr-dialog',
                title: ANR,
                resizable: false,
                height: 'auto',
                width: 'auto',
                modal: true,
                close: function () {
                    // Destory the dialog and its contents when closed by any means
                    $(this).empty().dialog('destroy');
                }
            });
            // Create button that redirects the user to the config page
            const $config = $('<div>');
            $config.prop('id', 'anr-dialog-configlink-wrapper');
            const $configLink = $('<label>')
                .prop('id', 'anr-dialog-configlink')
                .append(getImage('gear', 'margin-right: 0.5em;'), $('<span>').text('設定'))
                .off('click').on('click', () => {
                window.open(mw.util.getUrl('特別:ANReporterConfig'), '_blank');
            });
            $config.append($configLink);
            this.$dialog.append($config);
            // Create progress container
            this.$progress = $('<div>');
            this.$progress
                .prop('id', 'anr-dialog-progress')
                .css('padding', '1em') // Will be removed in Reporter.new
                .append(document.createTextNode('読み込み中'), getImage('load', 'margin-left: 0.5em;'));
            this.$dialog.append(this.$progress);
            // Create option container
            this.$content = $('<div>');
            this.$content.prop('id', 'anr-dialog-content');
            this.$dialog.append(this.$content);
            // Create fieldset
            this.$fieldset = $('<fieldset>');
            this.$fieldset.prop({
                id: 'anr-dialog-optionfield',
                innerHTML: '<legend>利用者を報告</legend>'
            });
            this.$content.append(this.$fieldset);
            // Create target page option
            const $pageWrapper = Reporter.createRow();
            const $pageLabel = Reporter.createRowLabel($pageWrapper, '報告先');
            this.$page = $('<select>');
            this.$page
                .addClass('anr-juxtaposed') // Important for the dropdown to fill the remaining space
                .prop('innerHTML', '<option selected disabled hidden value="">選択してください</option>' +
                '<option>' + ANI + '</option>' +
                '<option>' + ANS + '</option>' +
                '<option>' + AN3RR + '</option>')
                .off('change').on('change', () => {
                this.switchSectionDropdown();
            });
            const $pageDropdownWrapper = Reporter.wrapElement($pageWrapper, this.$page); // As important as above
            this.$fieldset.append($pageWrapper);
            Reporter.verticalAlign($pageLabel, $pageDropdownWrapper);
            // Create target page anchor
            const $pageLinkWrapper = Reporter.createRow();
            Reporter.createRowLabel($pageLinkWrapper, '');
            this.$pageLink = $('<a>');
            this.$pageLink
                .addClass('anr-disabledanchor') // Disable the anchor by default
                .text('報告先を確認')
                .prop('target', '_blank');
            $pageLinkWrapper.append(this.$pageLink);
            this.$fieldset.append($pageLinkWrapper);
            // Create section option for ANI and AN3RR
            this.$sectionWrapper = Reporter.createRow();
            const $sectionLabel = Reporter.createRowLabel(this.$sectionWrapper, '節');
            this.$section = $('<select>');
            this.$section
                .prop({
                innerHTML: '<option selected disabled hidden value="">選択してください</option>',
                disabled: true
            })
                .off('change').on('change', () => {
                this.setPageLink();
            });
            const $sectionDropdownWrapper = Reporter.wrapElement(this.$sectionWrapper, this.$section);
            this.$fieldset.append(this.$sectionWrapper);
            Reporter.verticalAlign($sectionLabel, $sectionDropdownWrapper);
            // Create section option for ANS
            this.$sectionAnsWrapper = Reporter.createRow(true);
            const $sectionAnsLabel = Reporter.createRowLabel(this.$sectionAnsWrapper, '節');
            this.$sectionAns = $('<select>');
            this.$sectionAns
                .prop('innerHTML', '<option selected disabled hidden value="">選択してください</option>' +
                '<optgroup label="系列が立てられていないもの">' +
                '<option>著作権侵害・犯罪予告</option>' +
                '<option>名誉毀損・なりすまし・個人情報</option>' +
                '<option>妨害編集・いたずら</option>' +
                '<option>その他</option>' +
                '</optgroup>')
                .off('change').on('change', () => {
                this.setPageLink();
            });
            const $sectionAnsDropdownWrapper = Reporter.wrapElement(this.$sectionAnsWrapper, this.$sectionAns);
            this.$fieldset.append(this.$sectionAnsWrapper);
            Reporter.select2(this.$sectionAns);
            Reporter.verticalAlign($sectionAnsLabel, $sectionAnsDropdownWrapper);
            // Create an 'add' button
            this.$fieldset.append(document.createElement('hr'));
            const $addButtonWrapper = Reporter.createRow();
            this.$addButton = $('<input>');
            this.$addButton.prop('type', 'button').val('追加');
            $addButtonWrapper.append(this.$addButton);
            this.$fieldset.append($addButtonWrapper);
            this.$fieldset.append(document.createElement('hr'));
            // Create a user pane
            this.Users = [
                new User($addButtonWrapper, { removable: false })
            ];
            this.$addButton.off('click').on('click', () => {
                // eslint-disable-next-line @typescript-eslint/no-this-alias
                const _this = this;
                new User($addButtonWrapper, {
                    addCallback(User) {
                        const minWidth = User.$label.outerWidth() + 'px';
                        $.each([User.$wrapper, User.$hideUserWrapper, User.$idLinkWrapper, User.$blockStatusWrapper], (_, $wrapper) => {
                            $wrapper.children('.anr-option-label').css('min-width', minWidth);
                        });
                        _this.Users.push(User);
                    },
                    removeCallback(User) {
                        const idx = _this.Users.findIndex((U) => U.id === User.id);
                        if (idx !== -1) { // Should never be -1
                            const U = _this.Users[idx];
                            U.$wrapper.remove();
                            _this.Users.splice(idx, 1);
                        }
                    }
                });
            });
            const dialogWith = this.$fieldset.outerWidth(true);
            this.$fieldset.css('width', dialogWith); // Assign an absolute width to $content
            this.$progress.css('width', dialogWith);
            Reporter.centerDialog(this.$dialog); // Recenter the dialog because the width has been changed
            /**
             * (Bound to the change event of a \<select> element.)
             *
             * Copy the selected value to the clipboard and reset the selection.
             */
            const copyThenResetSelection = function () {
                lib.copyToClipboard(this.value, 'ja');
                this.selectedIndex = 0;
            };
            // Create VIP copier
            this.$vipWrapper = Reporter.createRow(true);
            const $vipLabel = Reporter.createRowLabel(this.$vipWrapper, 'VIP');
            this.$vip = $('<select>');
            this.$vip
                .prop('innerHTML', '<option selected disabled hidden value="">選択してコピー</option>')
                .off('change').on('change', copyThenResetSelection);
            const $vipDropdownWrapper = Reporter.wrapElement(this.$vipWrapper, this.$vip);
            this.$fieldset.append(this.$vipWrapper);
            Reporter.select2(this.$vip);
            Reporter.verticalAlign($vipLabel, $vipDropdownWrapper);
            // Create LTA copier
            this.$ltaWrapper = Reporter.createRow(true);
            const $ltaLabel = Reporter.createRowLabel(this.$ltaWrapper, 'LTA');
            this.$lta = $('<select>');
            this.$lta
                .prop('innerHTML', '<option selected disabled hidden value="">選択してコピー</option>')
                .off('change').on('change', copyThenResetSelection);
            const $ltaDropdownWrapper = Reporter.wrapElement(this.$ltaWrapper, this.$lta);
            this.$fieldset.append(this.$ltaWrapper);
            Reporter.select2(this.$lta);
            Reporter.verticalAlign($ltaLabel, $ltaDropdownWrapper);
            // Create predefined reason selector
            const $predefinedWrapper = Reporter.createRow(true);
            const $predefinedLabel = Reporter.createRowLabel($predefinedWrapper, '定型文');
            this.$predefined = $('<select>');
            this.$predefined
                .prop('innerHTML', '<option selected disabled hidden value="">選択してコピー</option>')
                .append($('<optgroup>')
                .css('display', 'none')
                .prop('innerHTML', this.cfg.reasons.map((el) => '<option>' + el + '</option>').join('')))
                .off('change').on('change', copyThenResetSelection);
            const $predefinedDropdownWrapper = Reporter.wrapElement($predefinedWrapper, this.$predefined);
            this.$fieldset.append($predefinedWrapper);
            Reporter.select2(this.$predefined);
            Reporter.verticalAlign($predefinedLabel, $predefinedDropdownWrapper);
            // Create reason field
            const $reasonWrapper = Reporter.createRow();
            Reporter.createRowLabel($reasonWrapper, '理由');
            this.$reason = $('<textarea>');
            this.$reason.prop({
                id: 'anr-option-reason',
                rows: 5,
                placeholder: '署名不要'
            });
            $reasonWrapper.append(this.$reason);
            this.$fieldset.append($reasonWrapper);
            // Create "add comment" option
            const addCommentElements = createLabelledCheckbox('要約にコメントを追加', { checkboxId: 'anr-option-addcomment' });
            this.$addComment = addCommentElements.$checkbox;
            this.$fieldset.append(addCommentElements.$wrapper);
            this.$comment = $('<textarea>');
            this.$comment.prop({
                id: 'anr-option-comment',
                rows: 2
            });
            addCommentElements.$wrapper.append(this.$comment);
            this.$addComment.off('change').on('change', () => {
                Reporter.toggle(this.$comment, this.$addComment.prop('checked'));
            }).trigger('change');
            // Create "block check" option
            const checkBlockElements = createLabelledCheckbox('報告前にブロック状態をチェック', { checkboxId: 'anr-option-checkblock' });
            this.$checkBlock = checkBlockElements.$checkbox;
            this.$checkBlock.prop('checked', this.cfg.blockCheck);
            this.$fieldset.append(checkBlockElements.$wrapper);
            // Create "duplicate check" option
            const checkDuplicatesElements = createLabelledCheckbox('報告前に重複報告をチェック', { checkboxId: 'anr-option-checkduplicates' });
            this.$checkDuplicates = checkDuplicatesElements.$checkbox;
            this.$checkDuplicates.prop('checked', this.cfg.duplicateCheck);
            this.$fieldset.append(checkDuplicatesElements.$wrapper);
            // Create "watch user" option
            const watchUserElements = createLabelledCheckbox('報告対象者をウォッチ', { checkboxId: 'anr-option-watchuser' });
            this.$watchUser = watchUserElements.$checkbox;
            this.$watchUser.prop('checked', this.cfg.watchUser);
            this.$fieldset.append(watchUserElements.$wrapper);
            this.$watchExpiry = $('<select>');
            this.$watchExpiry
                .prop({
                id: 'anr-option-watchexpiry',
                innerHTML: '<option value="infinity">無期限</option>' +
                    '<option value="1 week">1週間</option>' +
                    '<option value="2 weeks">2週間</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>'
            })
                .val(this.cfg.watchExpiry);
            const $watchExpiryWrapper = $('<div>');
            $watchExpiryWrapper
                .prop({ id: 'anr-option-watchexpiry-wrapper' })
                .css({
                marginLeft: this.$watchUser.outerWidth(true) + 'px',
                marginTop: '0.3em'
            })
                .append(document.createTextNode('期間: '), this.$watchExpiry);
            watchUserElements.$wrapper.append($watchExpiryWrapper);
            this.$watchUser.off('change').on('change', () => {
                Reporter.toggle($watchExpiryWrapper, this.$watchUser.prop('checked'));
            }).trigger('change');
            // Set all the row labels to the same width
            Reporter.setWidestWidth($('.anr-option-label'));
            // Make some wrappers invisible
            Reporter.toggle(this.$sectionAnsWrapper, false);
            Reporter.toggle(this.$vipWrapper, false);
            Reporter.toggle(this.$ltaWrapper, false);
            if (this.$predefined.find('option').length < 2) {
                Reporter.toggle($predefinedWrapper, false);
            }
            Reporter.toggle(this.$content, false);
        }
        /**
         * Taken several HTML elements, set the width that is widest among the elements to all of them.
         * @param $elements
         * @returns The width.
         */
        static setWidestWidth($elements) {
            // eslint-disable-next-line @typescript-eslint/no-explicit-any
            const optionsWidths = Array.prototype.map.call($elements, (el) => el.offsetWidth // Collect the widths of all the elements
            );
            const optionWidth = Math.max(...optionsWidths); // Get the max value
            $elements.css('min-width', optionWidth); // Set the value to all
            return optionWidth;
        }
        /**
         * Toggle the visibility of an element by (de)assigning the `anr-hidden` class.
         * @param $element The element of which to toggle the visibility.
         * @param show Whether to show the element.
         * @returns The passed element.
         */
        static toggle($element, show) {
            return $element.toggleClass('anr-hidden', !show);
        }
        /**
         * Create a \<div> that works as a Reporter row.
         * ```html
         * <!-- hasSelect2: false -->
         * <div class="anr-option-row"></div>
         * <!-- hasSelect2: true -->
         * <div class="anr-option-row-withselect2"></div>
         * ```
         * @param hasSelect2 `false` by default.
         * @returns The created row.
         */
        static createRow(hasSelect2 = false) {
            const $row = $('<div>');
            $row.addClass(!hasSelect2 ? 'anr-option-row' : 'anr-option-row-withselect2');
            return $row;
        }
        /**
         * Create a \<div> that works as a left-aligned label.
         * ```html
         * <div class="anr-option-label">labelText</div>
         * ```
         * @param $appendTo The element to which to append the label.
         * @param labelText The text of the label (technically, the innerHTML). If an empty string is passed, `&nbsp;` is used.
         * @returns The created label.
         */
        static createRowLabel($appendTo, labelText) {
            const $label = $('<div>');
            $label.addClass('anr-option-label');
            if (typeof labelText === 'string') {
                $label.prop('innerHTML', labelText || '&nbsp;');
            }
            else {
                $label.append(labelText);
            }
            $appendTo.append($label);
            return $label;
        }
        /**
         * Compare the outerHeight of a row label div and that of a sibling div, and if the former is smaller than the latter,
         * assign `padding-top` to the former.
         *
         * Note: **Both elements must be visible when this function is called**.
         * @param $label
         * @param $sibling
         */
        static verticalAlign($label, $sibling) {
            const labelHeight = $label.outerHeight();
            const siblingHeight = $sibling.outerHeight();
            if ($label.text() && labelHeight < siblingHeight) {
                $label.css('padding-top', ((siblingHeight - labelHeight) / 2) + 'px');
            }
        }
        /**
         * Wrap a (non-block) element (next to a row label) with a div. This is for the element to fill the remaining space.
         * ```html
         * <div class="anr-option-row">
         * 	<div class="anr-option-label"></div> <!-- float: left; -->
         * 	<div class="anr-option-wrapper"> <!-- overflow: hidden; -->
         * 		<element class="anr-juxtaposed">...</element> <!-- width: 100%; -->
         * 	</div>
         * </div>
         * ```
         * @param $appendTo The element to which to append the wrapper div.
         * @param $element The element to wrap.
         * @returns The wrapper div.
         */
        static wrapElement($appendTo, $element) {
            const $wrapper = $('<div>');
            $wrapper.addClass('anr-option-wrapper');
            $element.addClass('anr-juxtaposed');
            $wrapper.append($element);
            $appendTo.append($wrapper);
            return $wrapper;
        }
        /**
         * Set up `select2` to a dropdown.
         * @param $dropdown
         */
        static select2($dropdown) {
            $dropdown.select2({
                width: '100%', // Without this, the right end of the dropdown overflows
                dropdownCssClass: 'anr-select2' // This needs select2.full.js
            });
        }
        /**
         * Bring a jQuery UI dialog to the center of the viewport.
         * @param $dialog
         * @param absoluteCenter Whether to apply `center` instead of `top+5%`, defaulted to `false`.
         */
        static centerDialog($dialog, absoluteCenter = false) {
            $dialog.dialog({
                position: {
                    my: absoluteCenter ? 'center' : 'top',
                    at: absoluteCenter ? 'center' : 'top+5%',
                    of: window
                }
            });
        }
        /**
         * Create a new Reporter dialog. This static method handles asynchronous procedures that are necessary
         * after calling the constructor.
         * @param e
         */
        static new(e) {
            // Cancel portletlink click event
            e.preventDefault();
            // Create a Reporter dialog
            const R = new Reporter();
            // Get a username associated with the current page if any
            const heading = document.querySelector('.mw-first-heading') ||
                document.querySelector('.firstHeading') ||
                document.querySelector('#firstHeading');
            const relevantUser = mw.config.get('wgRelevantUserName') ||
                mw.config.get('wgCanonicalSpecialPageName') === 'Contributions' && heading && heading.textContent && extractCidr(heading.textContent);
            const U = R.Users[0];
            U.$input.val(relevantUser || '');
            const def = U.processInputChange();
            // Process additional asynchronous procedures for Reporter
            $.when(lib.Wikitext.newFromTitle(ANS), lib.getVipList(), lib.getLtaList())
                .then((Wkt, vipList, ltaList) => {
                // Initialize the ANS section dropdown
                if (Wkt) {
                    const exclude = [
                        'top',
                        '系列が立てられていないもの',
                        '著作権侵害・犯罪予告',
                        '名誉毀損・なりすまし・個人情報',
                        '妨害編集・いたずら',
                        'その他',
                        'A. 最優先',
                        '暫定A',
                        '休止中A',
                        'B. 優先度高',
                        '暫定B',
                        '休止中B',
                        'C. 優先度中',
                        '暫定C',
                        '休止中C',
                        'D. 優先度低',
                        '暫定D',
                        '休止中D',
                        'N. 未分類',
                        'サブページなし',
                        '休止中N'
                    ];
                    const optgroup = document.createElement('optgroup');
                    optgroup.label = 'LTA';
                    Wkt.parseSections().forEach(({ title }) => {
                        if (!exclude.includes(title)) {
                            const option = document.createElement('option');
                            option.textContent = title;
                            optgroup.appendChild(option);
                        }
                    });
                    if (optgroup.querySelector('option')) {
                        R.$sectionAns[0].add(optgroup);
                    }
                    else {
                        mw.notify('WP:AN/Sのセクション情報の取得に失敗しました。節構成が変更された、またはスクリプトのバグの可能性があります。', { type: 'error' });
                    }
                }
                else {
                    mw.notify('WP:AN/Sのセクション情報の取得に失敗しました。ダイアログを開き直すと改善する場合があります。', { type: 'error' });
                }
                // Initialize the VIP copier dropdown
                if (vipList.length) {
                    const optgroup = document.createElement('optgroup');
                    optgroup.style.display = 'none'; // Wrap with optgroup to adjust font size
                    vipList.forEach((vip) => {
                        const option = document.createElement('option');
                        option.textContent = vip;
                        option.value = '[[WP:VIP#' + vip + ']]';
                        optgroup.appendChild(option);
                    });
                    R.$vip[0].add(optgroup);
                    Reporter.toggle(R.$vipWrapper, true);
                }
                // Initialize the LTA copier dropdown
                if (ltaList.length) {
                    const optgroup = document.createElement('optgroup');
                    optgroup.style.display = 'none'; // Wrap with optgroup to adjust font size
                    ltaList.forEach((lta) => {
                        const option = document.createElement('option');
                        option.textContent = lta;
                        option.value = '[[LTA:' + lta + ']]';
                        optgroup.appendChild(option);
                    });
                    R.$lta[0].add(optgroup);
                    Reporter.toggle(R.$ltaWrapper, true);
                }
                def.then(() => {
                    Reporter.toggle(R.$progress, false);
                    R.$progress.css('padding', '');
                    Reporter.toggle(R.$content, true);
                    R.setMainButtons();
                });
            });
        }
        /**
         * Set the main dialog buttons.
         */
        setMainButtons() {
            this.$dialog.dialog({
                buttons: [
                    {
                        text: '報告',
                        click: () => this.report()
                    },
                    {
                        text: 'プレビュー',
                        click: () => this.preview()
                    },
                    {
                        text: '閉じる',
                        click: () => this.close()
                    }
                ]
            });
        }
        /**
         * Close the Reporter dialog (will be destroyed).
         */
        close() {
            this.$dialog.dialog('close');
        }
        /**
         * Get `YYYY年MM月D1日 - D2日新規依頼`, relative to the current day.
         * @param getLast Whether to get the preceding section, defaulted to `false`.
         * @returns
         */
        static getCurrentAniSection(getLast = false) {
            const d = new Date();
            let subtract;
            if (getLast) {
                if (d.getDate() === 1 || d.getDate() === 2) {
                    subtract = 3;
                }
                else if (d.getDate() === 31) {
                    subtract = 6;
                }
                else {
                    subtract = 5;
                }
                d.setDate(d.getDate() - subtract);
            }
            const multiplier = Math.ceil(d.getDate() / 5); // 1 to 7
            let lastDay, startDay;
            if (multiplier < 6) {
                lastDay = 5 * multiplier; // 5, 10, 15, 20, 25
                startDay = lastDay - 4; // 1, 6, 11, 16, 21
            }
            else {
                lastDay = Reporter.getLastDay(d.getFullYear(), d.getMonth()); // 28-31
                startDay = 26;
            }
            return `${d.getFullYear()}年${d.getMonth() + 1}月${startDay}日 - ${lastDay}日新規報告`;
        }
        /**
         * Get the last day of a given month in a given year.
         * @param year A 4-digit year.
         * @param month The month as a number between 0 and 11 (January to December).
         * @returns
         */
        static getLastDay(year, month) {
            return new Date(year, month + 1, 0).getDate();
        }
        /**
         * Get the page to which to forward the report.
         * @returns
         */
        getPage() {
            return this.$page.val() || null;
        }
        /**
         * Set an href to {@link $pageLink}. If {@link $page} is not selected, disable the anchor.
         * @returns
         */
        setPageLink() {
            const page = this.getPage();
            if (page) {
                this.$pageLink
                    .removeClass('anr-disabledanchor')
                    .prop('href', mw.util.getUrl(page + (this.getSection(true) || '')));
            }
            else {
                this.$pageLink
                    .addClass('anr-disabledanchor')
                    .prop('href', '');
            }
            return this;
        }
        /**
         * Get the selected section.
         * @param addHash Add '#' to the beginning when there's a value to return. (Default: `false`)
         * @returns
         */
        getSection(addHash = false) {
            let ret = null;
            switch (this.getPage()) {
                case ANI:
                    ret = this.$section.val() || null;
                    break;
                case ANS:
                    ret = this.$sectionAns.val() || null;
                    break;
                case AN3RR:
                    ret = '3RR';
                    break;
                default: // Section not selected
            }
            return ret && (addHash ? '#' : '') + ret;
        }
        /**
         * Switch the section dropdown options in accordance with the selection in the page dropdown.
         * This method calls {@link setPageLink} when done.
         * @returns
         */
        switchSectionDropdown() {
            const page = this.getPage();
            if (page) {
                switch (page) {
                    case ANI:
                        this.$section.prop('disabled', false).empty();
                        addOptions(this.$section, [
                            { text: '選択してください', value: '', disabled: true, selected: true, hidden: true },
                            { text: Reporter.getCurrentAniSection() },
                            { text: '不適切な利用者名' },
                            { text: '公開アカウント' },
                            { text: '公開プロキシ・ゾンビマシン・ボット・不特定多数' },
                            { text: '犯罪行為またはその疑いのある投稿' }
                        ]);
                        Reporter.toggle(this.$sectionWrapper, true);
                        Reporter.toggle(this.$sectionAnsWrapper, false);
                        this.setPageLink();
                        break;
                    case ANS:
                        this.$sectionAns.val('').trigger('change'); // For select2. This triggers `setPageLink`.
                        Reporter.toggle(this.$sectionWrapper, false);
                        Reporter.toggle(this.$sectionAnsWrapper, true);
                        break;
                    case AN3RR:
                        this.$section.prop({
                            disabled: false,
                            innerHTML: '<option>3RR</option>'
                        });
                        Reporter.toggle(this.$sectionWrapper, true);
                        Reporter.toggle(this.$sectionAnsWrapper, false);
                        this.setPageLink();
                }
            }
            else {
                this.$section.prop({
                    disabled: true,
                    innerHTML: '<option disabled selected hidden value="">選択してください</option>'
                });
                Reporter.toggle(this.$sectionWrapper, true);
                Reporter.toggle(this.$sectionAnsWrapper, false);
                this.setPageLink();
            }
            return this;
        }
        /**
         * Evaluate a username, classify it into a type, and check the block status of the relevant user.
         * @param username Automatically formatted by {@link User.formatName}.
         * @returns
         */
        static getBlockStatus(username) {
            username = User.formatName(username);
            const isIp = mw.util.isIPAddress(username, true);
            const bkpara = {};
            if (!username || !isIp && User.containsInvalidCharacter(username)) { // Blank or invalid
                return $.Deferred().resolve({
                    usertype: 'other',
                    blocked: null
                });
            }
            else if (Reporter.blockStatus[username]) {
                return $.Deferred().resolve(Object.assign({}, Reporter.blockStatus[username]));
            }
            else if (isIp) {
                bkpara.bkip = username;
            }
            else {
                bkpara.bkusers = username;
            }
            const params = Object.assign({
                action: 'query',
                list: 'users|blocks',
                ususers: username,
                formatversion: '2'
            }, bkpara);
            return new mw.Api().get(params)
                .then((res) => {
                const resUs = res && res.query && res.query.users;
                const resBl = res && res.query && res.query.blocks;
                if (resUs && resBl) {
                    const ret = {
                        usertype: isIp ? 'ip' : resUs[0].userid !== void 0 ? 'user' : 'other',
                        blocked: !!resBl.length
                    };
                    Reporter.blockStatus[username] = Object.assign({}, ret);
                    return ret;
                }
                else {
                    throw new Error('APIリクエストにおける不明なエラー');
                }
            })
                .catch((_, err) => {
                console.error(err);
                mw.notify('ユーザー情報の取得に失敗しました。', { type: 'error' });
                return {
                    usertype: 'other',
                    blocked: null
                };
            });
        }
        // -- Methods related to the dialog buttons of "report" and "preview" --
        /**
         * Collect option values.
         * @returns `null` if there's some error.
         */
        collectData() {
            // -- Check first for required fields --
            const page = this.getPage();
            const section = this.getSection();
            const shiftClick = $.Event('click');
            shiftClick.shiftKey = true;
            let hasInvalidId = false;
            const users = this.Users.reduceRight((acc, User) => {
                const inputVal = User.getName();
                const selectedType = User.getType();
                if (!inputVal) { // Username is blank
                    User.$label.trigger(shiftClick); // Remove the user pane
                }
                else if (['logid', 'diffid'].includes(selectedType) && !/^\d+$/.test(inputVal)) { // Invalid ID
                    hasInvalidId = true;
                }
                else { // Valid
                    acc.push({
                        user: inputVal,
                        type: selectedType
                    });
                }
                return acc;
            }, []).reverse();
            let reason = this.$reason.val();
            reason = lib.clean(reason.replace(/[\s-~]*$/, '')); // Remove signature (if any)
            this.$reason.val(reason);
            // Look for errors
            const $errList = $('<ul>');
            if (!page) {
                $errList.append($('<li>').text('報告先のページ名が未指定'));
            }
            if (!section) {
                $errList.append($('<li>').text('報告先のセクション名が未指定'));
            }
            if (!users.length) {
                $errList.append($('<li>').text('報告対象者が未指定'));
            }
            if (hasInvalidId) {
                $errList.append($('<li>').text('数字ではないID'));
            }
            if (!reason) {
                $errList.append($('<li>').text('報告理由が未指定'));
            }
            const errLen = $errList.children('li').length;
            if (errLen) {
                const $err = $('<div>')
                    .text('以下のエラーを修正してください。')
                    .append($errList);
                mw.notify($err, { type: 'error', autoHideSeconds: errLen > 2 ? 'long' : 'short' });
                return null;
            }
            // -- Collect secondary data --
            reason += '--~~~~'; // Add signature to reason
            const summary = this.$addComment.prop('checked') ? lib.clean(this.$comment.val()) : '';
            const blockCheck = this.$checkBlock.prop('checked');
            const duplicateCheck = this.$checkDuplicates.prop('checked');
            const watchUser = this.$watchUser.prop('checked');
            const watch = watchUser ? this.$watchExpiry.val() : null;
            // Return
            return {
                page: formatANTEST() || page,
                section: section,
                users,
                reason,
                summary,
                blockCheck,
                duplicateCheck,
                watch
            };
        }
        /**
         * Perform bilateral username-ID conversions against usernames collected from the Reporter dialog, in order to:
         * - find multiple occurrences of the same user in different formats (returned as `users` property), and
         * - create an array of all the collected usernames, in which user-denoting IDs are "sanitized" into real usernames
         * (returned as `info` property).
         *
         * @param data
         * @returns
         */
        processIds(data) {
            // Loop through all the input values for sanitization
            const registeredUsers = [];
            const promisifiedInfo = data.users.map((obj) => {
                switch (obj.type) {
                    case 'UNL':
                    case 'User2':
                        registeredUsers.push(obj.user);
                    // Proceed without break
                    // eslint-disable-next-line no-fallthrough
                    case 'IP2':
                        return $.Deferred().resolve(obj.user); // Username-denoting by nature
                    case 'none':
                        return $.Deferred().resolve(null); // This isn't a username-denoting value
                    case 'logid':
                    case 'diffid':
                        // Conversion of IDs to username-denoting values (null on failure)
                        return idList.getUsername(parseInt(obj.user), obj.type);
                }
            }, []);
            return $.when(...promisifiedInfo).then((...info) => {
                // Create an array of arrays of duplicate usernames
                const checkedIndexes = [];
                const users = info.reduce((acc, username, i, arr) => {
                    if (!username)
                        return acc;
                    // Usernames just converted from IDs aren't in the registeredUsers array yet
                    if (!registeredUsers.includes(username))
                        registeredUsers.push(username);
                    // Create an inner array as necessary
                    if (!checkedIndexes.includes(i)) {
                        const ret = [];
                        for (let j = i; j < arr.length; j++) { // Check array elements from the current index
                            if (j === i && j !== arr.lastIndexOf(username) || j !== i && arr[j] === username) { // Found a duplicate username
                                checkedIndexes.push(j);
                                const { user, type } = data.users[j];
                                const dup = type === 'logid' ? 'Logid/' + user : // If the username is displayed as an ID on the dialog,
                                    type === 'diffid' ? '差分/' + user : // list the user with the ID as a duplicate
                                        user;
                                if (!ret.includes(dup))
                                    ret.push(dup);
                            }
                        }
                        if (ret.length)
                            acc.push(ret);
                    }
                    return acc;
                }, []);
                // Return
                if (registeredUsers.length) {
                    // If any registered user is to be reported, convert their names to IDs by calling IdList.getIds,
                    // which registers username-ID correspondences into its class property of "list". We do this here
                    // because we want to know associated IDs for every user for the sake of checkDuplicateReports.
                    const deferreds = registeredUsers.reduce((acc, u, i, arr) => {
                        if (arr.indexOf(u) === i) { // If not duplicate
                            acc.push(idList.getIds(u));
                        }
                        return acc;
                    }, []);
                    return $.when(...deferreds).then(() => ({ users, info })); // Resolve ProcessedIds when username -> ID conversions are done
                }
                else {
                    return { users, info };
                }
            });
        }
        /**
         * Create the report text and summary out of the return values of {@link collectData} and {@link processIds}.
         * @param data The (null-proof) return value of {@link collectData}.
         * @param info The partial return value of {@link processIds}.
         * @returns The report text and summary.
         */
        createReport(data, info) {
            // Create UserANs and summary links
            const templates = [];
            const links = [];
            for (let i = 0; i < data.users.length; i++) {
                const obj = data.users[i];
                const Temp = new lib.Template('UserAN').addArgs([
                    {
                        name: 't',
                        value: obj.type
                    },
                    {
                        name: '1',
                        value: obj.user,
                        forceUnnamed: true
                    }
                ]);
                templates.push(Temp);
                switch (obj.type) {
                    case 'UNL':
                    case 'User2':
                    case 'IP2':
                        // If this username is the first occurrence in the "info" array in which IDs have been converted to usernames
                        if (info.indexOf(info[i]) === i) {
                            links.push(`[[特別:投稿記録/${obj.user}|${obj.user}]]`);
                        }
                        break;
                    case 'logid':
                        // The ID failed to be converted to a username or the converted username is the first occurrence and not a duplicate
                        if (info[i] === null || info.indexOf(info[i]) === i) {
                            links.push(`[[特別:転送/logid/${obj.user}|Logid/${obj.user}]]`);
                        }
                        break;
                    case 'diffid':
                        if (info[i] === null || info.indexOf(info[i]) === i) {
                            links.push(`[[特別:差分/${obj.user}|差分/${obj.user}]]の投稿者`);
                        }
                        break;
                    default: // none
                        if (info[i] === null || info.indexOf(info[i]) === i) {
                            links.push(obj.user);
                        }
                }
            }
            // Create the report text
            let text = '';
            templates.forEach((Temp, i) => {
                text += `${i === 0 ? '' : '\n'}* ${Temp.toString()}`;
            });
            text += templates.length > 1 ? '\n:' : ' - ';
            text += data.reason;
            // Create the report summary
            let summary = '';
            const fixed = [
                `/*${data.section}*/+`,
                ad
            ];
            const fixedLen = fixed.join('').length; // The length of the fixed summary
            const summaryComment = data.summary ? ' - ' + data.summary : '';
            for (let i = 0; i < Math.min(5, links.length); i++) { // Loop the reportee links
                const userLinks = links.slice(0, i + 1).join(', ') + // The first "i + 1" links
                    (links.slice(i + 1).length ? `, ほか${links.slice(i + 1).length}アカウント` : ''); // and the number of the remaining links if any
                const totalLen = fixedLen + userLinks.length + summaryComment.length; // The total length of the summary
                if (i === 0 && totalLen > 500) { // The summary exceeds the word count limit only with the first link
                    const maxLen = 500 - fixedLen - userLinks.length;
                    const trunc = summaryComment.slice(0, maxLen - 3) + '...'; // Truncate the additional comment
                    const augFixed = fixed.slice(); // Copy the fixed summary array
                    augFixed.splice(1, 0, userLinks, trunc); // Augment the copied array by inserting the first user link and the truncated comment
                    summary = augFixed.join(''); // Join the array elements and that will be the whole of the summary
                    break;
                }
                else if (totalLen > 500) {
                    // The word count limit is exceeded when we add a non-first link
                    // In this case, use the summary created in the last loop
                    break;
                }
                else { // If the word count limit isn't exceeded in the first loop, the code always reaches this block
                    const augFixed = fixed.slice();
                    augFixed.splice(1, 0, userLinks, summaryComment);
                    summary = augFixed.join('');
                }
            }
            return { text, summary };
        }
        // The 3 methods above are used both in "report" and "preview" (the former needs additional functions, and they are defined below).
        /**
         * Preview the report.
         * @returns
         */
        preview() {
            const data = this.collectData();
            if (!data)
                return;
            const $preview = $('<div>')
                .css({
                maxHeight: '70vh',
                maxWidth: '80vw'
            })
                .dialog({
                dialogClass: 'anr-dialog anr-dialog-preview',
                title: ANR + ' - Preview',
                height: 'auto',
                width: 'auto',
                modal: true,
                close: function () {
                    // Destory the dialog and its contents when closed by any means
                    $(this).empty().dialog('destroy');
                }
            });
            const $previewContent = $('<div>')
                .prop('id', 'anr-dialog-preview-content')
                .text('読み込み中')
                .append(getImage('load', 'margin-left: 0.5em;'));
            $preview.append($previewContent);
            this.processIds(data).then(({ info }) => {
                const { text, summary } = this.createReport(data, info);
                new mw.Api().post({
                    action: 'parse',
                    title: data.page,
                    text,
                    summary,
                    prop: 'text|modules|jsconfigvars',
                    pst: true,
                    disablelimitreport: true,
                    disableeditsection: true,
                    disabletoc: true,
                    contentmodel: 'wikitext',
                    formatversion: '2'
                }).then((res) => {
                    const resParse = res && res.parse;
                    const content = resParse.text;
                    const comment = resParse.parsedsummary;
                    if (content && comment) {
                        if (resParse.modules.length) {
                            mw.loader.load(resParse.modules);
                        }
                        if (resParse.modulestyles.length) {
                            mw.loader.load(resParse.modulestyles);
                        }
                        const $header = $('<div>')
                            .prop('id', 'anr-dialog-preview-header')
                            .append($('<p>' +
                            '注意1: このプレビュー上のリンクは全て新しいタブで開かれます<br>' +
                            '注意2: 報告先が <a href="' + mw.util.getUrl('WP:AN/S#OTH') + '" target="_blank">WP:AN/S#その他</a> の場合、' +
                            'このプレビューには表示されませんが「他M月D日」のヘッダーは必要に応じて自動挿入されます' +
                            '</p>'));
                        const $body = $('<div>').prop('id', 'anr-dialog-preview-body');
                        $body.append($(content), $('<div>')
                            .css('margin-top', '0.8em')
                            .append($(comment)));
                        $previewContent
                            .empty()
                            .append($header, $('<hr>'), $body)
                            .find('a').prop('target', '_blank'); // Open all links on a new tab
                        $preview.dialog({
                            buttons: [
                                {
                                    text: '閉じる',
                                    click: () => {
                                        $preview.dialog('close');
                                    }
                                }
                            ]
                        });
                        Reporter.centerDialog($preview, true);
                    }
                    else {
                        throw new Error('action=parseのエラー');
                    }
                }).catch((_, err) => {
                    console.log(err);
                    $previewContent
                        .empty()
                        .text('プレビューの読み込みに失敗しました。')
                        .append(getImage('cross', 'margin-left: 0.5em;'));
                    $preview.dialog({
                        buttons: [
                            {
                                text: '閉じる',
                                click: () => {
                                    $preview.dialog('close');
                                }
                            }
                        ]
                    });
                });
            });
        }
        /**
         * Submit the report.
         * @returns
         */
        report() {
            // Collect dialog data and check for errors
            const data = this.collectData();
            if (!data)
                return;
            // Create progress dialog
            this.$progress.empty();
            Reporter.toggle(this.$content, false);
            Reporter.toggle(this.$progress, true);
            this.$dialog.dialog({ buttons: [] });
            const $progressField = $('<fieldset>').prop('id', 'anr-dialog-progress-field');
            this.$progress.append($progressField);
            $progressField.append($('<legend>').text('報告の進捗'), $('<div>').prop('id', 'anr-dialog-progress-icons').append(getImage('check'), document.createTextNode('処理通過'), getImage('exclamation'), document.createTextNode('要確認'), getImage('bar'), document.createTextNode('スキップ'), getImage('clock'), document.createTextNode('待機中'), getImage('cross'), document.createTextNode('処理失敗')), $('<hr>'));
            const $progressTable = $('<table>');
            $progressField.append($progressTable);
            const $dupUsersRow = $('<tr>');
            $progressTable.append($dupUsersRow);
            const $dupUsersLabel = $('<td>').append(getImage('load'));
            const $dupUsersText = $('<td>').text('利用者名重複');
            $dupUsersRow.append($dupUsersLabel, $dupUsersText);
            const $dupUsersListRow = $('<tr>');
            $progressTable.append($dupUsersListRow);
            const $dupUsersListText = $('<td>');
            $dupUsersListRow.append($('<td>'), $dupUsersListText);
            const $dupUsersList = $('<ul>');
            $dupUsersListText.append($dupUsersList);
            Reporter.toggle($dupUsersListRow, false);
            const $blockedUsersRow = $('<tr>');
            $progressTable.append($blockedUsersRow);
            const $blockedUsersLabel = $('<td>').append(getImage(data.blockCheck ? 'clock' : 'bar'));
            const $blockedUsersText = $('<td>').text('既存ブロック');
            $blockedUsersRow.append($blockedUsersLabel, $blockedUsersText);
            const $blockedUsersListRow = $('<tr>');
            $progressTable.append($blockedUsersListRow);
            const $blockedUsersListText = $('<td>');
            $blockedUsersListRow.append($('<td>'), $blockedUsersListText);
            const $blockedUsersList = $('<ul>');
            $blockedUsersListText.append($blockedUsersList);
            Reporter.toggle($blockedUsersListRow, false);
            const $dupReportsRow = $('<tr>');
            $progressTable.append($dupReportsRow);
            const $dupReportsLabel = $('<td>').append(getImage(data.duplicateCheck ? 'clock' : 'bar'));
            const $dupReportsText = $('<td>').text('重複報告');
            $dupReportsRow.append($dupReportsLabel, $dupReportsText);
            const $dupReportsButtonRow = $('<tr>');
            $progressTable.append($dupReportsButtonRow);
            const $dupReportsButtonCell = $('<td>');
            $dupReportsButtonRow.append($('<td>'), $dupReportsButtonCell);
            Reporter.toggle($dupReportsButtonRow, false);
            const $reportRow = $('<tr>');
            $progressTable.append($reportRow);
            const $reportLabel = $('<td>').append(getImage('clock'));
            const $reportText = $('<td>').text('報告');
            $reportRow.append($reportLabel, $reportText);
            const $errorWrapper = $('<div>').prop('id', 'anr-dialog-progress-error');
            $progressField.append($errorWrapper);
            const $errorMessage = $('<p>').prop('id', 'anr-dialog-progress-error-message');
            const $errorReportText = $('<textarea>');
            $errorReportText.prop({
                id: 'anr-dialog-progress-error-text',
                rows: 5,
                disabled: true
            });
            const $errorReportSummary = $('<textarea>');
            $errorReportSummary.prop({
                id: 'anr-dialog-progress-error-summary',
                rows: 3,
                disabled: true
            });
            $errorWrapper.append($('<hr>'), $errorMessage, $('<label>').text('手動編集用'), $errorReportText, $errorReportSummary);
            Reporter.toggle($errorWrapper, false);
            // Process IDs that need to be converted to usernames
            this.processIds(data).then(({ users, info }) => {
                // Post-procedure of username-ID conversions and duplicate username check
                (() => {
                    const def = $.Deferred();
                    if (!users.length) {
                        $dupUsersLabel.empty().append(getImage('check'));
                        def.resolve(true);
                    }
                    else {
                        $dupUsersLabel.empty().append(getImage('exclamation'));
                        users.forEach((arr) => {
                            const $li = $('<li>').text(arr.join(', '));
                            $dupUsersList.append($li);
                        });
                        Reporter.toggle($dupUsersListRow, true);
                        this.$dialog.dialog({
                            buttons: [
                                {
                                    text: '続行',
                                    click: () => {
                                        Reporter.toggle($dupUsersListRow, false);
                                        this.$dialog.dialog({ buttons: [] });
                                        def.resolve(true);
                                    }
                                },
                                {
                                    text: '戻る',
                                    click: () => {
                                        Reporter.toggle(this.$progress, false);
                                        Reporter.toggle(this.$content, true);
                                        this.setMainButtons();
                                        def.resolve(false);
                                    }
                                },
                                {
                                    text: '閉じる',
                                    click: () => {
                                        this.close();
                                        def.resolve(false);
                                    }
                                }
                            ]
                        });
                        mw.notify('利用者名の重複を検出しました。', { type: 'warn' });
                    }
                    return def.promise();
                })()
                    .then((duplicateUsernamesResolved) => {
                    if (!duplicateUsernamesResolved)
                        return;
                    const deferreds = [];
                    if (data.blockCheck && data.duplicateCheck) {
                        $blockedUsersLabel.empty().append(getImage('load'));
                        $dupReportsLabel.empty().append(getImage('load'));
                        deferreds.push(this.checkBlocks(info), this.checkDuplicateReports(data, info));
                    }
                    else if (data.blockCheck) {
                        $blockedUsersLabel.empty().append(getImage('load'));
                        deferreds.push(this.checkBlocks(info), $.Deferred().resolve(void 0));
                    }
                    else if (data.duplicateCheck) {
                        $dupReportsLabel.empty().append(getImage('load'));
                        deferreds.push($.Deferred().resolve(void 0), this.checkDuplicateReports(data, info));
                    }
                    else {
                        deferreds.push($.Deferred().resolve(void 0), $.Deferred().resolve(void 0));
                    }
                    $.when(...deferreds).then((blocked, dup) => {
                        (() => {
                            const def = $.Deferred();
                            let stop = false;
                            // Process the result of block check
                            if (blocked) {
                                if (!blocked.length) {
                                    $blockedUsersLabel.empty().append(getImage('check'));
                                }
                                else {
                                    $blockedUsersLabel.empty().append(getImage('exclamation'));
                                    blocked.forEach((user) => {
                                        $blockedUsersList.append($('<li>').append($('<a>')
                                            .prop({
                                            href: mw.util.getUrl('特別:投稿記録/' + user),
                                            target: '_blank'
                                        })
                                            .text(user)));
                                    });
                                    Reporter.toggle($blockedUsersListRow, true);
                                    mw.notify('ブロック済みの利用者を検出しました。', { type: 'warn' });
                                    stop = true;
                                }
                            }
                            // Process the result of duplicate report check
                            if (dup instanceof lib.Wikitext) {
                                $dupReportsLabel.empty().append(getImage('check'));
                            }
                            else if (typeof dup === 'string') {
                                $dupReportsLabel.empty().append(getImage('exclamation'));
                                $dupReportsButtonCell.append($('<input>')
                                    .prop('type', 'button')
                                    .val('確認')
                                    .off('click').on('click', () => {
                                    this.previewDuplicateReports(data, dup);
                                }));
                                Reporter.toggle($dupReportsButtonRow, true);
                                mw.notify('重複報告を検出しました。', { type: 'warn' });
                                stop = true;
                            }
                            else if (dup === false || dup === null) {
                                $dupReportsLabel.empty().append(getImage('cross'));
                                mw.notify(`重複報告チェックに失敗しました。(${dup === null ? '通信エラー' : 'ページ非存在'})`, { type: 'error' });
                                stop = true;
                            }
                            if (!stop && dup instanceof lib.Wikitext) {
                                def.resolve(dup);
                            }
                            else if (!stop) {
                                def.resolve(void 0);
                            }
                            else {
                                this.$dialog.dialog({
                                    buttons: [
                                        {
                                            text: '続行',
                                            click: () => {
                                                Reporter.toggle($blockedUsersListRow, false);
                                                Reporter.toggle($dupReportsButtonRow, false);
                                                this.$dialog.dialog({ buttons: [] });
                                                def.resolve(void 0);
                                            }
                                        },
                                        {
                                            text: '戻る',
                                            click: () => {
                                                Reporter.toggle(this.$progress, false);
                                                Reporter.toggle(this.$content, true);
                                                this.setMainButtons();
                                                def.reject(); // Reject
                                            }
                                        },
                                        {
                                            text: '閉じる',
                                            click: () => {
                                                this.close();
                                                def.reject(); // Reject
                                            }
                                        }
                                    ]
                                });
                            }
                            return def.promise();
                        })()
                            .done((inheritedWkt) => {
                            // Recheck the target section for ANI
                            if (data.page === ANI && data.section === Reporter.getCurrentAniSection(true)) { // If the date range has changed since it was selected in the dropdown
                                this.switchSectionDropdown().$section.prop('selectedIndex', 1); // Update selection
                                data.section = this.getSection();
                            }
                            // Create report text and summary
                            $reportLabel.empty().append(getImage('load'));
                            const report = this.createReport(data, info);
                            let reportText = report.text;
                            const summary = report.summary;
                            /**
                             * Handle an error thrown on an edit attempt.
                             * @param err
                             */
                            const errorHandler = (err) => {
                                console.error(err);
                                $reportLabel.empty().append(getImage('cross'));
                                $errorMessage.text(err.message);
                                $errorReportText.val(reportText);
                                $errorReportSummary.val(summary.replace(new RegExp(mw.util.escapeRegExp(ad) + '$'), ''));
                                Reporter.toggle($errorWrapper, true);
                                mw.notify('報告に失敗しました。', { type: 'error' });
                                this.$dialog.dialog({
                                    buttons: [
                                        {
                                            text: '再試行',
                                            click: () => this.report()
                                        },
                                        {
                                            text: '報告先',
                                            click: () => {
                                                window.open(this.$pageLink.prop('href'), '_blank');
                                            }
                                        },
                                        {
                                            text: '戻る',
                                            click: () => {
                                                Reporter.toggle(this.$progress, false);
                                                Reporter.toggle(this.$content, true);
                                                this.setMainButtons();
                                            }
                                        },
                                        {
                                            text: '閉じる',
                                            click: () => {
                                                this.close();
                                            }
                                        }
                                    ]
                                });
                            };
                            // Create a Wikitext instance for the report
                            const $when = inheritedWkt ?
                                $.when($.Deferred().resolve(inheritedWkt)) :
                                $.when(lib.Wikitext.newFromTitle(data.page));
                            $when.then((Wkt) => {
                                // Validate the Wikitext instance
                                if (Wkt === false) {
                                    throw new Error(`ページ「${data.page}」が見つかりませんでした。`);
                                }
                                else if (Wkt === null) {
                                    throw new Error('通信エラーが発生しました。');
                                }
                                // Get the index of the section to edit
                                let sectionIdx = -1;
                                let sectionContent = '';
                                for (const { title, index, content } of Wkt.parseSections()) {
                                    if (title === data.section) {
                                        sectionIdx = index;
                                        sectionContent = content;
                                        break;
                                    }
                                }
                                if (sectionIdx === -1) {
                                    throw new Error(`節「${data.section}」が見つかりませんでした。`);
                                }
                                // Create a new content for the section to edit
                                if (data.page === ANS || formatANTEST(true) === ANS) { // ANS
                                    // Add div if the target section is 'その他' but lacks div for the current date
                                    const d = new Date();
                                    const today = (d.getMonth() + 1) + '月' + d.getDate() + '日';
                                    const miscHeader = '{{bgcolor|#eee|{{Visible anchor|他' + today + '}}|div}}';
                                    if (data.section === 'その他' && !sectionContent.includes(miscHeader)) {
                                        reportText = '; ' + miscHeader + '\n\n' + reportText;
                                    }
                                    // Get the report text to submit
                                    const sockInfoArr = new lib.Wikitext(sectionContent).parseTemplates({
                                        namePredicate: (name) => name === 'SockInfo/M',
                                        recursivePredicate: (Temp) => !Temp || Temp.getName('clean') !== 'SockInfo/M'
                                    });
                                    if (!sockInfoArr.length) {
                                        throw new Error(`節「${data.section}」内にテンプレート「SockInfo/M」が存在しないため報告場所を特定できませんでした。`);
                                    }
                                    else if (sockInfoArr.length > 1) {
                                        throw new Error(`節「${data.section}」内にテンプレート「SockInfo/M」が複数個あるため報告場所を特定できませんでした。`);
                                    }
                                    const sockInfo = sockInfoArr[0];
                                    sectionContent = sockInfo.replaceIn(sectionContent, {
                                        with: sockInfo.renderOriginal().replace(/\s*?\}{2}$/, '') + '\n\n' + reportText + '\n\n}}'
                                    });
                                }
                                else { // ANI or AN3RR
                                    sectionContent = lib.clean(sectionContent) + '\n\n' + reportText;
                                }
                                // Send action=watch requests in the background (if relevant)
                                this.watchUsers(data, info);
                                // Edit page
                                const { basetimestamp, curtimestamp } = Wkt.getRevision();
                                new mw.Api().postWithEditToken({
                                    action: 'edit',
                                    title: data.page,
                                    section: sectionIdx,
                                    text: sectionContent,
                                    summary,
                                    basetimestamp,
                                    curtimestamp,
                                    formatversion: '2'
                                }).then((res) => {
                                    if (res && res.edit && res.edit.result === 'Success') {
                                        $reportLabel.empty().append(getImage('check'));
                                        mw.notify('報告が完了しました。', { type: 'success' });
                                        this.$dialog.dialog({
                                            buttons: [
                                                {
                                                    text: '報告先',
                                                    click: () => {
                                                        window.open(this.$pageLink.prop('href'), '_blank');
                                                    }
                                                },
                                                {
                                                    text: '閉じる',
                                                    click: () => {
                                                        this.close();
                                                    }
                                                }
                                            ]
                                        });
                                    }
                                    else {
                                        errorHandler(new Error('報告に失敗しました。(不明なエラー)'));
                                    }
                                }).catch((code, err) => {
                                    console.warn(err);
                                    errorHandler(new Error(`報告に失敗しました。(${code})`));
                                });
                            }).catch(errorHandler);
                        });
                    });
                });
            });
        }
        /**
         * Check the block statuses of the reportees.
         * @param userInfoArray The `info` property array of the return value of {@link processIds}.
         * @returns An array of blocked users and IPs.
         */
        checkBlocks(userInfoArray) {
            const users = [];
            const ips = [];
            for (const user of userInfoArray) {
                if (!user) {
                    // Do nothing
                }
                else if (mw.util.isIPAddress(user, true)) {
                    if (!ips.includes(user))
                        ips.push(user);
                }
                else if (User.containsInvalidCharacter(user)) {
                    // Do nothing
                }
                else {
                    if (!users.includes(user))
                        users.push(user);
                }
            }
            const processUsers = (usersArr) => {
                if (!usersArr.length) {
                    return $.Deferred().resolve([]);
                }
                return lib.massRequest({
                    action: 'query',
                    list: 'blocks',
                    bkusers: usersArr,
                    bklimit: 'max',
                    formatversion: '2'
                }, 'bkusers')
                    .then((response) => {
                    return response.reduce((acc, res) => {
                        const resBk = res && res.query && res.query.blocks;
                        (resBk || []).forEach(({ user }) => {
                            if (user) {
                                acc.push(user);
                            }
                        });
                        return acc;
                    }, []);
                });
            };
            const processIps = (ipsArr) => {
                if (!ipsArr.length) {
                    return $.Deferred().resolve([]);
                }
                return lib.massRequest({
                    action: 'query',
                    list: 'blocks',
                    bkip: ipsArr,
                    bklimit: 1,
                    formatversion: '2'
                }, 'bkip', 1)
                    .then((response) => {
                    return response.reduce((acc, res, i) => {
                        const resBk = res && res.query && res.query.blocks;
                        if (resBk && resBk[0]) {
                            acc.push(ipsArr[i]);
                        }
                        return acc;
                    }, []);
                });
            };
            return $.when(processUsers(users), processIps(ips)).then((blockedUsers, blockedIps) => {
                const blocked = blockedUsers.concat(blockedIps);
                // Update block status info
                users.concat(ips).forEach((user) => {
                    if (Reporter.blockStatus[user]) {
                        Reporter.blockStatus[user].blocked = blocked.includes(user);
                    }
                });
                this.Users.forEach((U) => {
                    U.processTypeChange(); // Toggle the visibility of block status links
                });
                return blocked;
            });
        }
        /**
         * Check for duplicate reports.
         * @param data The return value of {@link collectData}.
         * @param info The partial return value of {@link processIds}.
         * @returns `string` if duplicate reports are found, a `Wikitext` instance if no duplicate reports are found,
         * `false` if the page isn't found, and `null` if there's an issue with the connection.
         */
        checkDuplicateReports(data, info) {
            return lib.Wikitext.newFromTitle(data.page).then((Wkt) => {
                // Wikitext instance failed to be initialized
                if (!Wkt)
                    return Wkt; // false or null
                // Find UserANs that contain duplicate reports
                const UserANs = Wkt.parseTemplates({
                    namePredicate: (name) => name === 'UserAN',
                    recursivePredicate: (Temp) => !Temp || Temp.getName('clean') !== 'UserAN',
                    hierarchy: [
                        ['1', 'user', 'User'],
                        ['t', 'type', 'Type'],
                        ['状態', 's', 'status', 'Status']
                    ],
                    templatePredicate: (Temp) => {
                        // Get 1= and t= parameter values of this UserAN
                        let param1 = '';
                        let paramT = 'User2';
                        let converted = null;
                        for (const { name, value } of Temp.args) {
                            if (value) {
                                if (name === '2') {
                                    return false; // Ignore closed ones
                                }
                                else if (/^(1|[uU]ser)$/.test(name)) {
                                    param1 = value;
                                }
                                else if (/^(t|[tT]ype)$/.test(name)) {
                                    if (/^(unl|usernolink)$/i.test(value)) {
                                        paramT = 'UNL';
                                    }
                                    else if (/^ip(user)?2$/i.test(value)) {
                                        paramT = 'IP2';
                                    }
                                    else if (/^log(id)?$/i.test(value)) {
                                        paramT = 'logid';
                                    }
                                    else if (/^diff(id)?$/i.test(value)) {
                                        paramT = 'diffid';
                                    }
                                    else if (/^none$/i.test(value)) {
                                        paramT = 'none';
                                    }
                                }
                            }
                        }
                        if (!param1) {
                            return false;
                        }
                        else {
                            param1 = User.formatName(param1);
                        }
                        if (['logid', 'diffid'].includes(paramT)) {
                            // Ensure the 1= param value is of numerals if the t= param value is 'logid' or 'diffid'
                            if (!/^\d+$/.test(param1)) {
                                return false;
                            }
                            else {
                                // If the script user has ever converted the ID to an username, get the username
                                converted = idList.getRegisteredUsername(parseInt(param1), paramT);
                            }
                        }
                        // Evaluation
                        const isDuplicate = data.users.some(({ user, type }) => {
                            switch (paramT) {
                                case 'UNL':
                                case 'User2':
                                case 'IP2':
                                case 'none':
                                    return user === param1 && /^(UNL|User2|IP2|none)$/.test(type) || info.includes(param1);
                                case 'logid':
                                case 'diffid':
                                    return user === param1 && type === paramT || converted && info.includes(converted);
                            }
                        });
                        return isDuplicate;
                    }
                });
                if (!UserANs.length)
                    return Wkt;
                // Highlight the duplicate UserANs
                let wikitext = Wkt.wikitext;
                const spanStart = '<span class="anr-preview-duplicate">';
                UserANs.reverse().forEach((Temp) => {
                    wikitext = Temp.replaceIn(wikitext, { with: spanStart + Temp.renderOriginal() + '</span>' });
                });
                if (wikitext === Wkt.wikitext)
                    return Wkt;
                // The sections in which to search for duplicate reports
                const tarSectionsAll = {
                    [ANI]: [
                        Reporter.getCurrentAniSection(true),
                        Reporter.getCurrentAniSection(false),
                        '不適切な利用者名',
                        '公開アカウント',
                        '公開プロキシ・ゾンビマシン・ボット・不特定多数',
                        '犯罪行為またはその疑いのある投稿'
                    ],
                    [ANS]: [
                        '著作権侵害・犯罪予告',
                        '名誉毀損・なりすまし・個人情報',
                        '妨害編集・いたずら',
                        'その他'
                    ],
                    [AN3RR]: ['3RR']
                };
                const testKey = formatANTEST(true);
                const tarSections = tarSectionsAll[(testKey || data.page)];
                if (!tarSections) {
                    console.error(`"tarSectionsAll['${data.page}']" is undefined.`);
                }
                else if ((data.page === ANS || testKey === ANS) && !tarSections.includes(data.section)) {
                    tarSections.push(data.section);
                }
                // Filter out the content of the relevant sections
                const ret = new lib.Wikitext(wikitext).parseSections().reduce((acc, { title, content }) => {
                    if (tarSections.includes(title) && content.includes(spanStart)) {
                        acc.push(content.trim());
                    }
                    return acc;
                }, []);
                if (!ret.length) {
                    return Wkt;
                }
                else {
                    return ret.join('\n\n');
                }
            });
        }
        /**
         * Preview duplicate reports.
         * @param data The return value of {@link collectData}.
         * @param wikitext The wikitext to parse as HTML.
         */
        previewDuplicateReports(data, wikitext) {
            // Create preview dialog
            const $preview = $('<div>')
                .css({
                maxHeight: '70vh',
                maxWidth: '80vw'
            })
                .dialog({
                dialogClass: 'anr-dialog anr-dialog-drpreview',
                title: ANR + ' - Duplicate report preview',
                height: 'auto',
                width: 'auto',
                modal: true,
                close: function () {
                    // Destory the dialog and its contents when closed by any means
                    $(this).empty().dialog('destroy');
                }
            });
            const $previewContent = $('<div>')
                .prop('id', 'anr-dialog-drpreview-content')
                .text('読み込み中')
                .append(getImage('load', 'margin-left: 0.5em;'));
            $preview.append($previewContent);
            // Parse wikitext to HTML
            new mw.Api().post({
                action: 'parse',
                title: data.page,
                text: wikitext,
                prop: 'text',
                disablelimitreport: true,
                disableeditsection: true,
                disabletoc: true,
                formatversion: '2'
            }).then((res) => {
                const content = res && res.parse && res.parse.text;
                if (content) {
                    // Append the parsed HTML to the preview dialog
                    const $body = $('<div>').prop('id', 'anr-dialog-drpreview-body');
                    $body.append(content);
                    $previewContent
                        .empty()
                        .append($body)
                        .find('a').prop('target', '_blank'); // Open all links on a new tab
                    $preview.dialog({
                        buttons: [
                            {
                                text: '閉じる',
                                click: () => {
                                    $preview.dialog('close');
                                }
                            }
                        ]
                    });
                    // Center the preview dialog and scroll to the first duplicate report
                    Reporter.centerDialog($preview, true);
                    Reporter.centerDialog($preview, true); // Necessary to call this twice for some reason
                    $('.anr-dialog-drpreview').children('.ui-dialog-content').eq(0).scrollTop($('.anr-preview-duplicate').position().top);
                }
                else {
                    throw new Error('action=parseのエラー');
                }
            }).catch((_, err) => {
                console.log(err);
                $previewContent
                    .empty()
                    .text('プレビューの読み込みに失敗しました。')
                    .append(getImage('cross', 'margin-left: 0.5em;'));
                $preview.dialog({
                    buttons: [
                        {
                            text: '閉じる',
                            click: () => {
                                $preview.dialog('close');
                            }
                        }
                    ]
                });
            });
        }
        /**
         * Watch user pages on report. If `data.watch` isn't a string (i.e. not a watch expiry), the method
         * will not send any API request of `action=watch`.
         * @param data The return value of {@link collectData}.
         * @param info The partial return value of {@link processIds}.
         * @returns
         */
        watchUsers(data, info) {
            if (!data.watch) {
                return;
            }
            const users = info.reduce((acc, val) => {
                if (val) {
                    const username = '利用者:' + val;
                    if (!acc.includes(username)) {
                        acc.push(username);
                    }
                }
                return acc;
            }, []);
            if (!users.length) {
                return;
            }
            new mw.Api().watch(users, data.watch);
        }
    }
    /**
     * Storage of the return value of {@link getBlockStatus}.
     *
     * This property is initialized every time when the constructor is called. This per se would tempt one to make the method non-static,
     * but this isn't an option because the property is accessed by {@link getBlockStatus}, which is a static method.
     */
    Reporter.blockStatus = {};
    let userPaneCnt = 0;
    /**
     * The User class. An instance of this handles a User field row on the Reporter dialog.
     */
    class User {
        /**
         * Create a user pane of the Reporter dialog with the following structure.
         * ```html
         * <div class="anr-option-row anr-option-userpane-wrapper">
         * 	<div class="anr-option-label">利用者</div> <!-- float: left; -->
         * 	<div class="anr-option-usertype"> <!-- float: right; -->
         * 		<select>...</select>
         * 	</div>
         * 	<div class="anr-option-wrapper"> <!-- overflow: hidden; -->
         * 		<input class="anr-option-username anr-juxtaposed"> <!-- width: 100%; -->
         * 	</div>
         * 	<!-- row boundary -->
         * 	<div class="anr-option-row-inner anr-option-hideuser-wrapper">
         * 		<div class="anr-option-label">&nbsp;</div> <!-- float: left; -->
         * 		<div class="anr-option-hideuser">
         * 			<label>
         * 				<input class="anr-checkbox">
         * 				<span class="anr-checkbox-label">利用者名を隠す</span>
         * 			</label>
         * 		</div>
         * 	</div>
         * 	<div class="anr-option-row-inner anr-option-idlink-wrapper">
         * 		<div class="anr-option-label">&nbsp;</div>
         * 		<div class="anr-option-idlink">
         * 			<a></a>
         * 		</div>
         * 	</div>
         * 	<div class="anr-option-row-inner anr-option-blockstatus-wrapper">
         * 		<div class="anr-option-label">&nbsp;</div>
         * 		<div class="anr-option-blockstatus">
         * 			<a>ブロックあり</a>
         * 		</div>
         * 	</div>
         * </div>
         * <!-- ADD BUTTON HERE -->
         * ```
         * @param $next The element before which to create a user pane.
         * @param options
         */
        constructor($next, options) {
            options = Object.assign({ removable: true }, options || {});
            // Create user pane row
            this.$wrapper = Reporter.createRow();
            this.$wrapper.addClass('anr-option-userpane-wrapper');
            this.$overlay = $('<div>');
            this.$overlay.addClass('anr-option-userpane-overlay');
            Reporter.toggle(this.$overlay, false);
            this.$wrapper.append(this.$overlay);
            // Append a label div
            this.id = 'anr-dialog-userpane-' + (userPaneCnt++);
            this.$label = Reporter.createRowLabel(this.$wrapper, '利用者').prop('id', this.id);
            if (options.removable) {
                this.$wrapper.addClass('anr-option-removable');
                this.$label
                    .prop('title', 'SHIFTクリックで除去')
                    .off('click').on('click', (e) => {
                    if (e.shiftKey) { // Remove the user pane when the label is shift-clicked
                        this.$wrapper.remove();
                        if (options && options.removeCallback) {
                            options.removeCallback(this);
                        }
                    }
                });
            }
            // Append a type dropdown
            const $typeWrapper = $('<div>').addClass('anr-option-usertype');
            this.$type = addOptions($('<select>'), ['UNL', 'User2', 'IP2', 'logid', 'diffid', 'none'].map((el) => ({ text: el })));
            this.$type // Initialize
                .prop('disabled', true) // Disable
                .off('change').on('change', () => {
                this.processTypeChange();
            })
                .children('option').eq(5).prop('selected', true); // Select 'none'
            $typeWrapper.append(this.$type);
            this.$wrapper.append($typeWrapper);
            // Append a username input
            this.$input = $('<input>');
            let inputTimeout;
            this.$input
                .addClass('anr-option-username') // Currently not used for anything
                .prop({
                type: 'text',
                placeholder: '入力してください'
            })
                .off('input').on('input', () => {
                clearTimeout(inputTimeout);
                inputTimeout = setTimeout(() => {
                    this.processInputChange();
                }, 350);
            });
            const $userWrapper = Reporter.wrapElement(this.$wrapper, this.$input);
            $next.before(this.$wrapper);
            let selectHeight;
            if ((selectHeight = this.$type.height()) > this.$input.height()) {
                this.$input.height(selectHeight);
            }
            Reporter.verticalAlign(this.$label, $userWrapper);
            // Append a hide-user checkbox
            this.$hideUserWrapper = Reporter.createRow();
            this.$hideUserWrapper.removeAttr('class').addClass('anr-option-row-inner anr-option-hideuser-wrapper');
            Reporter.createRowLabel(this.$hideUserWrapper, '');
            const hideUserElements = createLabelledCheckbox('利用者名を隠す', { alterClasses: ['anr-option-hideuser'] });
            this.$hideUser = hideUserElements.$checkbox;
            this.$hideUser.off('change').on('change', () => {
                this.processHideUserChange();
            });
            this.$hideUserLabel = hideUserElements.$label;
            this.$hideUserWrapper.append(hideUserElements.$wrapper);
            this.$wrapper.append(this.$hideUserWrapper);
            Reporter.toggle(this.$hideUserWrapper, false);
            // Append an ID link
            this.$idLinkWrapper = Reporter.createRow();
            this.$idLinkWrapper.removeAttr('class').addClass('anr-option-row-inner anr-option-idlink-wrapper');
            Reporter.createRowLabel(this.$idLinkWrapper, '');
            this.$idLink = $('<a>');
            this.$idLink.prop('target', '_blank');
            this.$idLinkWrapper.append($('<div>').addClass('anr-option-idlink').append(this.$idLink));
            this.$wrapper.append(this.$idLinkWrapper);
            Reporter.toggle(this.$idLinkWrapper, false);
            // Append a block status link
            this.$blockStatusWrapper = Reporter.createRow();
            this.$blockStatusWrapper.removeAttr('class').addClass('anr-option-row-inner anr-option-blockstatus-wrapper');
            Reporter.createRowLabel(this.$blockStatusWrapper, '');
            this.$blockStatus = $('<a>');
            this.$blockStatus.prop('target', '_blank').text('ブロックあり');
            this.$blockStatusWrapper.append($('<div>').addClass('anr-option-blockstatus').append(this.$blockStatus));
            this.$wrapper.append(this.$blockStatusWrapper);
            Reporter.toggle(this.$blockStatusWrapper, false);
            if (options.addCallback) {
                options.addCallback(this);
            }
        }
        /**
         * Format a username by calling `lib.clean`, replacing underscores with spaces, and capitalizing the first letter.
         * If the username is an IPv6 address, all letters will be captalized.
         * @param username
         * @returns The formatted username.
         */
        static formatName(username) {
            let user = lib.clean(username.replace(/_/g, ' '));
            if (mw.util.isIPv6Address(user, true)) {
                user = user.toUpperCase();
            }
            else if (!/^[\u10A0-\u10FF]/.test(user)) { // ucFirst, except for Georgean letters
                user = mwString.ucFirst(user);
            }
            return user;
        }
        /**
         * Get the username in the textbox (underscores are replaced by spaces).
         * @returns
         */
        getName() {
            return User.formatName(this.$input.val()) || null;
        }
        /**
         * Set a value into the username input. Note that this method does not call {@link processInputChange}.
         * @param val
         * @returns
         */
        setName(val) {
            this.$input.val(val);
            return this;
        }
        /**
         * Get the UserAN type selected in the dropdown.
         * @returns
         */
        getType() {
            return this.$type.val();
        }
        /**
         * Select a type in the UserAN type dropdown. Note that this method does not call {@link processTypeChange}.
         * @param type
         * @returns
         */
        setType(type) {
            this.$type.val(type);
            return this;
        }
        /**
         * Change the hidden state of the options in the type dropdown.
         * @param types An array of type options to make visible. The element at index 0 will be selected.
         * @returns
         */
        setTypeOptions(types) {
            this.$type.children('option').each((_, opt) => {
                // Set up the UserAN type dropdown
                const idx = types.indexOf(opt.value);
                opt.hidden = idx === -1; // Show/hide options
                if (idx === 0) {
                    opt.selected = true; // Select types[0]
                }
            });
            return this;
        }
        /**
         * Update the visibility of auxiliary wrappers when the selection is changed in the type dropdown.
         * @returns
         */
        processTypeChange() {
            const selectedType = this.processAuxiliaryElements().getType();
            this.$type.toggleClass('anr-option-usertype-none', false);
            switch (selectedType) {
                case 'UNL':
                case 'User2':
                    Reporter.toggle(this.$hideUserWrapper, true);
                    Reporter.toggle(this.$idLinkWrapper, false);
                    Reporter.toggle(this.$blockStatusWrapper, !!this.$blockStatus.text());
                    break;
                case 'IP2':
                    Reporter.toggle(this.$hideUserWrapper, false);
                    Reporter.toggle(this.$idLinkWrapper, false);
                    Reporter.toggle(this.$blockStatusWrapper, !!this.$blockStatus.text());
                    break;
                case 'logid':
                case 'diffid':
                    Reporter.toggle(this.$hideUserWrapper, true);
                    Reporter.toggle(this.$idLinkWrapper, true);
                    Reporter.toggle(this.$blockStatusWrapper, !!this.$blockStatus.text());
                    break;
                default: // 'none'
                    Reporter.toggle(this.$hideUserWrapper, false);
                    Reporter.toggle(this.$idLinkWrapper, false);
                    Reporter.toggle(this.$blockStatusWrapper, false);
                    this.$type.toggleClass('anr-option-usertype-none', !this.$type.prop('disabled'));
            }
            return this;
        }
        /**
         * Update the properties of auxiliary elements in the user pane.
         * - Toggle the application of a red border on the username input.
         * - Toggle the checked and disabled states of the hideuser checkbox.
         * - Change the display text, the href, and the disabled state of the event ID link.
         * - Set up the display text and the href of the block status link (by {@link processBlockStatus}).
         * @returns
         */
        processAuxiliaryElements() {
            const selectedType = this.getType();
            const inputVal = this.getName() || '';
            const clss = 'anr-option-invalidid';
            if (['logid', 'diffid'].includes(selectedType)) {
                // Set up $input, $hideUser, and $idLink
                const isNotNumber = !/^\d*$/.test(inputVal);
                this.$input.toggleClass(clss, isNotNumber);
                this.$hideUser.prop({
                    disabled: isNotNumber,
                    checked: true
                });
                const idTitle = (selectedType === 'logid' ? '特別:転送/logid/' : '特別:差分/') + inputVal;
                this.$idLink
                    .text(idTitle)
                    .prop('href', mw.util.getUrl(idTitle))
                    .toggleClass('anr-disabledanchor', isNotNumber);
                // Set up $blockStatus
                if (!isNotNumber) {
                    const username = idList.getRegisteredUsername(parseInt(inputVal), selectedType);
                    if (username) {
                        this.processBlockStatus(username);
                    }
                    else {
                        this.$blockStatus.text('');
                    }
                }
            }
            else {
                this.$input.toggleClass(clss, false);
                this.$hideUser.prop({
                    disabled: false,
                    checked: false
                });
                this.$idLink.toggleClass('anr-disabledanchor', false);
                this.processBlockStatus(inputVal);
            }
            return this;
        }
        /**
         * Set up the display text and the href of the block status link
         * @param username
         * @returns
         */
        processBlockStatus(username) {
            username = User.formatName(username);
            const status = Reporter.blockStatus[username];
            if (status) {
                if (status.usertype === 'user' || status.usertype === 'ip') {
                    this.$blockStatus.prop('href', mw.util.getUrl('特別:投稿記録/' + username));
                    switch (status.blocked) {
                        case true:
                            this.$blockStatus.text('ブロックあり');
                            break;
                        case false:
                            this.$blockStatus.text('');
                            break;
                        default: // null
                            this.$blockStatus.text('ブロック状態不明');
                    }
                }
                else { // other
                    this.$blockStatus.text('');
                }
            }
            else { // Block status yet to be fetched
                this.$blockStatus.text('');
            }
            return this;
        }
        /**
         * Evaluate the input value, figure out its user type (and block status if relevant), and change selection
         * in the type dropdown (which proceeds to {@link processTypeChange}).
         * @returns
         */
        processInputChange() {
            const def = $.Deferred();
            const typeMap = {
                ip: ['IP2', 'none'],
                user: ['UNL', 'User2', 'none'],
                other: ['none', 'logid', 'diffid']
            };
            const username = this.getName();
            if (!username) { // Blank
                this.setType('none').$type.prop('disabled', true); // Disable dropdown and select 'none'
                this.processTypeChange();
                def.resolve(this);
            }
            else { // Some username is in the input
                Reporter.getBlockStatus(username).then((obj) => {
                    if (/^\d+$/.test(username) && obj.usertype === 'user') {
                        typeMap.user.push('logid', 'diffid');
                    }
                    this.setTypeOptions(typeMap[obj.usertype]).$type.prop('disabled', false);
                    this.processTypeChange();
                    def.resolve(this);
                });
            }
            return def.promise();
        }
        /**
         * Process the change event of the hideuser checkbox and do a username-ID conversion.
         * @returns
         */
        processHideUserChange() {
            // Show a spinner aside the hideuser checkbox label
            const $processing = $(getImage('load', 'margin-left: 0.5em;'));
            this.$hideUserLabel.append($processing);
            this.setOverlay(true);
            /*!
             * Error handlers. If the catch block is ever reached, there should be some problem with either processInputChange
             * or processTypeChange because the hideuser checkbox should be unclickable when the variables would be substituted
             * by an unexpected value.
             */
            const inputVal = this.getName();
            const selectedType = this.getType();
            const checked = this.$hideUser.prop('checked');
            try {
                if (typeof inputVal !== 'string') {
                    // The username input should never be empty
                    throw new TypeError('User.getName returned null.');
                }
                else if (!checked && !['logid', 'diffid'].includes(selectedType)) {
                    // The type dropdown should have either value when the box can be unchecked
                    throw new Error('User.getType returned neither "logid" nor "diffid".');
                }
                else if (!checked && !/^\d+$/.test(inputVal)) {
                    // The username input should only be of numbers when the box can be unchecked
                    throw new Error('User.getName returned a non-number.');
                }
            }
            catch (err) {
                console.error(err);
                mw.notify('変換試行時にエラーが発生しました。スクリプトのバグの可能性があります。', { type: 'error' });
                this.$hideUser.prop('checked', !checked);
                $processing.remove();
                this.setOverlay(false);
                return $.Deferred().resolve(this);
            }
            if (checked) { // username to ID
                return idList.getIds(inputVal).then(({ logid, diffid }) => {
                    if (typeof logid === 'number') {
                        this.setName(logid.toString()).setTypeOptions(['logid', 'diffid', 'none']).processTypeChange();
                        mw.notify(`利用者名「${inputVal}」をログIDに変換しました。`, { type: 'success' });
                    }
                    else if (typeof diffid === 'number') {
                        this.setName(diffid.toString()).setTypeOptions(['diffid', 'logid', 'none']).processTypeChange();
                        mw.notify(`利用者名「${inputVal}」を差分IDに変換しました。`, { type: 'success' });
                    }
                    else {
                        this.$hideUser.prop('checked', !checked);
                        mw.notify(`利用者名「${inputVal}」をIDに変換できませんでした。`, { type: 'warn' });
                    }
                    $processing.remove();
                    return this.setOverlay(false);
                });
            }
            else { // ID to username
                const idTypeJa = selectedType === 'logid' ? 'ログ' : '差分';
                return idList.getUsername(parseInt(inputVal), selectedType).then((username) => {
                    if (username) {
                        return this.setName(username).processInputChange().then(() => {
                            mw.notify(`${idTypeJa}ID「${inputVal}」を利用者名に変換しました。`, { type: 'success' });
                            $processing.remove();
                            return this.setOverlay(false);
                        });
                    }
                    else {
                        this.$hideUser.prop('checked', !checked);
                        mw.notify(`${idTypeJa}ID「${inputVal}」を利用者名に変換できませんでした。`, { type: 'warn' });
                        $processing.remove();
                        return this.setOverlay(false);
                    }
                });
            }
        }
        /**
         * Toggle the visibility of the overlay.
         * @param show
         * @returns
         */
        setOverlay(show) {
            Reporter.toggle(this.$overlay, show);
            return this;
        }
        /**
         * Check the validity of a username (by checking the inclusion of `/[@/#<>[\]|{}:]/`).
         *
         * Note that IP(v6) addresses should not be passed.
         * @param username
         * @returns
         */
        static containsInvalidCharacter(username) {
            return /[@/#<>[\]|{}:]/.test(username);
        }
    }
    /**
     * Get an \<img> tag.
     * @param iconType
     * @param cssText Additional styles to apply (Default styles: `vertical-align: middle; height: 1em; border: 0;`)
     * @returns
     */
    function getImage(iconType, cssText = '') {
        const img = (() => {
            if (iconType === 'load' || iconType === 'check' || iconType === 'cross' || iconType === 'cancel') {
                return lib.getIcon(iconType);
            }
            else {
                const tag = document.createElement('img');
                switch (iconType) {
                    case 'gear':
                        tag.src = 'https://upload.wikimedia.org/wikipedia/commons/0/05/OOjs_UI_icon_advanced.svg';
                        break;
                    case 'exclamation':
                        tag.src = 'https://upload.wikimedia.org/wikipedia/commons/c/c6/OOjs_UI_icon_alert-warning-black.svg';
                        break;
                    case 'bar':
                        tag.src = 'https://upload.wikimedia.org/wikipedia/commons/e/e5/OOjs_UI_icon_subtract.svg';
                        break;
                    case 'clock':
                        tag.src = 'https://upload.wikimedia.org/wikipedia/commons/8/85/OOjs_UI_icon_clock-progressive.svg';
                }
                tag.style.cssText = 'vertical-align: middle; height: 1em; border: 0;';
                return tag;
            }
        })();
        img.style.cssText += cssText;
        return img;
    }
    /**
     * Add \<option>s to a dropdown by referring to object data.
     * @param $dropdown
     * @param data `text` is obligatory, and the other properties are optional.
     * @returns The passed dropdown.
     */
    function addOptions($dropdown, data) {
        data.forEach(({ text, value, disabled, selected, hidden }) => {
            const option = document.createElement('option');
            option.textContent = text;
            if (value !== undefined) {
                option.value = value;
            }
            option.disabled = !!disabled;
            option.selected = !!selected;
            option.hidden = !!hidden;
            $dropdown[0].add(option);
        });
        return $dropdown;
    }
    let checkboxCnt = 0;
    /**
     * Create a labelled checkbox.
     * ```html
     * <div class="anr-option-row">
     * 	<label>
     * 		<input class="anr-checkbox">
     * 		<span class="anr-checkbox-label">labelText</span>
     * 	</label>
     * </div>
     * ```
     * @param labelText The label text.
     * @param options
     * @returns
     */
    function createLabelledCheckbox(labelText, options = {}) {
        const id = options.checkboxId && !document.getElementById(options.checkboxId) ? options.checkboxId : 'anr-checkbox-' + (checkboxCnt++);
        const $outerLabel = $('<label>');
        $outerLabel.attr('for', id);
        const $wrapper = Reporter.createRow();
        $wrapper.removeAttr('class').addClass((options.alterClasses || ['anr-option-row']).join(' ')).append($outerLabel);
        const $checkbox = $('<input>');
        $checkbox
            .prop({
            id,
            type: 'checkbox'
        })
            .addClass('anr-checkbox');
        const $label = $('<span>');
        $label.addClass('anr-checkbox-label').text(labelText);
        $outerLabel.append($checkbox, $label);
        return { $wrapper, $checkbox, $label };
    }
    /**
     * Extract a CIDR address from text.
     *
     * Regular expressions used in this method are adapted from `mediawiki.util`.
     * - {@link https://doc.wikimedia.org/mediawiki-core/REL1_41/js/#!/api/mw.util-method-isIPv4Address | mw.util.isIPv4Address}
     * - {@link https://doc.wikimedia.org/mediawiki-core/REL1_41/js/#!/api/mw.util-method-isIPv6Address | mw.util.isIPv6Address}
     *
     * @param text
     * @returns The extracted CIDR, or `null` if there's no match.
     */
    function extractCidr(text) {
        const v4_byte = '(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|0?[0-9]?[0-9])';
        const v4_regex = new RegExp('(?:' + v4_byte + '\\.){3}' + v4_byte + '\\/(?:3[0-2]|[12]?\\d)');
        const v6_block = '\\/(?:12[0-8]|1[01][0-9]|[1-9]?\\d)';
        const v6_regex = new RegExp('(?::(?::|(?::[0-9A-Fa-f]{1,4}){1,7})|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){0,6}::|[0-9A-Fa-f]{1,4}(?::[0-9A-Fa-f]{1,4}){7})' +
            v6_block);
        const v6_regex2 = new RegExp('[0-9A-Fa-f]{1,4}(?:::?[0-9A-Fa-f]{1,4}){1,6}' + v6_block);
        let m;
        if ((m = text.match(v4_regex)) ||
            (m = text.match(v6_regex)) ||
            (m = text.match(v6_regex2)) && /::/.test(m[0]) && !/::.*::/.test(m[0])) {
            return m[0];
        }
        else {
            return null;
        }
    }
    // ******************************************************************************************
    // Entry point
    init();
    // ******************************************************************************************
})();
//</nowiki>