用户:Hehua/deletebycategory.js

来自Postal Wiki
Hehua​(留言 | 贡献)2026年11月3日 (二) 07:50的版本 (创建页面,内容为“/*! * MediaWiki 小工具:按分类批量删除页面 * --------------------------------------------------------------------------- * 用途:列出某个分类(默认 Category:快速删除候选)内的页面,在原生 OOUI 界面中 * 勾选后批量删除;可选同时删除对应讨论页、以及指向这些页面的重定向。 * * 安装方式(任选其一): * 1) 粘贴到 MediaWiki:Common.js(需 interface-admin / syso…”)
(差异) ←上一版本 | 最后版本 (差异) | 下一版本→ (差异)

注意:在发布之后,您可能需要清除浏览器缓存才能看到所作出的更改的影响。

  • Firefox或Safari:按住Shift的同时单击刷新,或按Ctrl-F5或Ctrl-R(Mac为⌘-R)
  • Google Chrome:按Ctrl-Shift-R(Mac为⌘-Shift-R)
  • Edge:按住Ctrl的同时单击刷新,或按Ctrl-F5。
/*!
 * MediaWiki 小工具:按分类批量删除页面
 * ---------------------------------------------------------------------------
 * 用途:列出某个分类(默认 Category:快速删除候选)内的页面,在原生 OOUI 界面中
 *       勾选后批量删除;可选同时删除对应讨论页、以及指向这些页面的重定向。
 *
 * 安装方式(任选其一):
 *   1) 粘贴到 [[MediaWiki:Common.js]](需 interface-admin / sysop 权限);
 *   2) 粘贴到你的 [[Special:MyPage/common.js]],仅自己可用;
 *   3) 放在 [[MediaWiki:Gadget-xxx.js]] 并在 [[MediaWiki:Gadgets-definition]] 中启用;
 *   4) 其他站点用 mw.loader.load( 'https://你的站点/...js' ) 或 importScript 引。
 *
 * 权限要求:当前用户必须拥有 delete 权限(管理员),否则删除会返回 permissiondenied。
 *
 * 使用:任意页面左侧工具栏(工具箱)“批量删除分类页面”,点击后弹出对话框。
 *       也可在控制台执行 mw.libs.batchDeleteByCategory.open()。
 *
 * 可用配置(放在脚本加载之前即可,全部可选):
 *   window.BatchDeleteByCategoryConfig = {
 *       defaultCategory: 'Category:快速删除候选', // 默认分类
 *       defaultReason:   '',                      // 默认删除理由
 *       portletLabel:    '批量删除分类页面',      // 侧栏链接文字
 *       portletId:       't-batch-delete-category',
 *       pageLimit:       2000,                    // 单次最多载入页面数
 *       delay:           250,                     // 每次 API 请求间隔(ms),防限流
 *       watchlist:       'nochange',              // nochange / preferences / watch
 *       talkReasonSuffix:     '(删除讨论页)',
 *       redirectReasonSuffix: '(清理重定向)',
 *       addPortletLink:  true,                    // 是否自动添加侧栏链接
 *       checkRights:     true                     // 打开时检查是否有 delete 权限
 *   };
 *
 * 依赖:mediawiki.api、oojs-ui(1.35+ 均自带)。
 * 许可证:CC0 / 公有领域,随意修改。
 */
( function () {
	'use strict';

	if ( typeof mw === 'undefined' || !mw.loader || !window.jQuery || !mw.Api ) {
		return;
	}

	var $ = window.jQuery;
	var api = new mw.Api();

	/* ========================================================================
	 * 配置
	 * ===================================================================== */

	var conf = $.extend( {
		defaultCategory: 'Category:快速删除候选',
		defaultReason: '',
		portletLabel: '批量删除分类页面',
		portletId: 't-batch-delete-category',
		pageLimit: 2000,
		delay: 250,
		watchlist: 'nochange',
		talkReasonSuffix: '(删除讨论页)',
		redirectReasonSuffix: '(清理重定向)',
		addPortletLink: true,
		checkRights: true
	}, window.BatchDeleteByCategoryConfig || {} );

	var STATUS_TEXT = {
		ok: '成功',
		missing: '跳过',
		skip: '跳过',
		fail: '失败',
		dry: '试运行'
	};

	// 这些错误说明令牌/权限/数据库整体不可用,继续跑下去没有意义,直接中止整批
	var FATAL_CODES = {
		permissiondenied: true,
		badtoken: true,
		readonly: true,
		blocked: true,
		autoblocked: true,
		mustbeloggedin: true,
		'internal_api_error_DBQueryError': true
	};

	/* ========================================================================
	 * 通用工具
	 * ===================================================================== */

	function sleep( ms ) {
		var d = $.Deferred();
		window.setTimeout( function () {
			d.resolve();
		}, ms );
		return d.promise();
	}

	// 串行执行,保证请求有间隔、日志顺序正确
	function eachSeq( list, fn ) {
		var d = $.Deferred();
		var i = 0;

		function next() {
			if ( i >= list.length ) {
				d.resolve();
				return;
			}
			var item = list[ i ];
			i++;
			var result;
			try {
				result = fn( item );
			} catch ( e ) {
				d.reject( e );
				return;
			}
			$.when( result ).then( next, function ( e ) {
				d.reject( e );
			} );
		}

		next();
		return d.promise();
	}

	function categoryNamespace() {
		var ns = mw.config.get( 'wgFormattedNamespaces' ) || {};
		return ns[ 14 ] || ns[ '14' ] || 'Category';
	}

	// 允许只填“快速删除候选”,自动补上分类命名空间
	function normalizeCategory( value ) {
		var text = $.trim( value || '' ).replace( /_/g, ' ' );
		if ( !text ) {
			return '';
		}
		if ( text.indexOf( ':' ) === -1 ) {
			text = categoryNamespace() + ':' + text;
		}
		return text;
	}

	// 取讨论页标题;没有对应讨论页(如讨论页自身/特殊页)时返回 null
	function talkTitleOf( title ) {
		var t = mw.Title.newFromText( title );
		if ( !t ) {
			return null;
		}
		var talk = t.getTalkPage();
		if ( !talk ) {
			return null;
		}
		var talkText = talk.getPrefixedText();
		return talkText === t.getPrefixedText() ? null : talkText;
	}

	/* ========================================================================
	 * API 封装
	 * ===================================================================== */

	// 读取分类成员;返回 { pages: [...], subcats: [...] }
	function fetchCategoryMembers( cat ) {
		var result = { pages: [], subcats: [] };

		function step( cont ) {
			var params = {
				action: 'query',
				formatversion: 2,
				generator: 'categorymembers',
				gcmtitle: cat,
				gcmtype: 'page|subcat|file',
				gcmlimit: 'max',
				prop: 'info'
			};
			if ( cont ) {
				$.extend( params, cont );
			}
			return api.get( params ).then( function ( data ) {
				var pages = ( data.query && data.query.pages ) || [];
				pages.forEach( function ( p ) {
					if ( p.ns === 14 ) {
						result.subcats.push( p.title );
					} else if ( !p.missing ) {
						result.pages.push( {
							title: p.title,
							ns: p.ns,
							redirect: !!p.redirect,
							length: p.length || 0
						} );
					}
				} );
				if ( data.continue ) {
					return step( data.continue );
				}
				return result;
			} );
		}

		return step();
	}

	// 递归收集分类内页面(BFS,带深度限制)
	function collectPages( rootCat, opts, onStatus ) {
		var d = $.Deferred();
		var pages = [];
		var seen = {};
		var visitedCats = {};
		var allSubcats = {};
		var queue = [ { cat: rootCat, depth: 0 } ];

		function addPages( list ) {
			list.forEach( function ( p ) {
				if ( pages.length >= opts.limit || seen[ p.title ] ) {
					return;
				}
				seen[ p.title ] = true;
				pages.push( p );
			} );
		}

		function next() {
			if ( !queue.length || pages.length >= opts.limit ) {
				d.resolve( { pages: pages, subcats: Object.keys( allSubcats ) } );
				return;
			}
			var item = queue.shift();
			var key = item.cat.toLowerCase();
			if ( visitedCats[ key ] ) {
				return next();
			}
			visitedCats[ key ] = true;

			onStatus( '正在读取 ' + item.cat + ' …' );

			fetchCategoryMembers( item.cat ).then( function ( res ) {
				addPages( res.pages );
				res.subcats.forEach( function ( sc ) {
					allSubcats[ sc ] = true;
					if ( opts.recursive && item.depth < opts.depth ) {
						queue.push( { cat: sc, depth: item.depth + 1 } );
					}
				} );
				next();
			}, function ( code, data ) {
				// 分类不存在/标题非法时忽略,继续处理队列中的其他分类
				if ( code === 'invalidtitle' || code === 'invalidcategory' ||
					( data && data.error && /invalid/i.test( data.error.code || '' ) ) ) {
					next();
					return;
				}
				d.reject( code, data );
			} );
		}

		next();
		return d.promise();
	}

	// 列出指向该标题的所有重定向
	function getRedirectsTo( title ) {
		var result = [];

		function step( cont ) {
			var params = {
				action: 'query',
				formatversion: 2,
				titles: title,
				prop: 'redirects',
				rdprop: 'title|fragment',
				rdlimit: 'max'
			};
			if ( cont ) {
				$.extend( params, cont );
			}
			return api.get( params ).then( function ( data ) {
				var page = data.query && data.query.pages && data.query.pages[ 0 ];
				if ( page && page.redirects ) {
					page.redirects.forEach( function ( r ) {
						result.push( r.title );
					} );
				}
				if ( data.continue ) {
					return step( data.continue );
				}
				return result;
			} );
		}

		return step();
	}

	// 删除单个页面;返回 { title, status, code, info },不向外抛错
	function deletePage( title, reason, dryRun, watchlist ) {
		if ( dryRun ) {
			return $.Deferred().resolve( { title: title, status: 'dry' } ).promise();
		}
		return api.postWithToken( 'csrf', {
			action: 'delete',
			title: title,
			reason: reason || '',
			watchlist: watchlist || 'nochange'
		} ).then( function () {
			return { title: title, status: 'ok' };
		}, function ( code, data ) {
			if ( code === 'missingtitle' ) {
				return { title: title, status: 'missing', info: '页面不存在' };
			}
			var info = ( data && data.error && data.error.info ) || code;
			return { title: title, status: 'fail', code: code, info: info };
		} );
	}

	/* ========================================================================
	 * 删除流程(单页 / 批量)
	 * ===================================================================== */

	function processOne( item, o, report ) {
		var redirects = [];
		var mainStatus = null;
		var chain = $.Deferred().resolve().promise();

		// 1. 先把重定向列表取出来(页面删掉后就查不到了)
		if ( o.deleteRedirects ) {
			chain = chain.then( function () {
				return getRedirectsTo( item.title ).then( function ( list ) {
					redirects = list;
				}, function () {
					redirects = [];
				} );
			} );
		}

		// 2. 删除主页面
		chain = chain.then( function () {
			return deletePage( item.title, o.reason, o.dryRun, o.watchlist ).then( function ( r ) {
				mainStatus = r.status;
				report( { title: r.title, status: r.status, code: r.code, info: r.info, kind: '页面' } );
			} );
		} );

		// 3. 删除对应讨论页
		if ( o.deleteTalk ) {
			chain = chain.then( function () {
				var talk = talkTitleOf( item.title );
				if ( !talk ) {
					return null;
				}
				if ( mainStatus === 'fail' ) {
					report( { title: talk, status: 'skip', info: '主页面删除失败,已跳过讨论页', kind: '讨论页' } );
					return null;
				}
				return deletePage( talk, o.talkReason, o.dryRun, o.watchlist ).then( function ( r ) {
					report( { title: r.title, status: r.status, code: r.code, info: r.info, kind: '讨论页' } );
				} );
			} );
		}

		// 4. 删除指向该页面的重定向
		if ( o.deleteRedirects ) {
			chain = chain.then( function () {
				if ( !redirects.length || mainStatus === 'fail' ) {
					return null;
				}
				return eachSeq( redirects, function ( rd ) {
					if ( rd === item.title ) {
						return null;
					}
					return deletePage( rd, o.redirectReason, o.dryRun, o.watchlist ).then( function ( r ) {
						report( { title: r.title, status: r.status, code: r.code, info: r.info, kind: '重定向' } );
					} );
				} );
			} );
		}

		return chain;
	}

	function runBatch( items, o, hooks ) {
		var d = $.Deferred();
		var i = 0;
		var fatal = false;

		function step() {
			if ( fatal || hooks.shouldAbort() ) {
				d.resolve();
				return;
			}
			if ( i >= items.length ) {
				d.resolve();
				return;
			}
			var item = items[ i ];
			i++;
			hooks.onItem( i, items.length, item.title );

			processOne( item, o, function ( entry ) {
				if ( entry.code && FATAL_CODES[ entry.code ] ) {
					fatal = true;
				}
				hooks.onReport( entry );
			} ).then( function () {
				if ( fatal ) {
					hooks.onFatal();
					d.resolve();
					return;
				}
				sleep( o.delay ).then( step );
			}, function ( e ) {
				d.reject( e );
			} );
		}

		step();
		return d.promise();
	}

	/* ========================================================================
	 * 样式
	 * ===================================================================== */

	function injectStyles() {
		if ( document.getElementById( 'bdbc-styles' ) ) {
			return;
		}
		var css = [
			'.bdbc-list{border:1px solid var(--border-color-subtle,#c8ccd1);border-radius:2px;',
			'max-height:38vh;overflow-y:auto;padding:4px 8px;margin:6px 0;',
			'background:var(--background-color-base,#fff);color:var(--color-base,#202122);}',
			'.bdbc-row{display:flex;align-items:center;gap:6px;padding:1px 0;line-height:1.75;font-size:.92em;}',
			'.bdbc-row:hover{background:var(--background-color-interactive-subtle,#eaf3ff);}',
			'.bdbc-row input{margin:0 2px 0 0;vertical-align:middle;}',
			'.bdbc-row .bdbc-title{flex:1 1 auto;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}',
			'.bdbc-row .bdbc-tag{color:var(--color-subtle,#72777d);font-size:.86em;white-space:nowrap;}',
			'.bdbc-row-done{opacity:.55;text-decoration:line-through;}',
			'.bdbc-log{display:none;max-height:16vh;overflow-y:auto;border:1px solid var(--border-color-subtle,#c8ccd1);',
			'border-radius:2px;padding:6px 8px;margin-top:8px;font-family:monospace,monospace;font-size:.8em;',
			'line-height:1.6;background:var(--background-color-neutral-subtle,#f8f9fa);color:var(--color-base,#202122);}',
			'.bdbc-log .bdbc-ok{color:var(--color-success,#14866d);}',
			'.bdbc-log .bdbc-fail{color:var(--color-error,#d73333);}',
			'.bdbc-log .bdbc-skip{color:var(--color-subtle,#72777d);}',
			'.bdbc-selection{margin-left:8px;color:var(--color-subtle,#54595d);}',
			'.bdbc-muted{color:var(--color-subtle,#72777d);font-size:.85em;margin-top:4px;}',
			'.bdbc-inline{display:flex;align-items:center;flex-wrap:wrap;gap:10px;margin-top:4px;}',
			'.bdbc-inline > .oo-ui-widget{flex:0 0 auto;margin:0;}',
			'.bdbc-inline .oo-ui-dropdownInputWidget{width:7.5em;}',
			'.bdbc-section{margin-top:12px;font-weight:bold;}'
		].join( '' );

		var style = document.createElement( 'style' );
		style.id = 'bdbc-styles';
		style.textContent = css;
		document.head.appendChild( style );
	}

	/* ========================================================================
	 * 确认对话框(MessageDialog 子类,点“确认删除”后执行回调)
	 * ===================================================================== */

	/* ========================================================================
	 * 主对话框
	 * ===================================================================== */

	function Dialog( config ) {
		Dialog.super.call( this, config );
	}
	OO.inheritClass( Dialog, OO.ui.ProcessDialog );
	Dialog.static.name = 'batchDeleteByCategory';
	Dialog.static.title = '批量删除分类内页面';
	Dialog.static.actions = [
		{ action: 'close', label: '关闭', flags: 'safe' },
		{ action: 'abort', label: '停止', disabled: true },
		{ action: 'delete', label: '删除选中页面', flags: [ 'primary', 'destructive' ], disabled: true }
	];

	Dialog.prototype.initialize = function () {
		var dialog = this;

		Dialog.super.prototype.initialize.call( this );

		this.pages = [];
		this.running = false;
		this.aborted = false;
		this.confirming = false;

		/* ---------- 分类输入 ---------- */
		this.categoryInput = new OO.ui.TextInputWidget( {
			value: conf.defaultCategory,
			placeholder: 'Category:快速删除候选'
		} );
		this.categoryInput.$element.on( 'keydown', function ( e ) {
			if ( e.which === 13 ) {
				dialog.loadPages();
			}
		} );

		this.loadButton = new OO.ui.ButtonWidget( {
			label: '载入页面',
			flags: [ 'primary', 'progressive' ]
		} );
		this.loadButton.on( 'click', function () {
			dialog.loadPages();
		} );

		this.recursiveCheck = new OO.ui.CheckboxInputWidget( { selected: false } );
		this.depthSelect = new OO.ui.DropdownInputWidget( {
			options: [
				{ data: '1', label: '1 层' },
				{ data: '2', label: '2 层' },
				{ data: '3', label: '3 层' },
				{ data: '5', label: '5 层' }
			],
			value: '1',
			disabled: true
		} );
		this.recursiveCheck.on( 'change', function ( selected ) {
			dialog.depthSelect.setDisabled( !selected );
		} );

		this.categoryField = new OO.ui.FieldLayout( this.categoryInput, {
			label: '分类名称',
			align: 'top',
			help: '可省略「分类:」前缀。'
		} );

		this.recursiveField = new OO.ui.FieldLayout( this.recursiveCheck, {
			label: '包含子分类',
			align: 'inline'
		} );
		this.depthLabel = new OO.ui.LabelWidget( { label: '深度' } );

		this.loadRow = new OO.ui.HorizontalLayout( {
			items: [ this.loadButton, this.recursiveField, this.depthLabel, this.depthSelect ]
		} );
		this.loadRow.$element.addClass( 'bdbc-inline' );

		/* ---------- 删除选项 ---------- */
		this.talkCheck = new OO.ui.CheckboxInputWidget( { selected: false } );
		this.redirectCheck = new OO.ui.CheckboxInputWidget( { selected: false } );
		this.dryRunCheck = new OO.ui.CheckboxInputWidget( { selected: false } );
		this.reasonInput = new OO.ui.TextInputWidget( {
			value: conf.defaultReason,
			placeholder: '删除理由(可留空)'
		} );

		this.optionsRow = new OO.ui.HorizontalLayout( {
			items: [
				new OO.ui.FieldLayout( this.talkCheck, {
					label: '同时删除讨论页',
					align: 'inline'
				} ),
				new OO.ui.FieldLayout( this.redirectCheck, {
					label: '同时删除指向它们的重定向',
					align: 'inline'
				} ),
				new OO.ui.FieldLayout( this.dryRunCheck, {
					label: '试运行(不实际删除)',
					align: 'inline'
				} )
			]
		} );
		this.optionsRow.$element.addClass( 'bdbc-inline' );

		this.optionsFieldset = new OO.ui.FieldsetLayout( { label: '删除选项' } );
		this.optionsFieldset.addItems( [
			this.optionsRow,
			new OO.ui.FieldLayout( this.reasonInput, { label: '删除理由', align: 'top' } )
		] );

		/* ---------- 页面列表 ---------- */
		this.filterInput = new OO.ui.SearchInputWidget( {
			placeholder: '筛选标题…',
			disabled: true
		} );
		this.filterInput.on( 'change', function () {
			dialog.applyFilter();
		} );

		this.selectAllButton = new OO.ui.ButtonWidget( { label: '全选', framed: false } );
		this.selectNoneButton = new OO.ui.ButtonWidget( { label: '全不选', framed: false } );
		this.invertButton = new OO.ui.ButtonWidget( { label: '反选', framed: false } );

		this.selectAllButton.on( 'click', function () {
			dialog.setVisible( true );
		} );
		this.selectNoneButton.on( 'click', function () {
			dialog.setVisible( false );
		} );
		this.invertButton.on( 'click', function () {
			dialog.invertVisible();
		} );

		this.selectionLabel = new OO.ui.LabelWidget( {
			label: '尚未载入页面',
			classes: [ 'bdbc-selection' ]
		} );

		this.selectRow = new OO.ui.HorizontalLayout( {
			items: [
				this.selectAllButton,
				this.selectNoneButton,
				this.invertButton,
				this.selectionLabel
			]
		} );
		this.selectRow.$element.addClass( 'bdbc-inline' );

		this.listBox = $( '<div>' ).addClass( 'bdbc-list' ).text( '尚未载入页面。' );

		/* ---------- 进度与日志 ---------- */
		this.progressBar = new OO.ui.ProgressBarWidget( { progress: 0 } );
		this.progressBar.$element.hide();
		this.progressLabel = new OO.ui.LabelWidget( {
			label: '就绪',
			classes: [ 'bdbc-muted' ]
		} );
		this.logBox = $( '<div>' ).addClass( 'bdbc-log' );

		/* ---------- 组装 ---------- */
		this.content = new OO.ui.PanelLayout( { padded: true, expanded: false } );
		this.$body.append( this.content.$element );
		this.content.$element.append(
			this.categoryField.$element,
			this.loadRow.$element,
			this.optionsFieldset.$element,
			$( '<div>' ).addClass( 'bdbc-section' ).text( '页面列表' ),
			this.filterInput.$element,
			this.selectRow.$element,
			this.listBox,
			this.progressBar.$element,
			this.progressLabel.$element,
			this.logBox
		);
	};

	// 批量运行中关闭窗口时,先让当前页处理完再停
	Dialog.prototype.getActionProcess = function ( action ) {
		var dialog = this;
		if ( action === 'close' ) {
			return new OO.ui.Process( function () {
				if ( dialog.running ) {
					dialog.aborted = true;
				}
				dialog.close( { action: 'close' } );
			}, this );
		}
		if ( action === 'abort' ) {
			return new OO.ui.Process( function () {
				dialog.abort();
			}, this );
		}
		if ( action === 'delete' ) {
			return new OO.ui.Process( function () {
				dialog.confirmDelete();
			}, this );
		}
		return Dialog.super.prototype.getActionProcess.call( this, action );
	};

	// 动作按钮在 setup 阶段才创建,ready 之后再同步可用状态
	Dialog.prototype.getReadyProcess = function ( data ) {
		var dialog = this;
		return Dialog.super.prototype.getReadyProcess.call( this, data ).next( function () {
			dialog.syncActions();
		} );
	};

	// 内容变化后重新计算窗口尺寸,避免出现不必要的内部滚动
	Dialog.prototype.refreshSize = function () {
		var dialog = this;
		window.setTimeout( function () {
			try {
				dialog.updateSize();
			} catch ( e ) {
				// 窗口已关闭时忽略
			}
		}, 10 );
	};

	/* ---------- 载入 ---------- */

	Dialog.prototype.syncActions = function () {
		if ( !this.actions.get().length ) {
			return;
		}
		var selected = this.listBox ? this.listBox.find( 'input.bdbc-cb:checked' ).length : 0;
		this.actions.setAbilities( {
			delete: !this.running && !this.confirming && selected > 0,
			abort: !!this.running
		} );
	};

	Dialog.prototype.setBusy = function ( busy ) {
		this.running = busy;
		this.loadButton.setDisabled( busy );
		this.syncActions();
	};

	Dialog.prototype.loadPages = function () {
		var dialog = this;
		if ( this.running ) {
			return;
		}

		var cat = normalizeCategory( this.categoryInput.getValue() );
		if ( !cat ) {
			mw.notification.warn( '请先填写分类名称。' );
			return;
		}

		var opts = {
			recursive: this.recursiveCheck.isSelected(),
			depth: parseInt( this.depthSelect.getValue(), 10 ) || 1,
			limit: conf.pageLimit
		};

		this.pages = [];
		this.listBox.text( '载入中…' );
		this.progressLabel.setLabel( '正在读取分类成员…' );
		this.progressBar.$element.show();
		this.progressBar.setProgress( false );
		this.setBusy( true );

		collectPages( cat, opts, function ( status ) {
			dialog.progressLabel.setLabel( status );
		} ).then( function ( res ) {
			dialog.renderPages( res.pages );
			var msg = res.pages.length ?
				'已载入 ' + res.pages.length + ' 个页面' :
				'该分类下没有可删除的页面';
			if ( res.subcats.length && !opts.recursive ) {
				msg += '(另有 ' + res.subcats.length + ' 个子分类未包含,可勾选“包含子分类”)';
			}
			if ( res.pages.length >= opts.limit ) {
				msg += '(已达上限 ' + opts.limit + ' 条)';
			}
			dialog.progressLabel.setLabel( msg );
			dialog.filterInput.setDisabled( !res.pages.length );
		}, function ( code, data ) {
			var info = ( data && data.error && data.error.info ) || code || '未知错误';
			dialog.listBox.text( '载入失败:' + info );
			dialog.progressLabel.setLabel( '载入失败' );
			mw.notification.error( '载入分类成员失败:' + info );
		} ).always( function () {
			dialog.progressBar.$element.hide();
			dialog.setBusy( false );
			dialog.refreshSize();
		} );
	};

	Dialog.prototype.renderPages = function ( pages ) {
		var dialog = this;
		this.pages = pages || [];
		this.listBox.empty();
		this.filterInput.setValue( '' );

		if ( !this.pages.length ) {
			this.listBox.text( '该分类下没有可删除的页面。' );
			this.updateSelection();
			this.refreshSize();
			return;
		}

		var frag = document.createDocumentFragment();

		this.pages.forEach( function ( p, i ) {
			var $row = $( '<div>' )
				.addClass( 'bdbc-row' )
				.attr( { 'data-index': i, 'data-title': p.title } );

			var $cb = $( '<input>' )
				.attr( { type: 'checkbox', 'data-index': i } )
				.addClass( 'bdbc-cb' )
				.prop( 'checked', true );
			$cb.on( 'change', function () {
				dialog.updateSelection();
			} );

			var $link = $( '<a>' )
				.addClass( 'bdbc-title' )
				.attr( {
					href: mw.util.getUrl( p.title ),
					target: '_blank',
					rel: 'noopener',
					title: p.title
				} )
				.text( p.title );

			$row.append( $cb, $link );

			var tags = [];
			if ( p.redirect ) {
				tags.push( '重定向' );
			}
			if ( p.ns === 6 ) {
				tags.push( '文件' );
			}
			if ( p.length ) {
				tags.push( p.length + ' 字节' );
			}
			if ( tags.length ) {
				$row.append( $( '<span>' ).addClass( 'bdbc-tag' ).text( tags.join( ' · ' ) ) );
			}

			frag.appendChild( $row[ 0 ] );
		} );

		this.listBox.append( frag );
		this.updateSelection();
		this.refreshSize();
	};

	/* ---------- 选择 ---------- */

	Dialog.prototype.updateSelection = function () {
		var total = this.pages.length;
		var selected = this.listBox.find( 'input.bdbc-cb:checked' ).length;
		this.selectionLabel.setLabel( total ?
			'已选 ' + selected + ' / ' + total + ' 个页面' :
			'尚未载入页面' );
		this.syncActions();
	};

	Dialog.prototype.getSelected = function () {
		var dialog = this;
		var out = [];
		this.listBox.find( 'input.bdbc-cb:checked' ).each( function () {
			var idx = parseInt( this.getAttribute( 'data-index' ), 10 );
			if ( !isNaN( idx ) && dialog.pages[ idx ] ) {
				out.push( dialog.pages[ idx ] );
			}
		} );
		return out;
	};

	Dialog.prototype.visibleRows = function () {
		return this.listBox.find( '.bdbc-row' ).filter( function () {
			return $( this ).css( 'display' ) !== 'none';
		} );
	};

	Dialog.prototype.setVisible = function ( checked ) {
		this.visibleRows().find( 'input.bdbc-cb' ).each( function () {
			if ( !this.disabled ) {
				this.checked = checked;
			}
		} );
		this.updateSelection();
	};

	Dialog.prototype.invertVisible = function () {
		this.visibleRows().find( 'input.bdbc-cb' ).each( function () {
			if ( !this.disabled ) {
				this.checked = !this.checked;
			}
		} );
		this.updateSelection();
	};

	Dialog.prototype.applyFilter = function () {
		var q = $.trim( this.filterInput.getValue() ).toLowerCase();
		this.listBox.find( '.bdbc-row' ).each( function () {
			var title = ( $( this ).attr( 'data-title' ) || '' ).toLowerCase();
			$( this ).toggle( !q || title.indexOf( q ) !== -1 );
		} );
	};

	Dialog.prototype.markDone = function ( title ) {
		this.listBox.find( '.bdbc-row' ).filter( function () {
			return $( this ).attr( 'data-title' ) === title;
		} ).addClass( 'bdbc-row-done' ).find( 'input.bdbc-cb' ).prop( {
			checked: false,
			disabled: true
		} );
		this.updateSelection();
	};

	/* ---------- 删除 ---------- */

	Dialog.prototype.buildOptions = function () {
		var base = $.trim( this.reasonInput.getValue() ) || conf.defaultReason;
		return {
			reason: base,
			talkReason: base ? base + conf.talkReasonSuffix : '删除讨论页',
			redirectReason: base ? base + conf.redirectReasonSuffix : '删除重定向',
			deleteTalk: this.talkCheck.isSelected(),
			deleteRedirects: this.redirectCheck.isSelected(),
			dryRun: this.dryRunCheck.isSelected(),
			watchlist: conf.watchlist
		};
	};

	Dialog.prototype.confirmDelete = function () {
		var dialog = this;
		if ( this.confirming || this.running ) {
			return;
		}
		var items = this.getSelected();
		if ( !items.length ) {
			return;
		}

		var o = this.buildOptions();
		var $msg = $( '<div>' );

		[
			'分类:' + normalizeCategory( this.categoryInput.getValue() ),
			'待删除页面:' + items.length + ' 个',
			'删除理由:' + ( o.reason || '(留空)' ),
			'同时删除讨论页:' + ( o.deleteTalk ? '是' : '否' ),
			'同时删除重定向:' + ( o.deleteRedirects ? '是' : '否' )
		].forEach( function ( line ) {
			$msg.append( $( '<p>' ).text( line ) );
		} );

		if ( o.dryRun ) {
			$msg.append( $( '<p>' ).text( '当前为试运行,只会输出将要执行的操作,不会真的删除。' ) );
		} else {
			$msg.append( $( '<p>' ).text( '删除后无法通过普通操作恢复,请确认。' ) );
		}

		// 用 OOUI 自带的全局 WindowManager(与 OO.ui.confirm 相同的做法),
		// 避免同一个 WindowManager 里已有窗口打开时无法再开确认框
		var lifecycle = OO.ui.getWindowManager().openWindow( 'message', {
			title: o.dryRun ? '确认试运行' : '确认批量删除',
			message: $msg,
			actions: [
				{
					action: 'accept',
					label: o.dryRun ? '开始试运行' : '确认删除',
					flags: o.dryRun ? [ 'primary', 'progressive' ] : [ 'primary', 'destructive' ]
				},
				{ action: 'reject', label: '取消', flags: 'safe' }
			]
		} );

		this.confirming = true;
		this.syncActions();

		// lifecycle.closed 是新版写法;旧版 openWindow 直接返回可 then 的 promise
		var closed = lifecycle.closed || lifecycle;
		closed.then( function ( data ) {
			dialog.confirming = false;
			dialog.syncActions();
			if ( data && data.action === 'accept' ) {
				dialog.executeDelete( items, o );
			}
		}, function () {
			dialog.confirming = false;
			dialog.syncActions();
			mw.notification.error( '无法打开确认对话框,请稍后重试。' );
		} );
	};

	Dialog.prototype.logLine = function ( text, type ) {
		var $line = $( '<div>' ).text( text );
		if ( type === 'ok' ) {
			$line.addClass( 'bdbc-ok' );
		} else if ( type === 'fail' ) {
			$line.addClass( 'bdbc-fail' );
		} else {
			$line.addClass( 'bdbc-skip' );
		}
		this.logBox.append( $line );
		this.logBox.scrollTop( this.logBox[ 0 ].scrollHeight );
	};

	Dialog.prototype.formatEntry = function ( entry ) {
		var label = STATUS_TEXT[ entry.status ] || entry.status;
		var line = '[' + label + '] ' + ( entry.title || '(无标题)' );
		if ( entry.kind && entry.kind !== '页面' ) {
			line += '(' + entry.kind + ')';
		}
		if ( entry.info ) {
			line += ' — ' + entry.info;
		}
		return line;
	};

	Dialog.prototype.abort = function () {
		if ( !this.running ) {
			return;
		}
		this.aborted = true;
		this.actions.setAbilities( { abort: false } );
		this.progressLabel.setLabel( '正在停止(当前页面处理完后结束)…' );
	};

	Dialog.prototype.executeDelete = function ( items, o ) {
		var dialog = this;
		if ( this.running ) {
			return;
		}

		var stats = { ok: 0, fail: 0, skip: 0, dry: 0 };

		this.aborted = false;
		this.logBox.show().empty();
		this.progressBar.$element.show();
		this.progressBar.setProgress( 0 );
		this.setBusy( true );
		this.logLine( '开始处理 ' + items.length + ' 个页面' + ( o.dryRun ? '(试运行)' : '' ), 'skip' );
		this.refreshSize();

		runBatch( items, {
			reason: o.reason,
			talkReason: o.talkReason,
			redirectReason: o.redirectReason,
			deleteTalk: o.deleteTalk,
			deleteRedirects: o.deleteRedirects,
			dryRun: o.dryRun,
			watchlist: o.watchlist,
			delay: conf.delay
		}, {
			shouldAbort: function () {
				return dialog.aborted;
			},
			onItem: function ( i, n, title ) {
				dialog.progressLabel.setLabel( '正在处理(' + i + '/' + n + '):' + title );
				dialog.progressBar.setProgress( Math.round( ( i - 1 ) / n * 100 ) );
			},
			onReport: function ( entry ) {
				dialog.logLine( dialog.formatEntry( entry ), entry.status );

				if ( entry.status === 'ok' ) {
					stats.ok++;
					if ( entry.kind === '页面' ) {
						dialog.markDone( entry.title );
					}
				} else if ( entry.status === 'dry' ) {
					stats.dry++;
				} else if ( entry.status === 'fail' ) {
					stats.fail++;
				} else {
					stats.skip++;
				}
			},
			onFatal: function () {
				dialog.logLine( '检测到权限/令牌错误,已中止剩余页面的处理。', 'fail' );
			}
		} ).then( function () {
			dialog.progressBar.setProgress( 100 );
			dialog.progressLabel.setLabel( dialog.aborted ? '已停止' : '已完成' );

			var summary = '结束:成功 ' + stats.ok + ' 项,跳过 ' + stats.skip + ' 项,失败 ' + stats.fail + ' 项' +
				( o.dryRun ? ',试运行 ' + stats.dry + ' 项' : '' );
			dialog.logLine( summary, stats.fail ? 'fail' : 'ok' );
			dialog.setBusy( false );
			dialog.refreshSize();

			if ( !o.dryRun && stats.ok ) {
				mw.notification.success( '批量删除完成:成功删除 ' + stats.ok + ' 项。' );
			} else if ( !o.dryRun && stats.fail ) {
				mw.notification.error( '批量删除结束,有 ' + stats.fail + ' 项失败,详见日志。' );
			}
		}, function () {
			dialog.setBusy( false );
			dialog.progressLabel.setLabel( '发生未预期的错误' );
			dialog.logLine( '发生未预期的错误,已中止。', 'fail' );
		} );
	};

	/* ========================================================================
	 * 启动
	 * ===================================================================== */

	var windowManager = null;
	var currentDialog = null;

	function getWindowManager() {
		if ( !windowManager ) {
			windowManager = new OO.ui.WindowManager();
			$( document.body ).append( windowManager.$element );
		}
		return windowManager;
	}

	function openDialog() {
		mw.loader.using( [
			'oojs-ui',
			'mediawiki.api',
			'mediawiki.Title',
			'mediawiki.util',
			'mediawiki.notification'
		] ).then( function () {
			injectStyles();

			var wm = getWindowManager();

			// 同一个 WindowManager 同时只能开一个窗口,已经开着就直接复用
			if ( currentDialog ) {
				try {
					if ( currentDialog.isOpened() ) {
						return;
					}
				} catch ( e ) {
					// 忽略
				}
			}

			currentDialog = new Dialog( { size: 'large' } );
			wm.addWindows( [ currentDialog ] );
			wm.openWindow( currentDialog );

			if ( conf.checkRights ) {
				api.get( {
					action: 'query',
					meta: 'userinfo',
					uiprop: 'rights'
				} ).then( function ( data ) {
					var rights = ( data.query && data.query.userinfo && data.query.userinfo.rights ) || [];
					if ( rights.indexOf( 'delete' ) === -1 ) {
						mw.notification.warn(
							'当前账号没有 delete 权限,批量删除会被拒绝。',
							{ autoHide: false }
						);
					}
				}, function () {
					// 查询失败就算了,不影响使用
				} );
			}
		}, function () {
			if ( window.console && console.error ) {
				console.error( '[batch-delete-by-category] 加载 OOUI / mediawiki.api 失败。' );
			}
		} );
	}

	mw.libs.batchDeleteByCategory = {
		open: openDialog,
		config: conf
	};

	$( function () {
		mw.loader.using( 'mediawiki.util' ).then( function () {
			if ( !conf.addPortletLink ) {
				return;
			}
			var link = mw.util.addPortletLink(
				'p-tb',
				'#',
				conf.portletLabel,
				conf.portletId,
				'按分类批量删除页面(需要删除权限)'
			);
			if ( link ) {
				$( link ).on( 'click', function ( e ) {
					e.preventDefault();
					openDialog();
				} );
			}
		} );
	} );

}() );