User:WikiDwarf/AntiVandal.js: Difference between revisions

No edit summary
No edit summary
 
Line 11: Line 11:
var RK = (window.AntiVandal = window.AntiVandal || {});
var RK = (window.AntiVandal = window.AntiVandal || {});
RK.loaded = true;
RK.loaded = true;
RK.version = '1.1.1';
RK.version = '1.1.2';
var CONF = {
var CONF = {
storageKey: 'antivandal-prefs-v1',
storageKey: 'antivandal-prefs-v1',
Line 906: Line 906:
item('Contributions', function () { location.href = mw.util.getUrl('Special:Contributions/' + user); });
item('Contributions', function () { location.href = mw.util.getUrl('Special:Contributions/' + user); });
item('New message', function () { openNewMessage(user); });
item('New message', function () { openNewMessage(user); });
item('Warn user', function () { openWarnDialog(user); });
item('Welcome new user', function () { openWelcomeUser(user); });
ctx.style.left = e.pageX + 'px';
ctx.style.left = e.pageX + 'px';
ctx.style.top = e.pageY + 'px';
ctx.style.top = e.pageY + 'px';

Latest revision as of 20:24, 21 September 2026

/**
 * AntiVandal - Bharatpedia patrol helper
 * Host: User:WikiDwarf/AntiVandal.js
 * Version 1.1.1
 */
(function () {
	'use strict';
	if (window.AntiVandal && window.AntiVandal.loaded) {
		return;
	}
	var RK = (window.AntiVandal = window.AntiVandal || {});
	RK.loaded = true;
	RK.version = '1.1.2';
	var CONF = {
		storageKey: 'antivandal-prefs-v1',
		packKey: 'antivandal-packs-v1',
		tag: 'AntiVandal',
		aivPage: 'Bharatpedia:Administrator intervention against vandalism',
		rfppPage: 'Bharatpedia:Requests for page protection',
		anPage: 'Bharatpedia:Administrators noticeboard',
		privacyPage: 'Bharatpedia:Requests for oversight',
		docsPage: 'Bharatpedia:AntiVandal',
		welcomeCat: 'Category:Welcome templates'
	};
	var PRESETS = [
		{ id: 'vandalism', label: 'Vandalism', summary: 'Reverted unconstructive edit(s)', notice: 'uw-vandalism', level: true },
		{ id: 'blanking', label: 'Blanking', summary: 'Reverted unexplained content removal', notice: 'uw-delete', level: true },
		{ id: 'test', label: 'Test edit', summary: 'Reverted test edit', notice: 'uw-test', level: true },
		{ id: 'unsourced', label: 'Unsourced', summary: 'Reverted unsourced or poorly sourced addition', notice: 'uw-unsourced', level: true },
		{ id: 'spam', label: 'Spam / promo', summary: 'Reverted promotional or spam content', notice: 'uw-spam', level: true },
		{ id: 'npov', label: 'POV / attack', summary: 'Reverted non-neutral or attack content', notice: 'uw-biog', level: true },
		{ id: 'custom', label: 'Custom summary', summary: '', notice: '', level: false }
	];
	var NOTICES = [
		{ id: 'uw-vandalism', label: 'Vandalism', leveled: true },
		{ id: 'uw-test', label: 'Test edit', leveled: true },
		{ id: 'uw-delete', label: 'Removing content', leveled: true },
		{ id: 'uw-unsourced', label: 'Unsourced content', leveled: true },
		{ id: 'uw-error', label: 'Factual error / hoax', leveled: true },
		{ id: 'uw-spam', label: 'Spam / advertising', leveled: true },
		{ id: 'uw-advert', label: 'Promotional tone', leveled: true },
		{ id: 'uw-biog', label: 'BLP / biography issue', leveled: true },
		{ id: 'uw-defamatory', label: 'Defamatory content', leveled: true },
		{ id: 'uw-joke', label: 'Joke edit', leveled: true },
		{ id: 'uw-notvoted', label: 'Not a vote / forum', leveled: false },
		{ id: 'uw-chat', label: 'Talk page as chat', leveled: false },
		{ id: 'welcome', label: 'Welcome (constructive new editor)', leveled: false },
		{ id: 'welcometest', label: 'Welcome after a test edit', leveled: false }
	];
	function defaultPrefs() {
		return { barPosition: 'top', useRollback: true, autoNotice: false };
	}
	function loadPrefs() {
		try {
			var raw = localStorage.getItem(CONF.storageKey);
			return raw ? Object.assign(defaultPrefs(), JSON.parse(raw)) : defaultPrefs();
		} catch (e) {
			return defaultPrefs();
		}
	}
	function savePrefs(p) {
		try { localStorage.setItem(CONF.storageKey, JSON.stringify(p)); } catch (e) {}
	}
	var prefs = loadPrefs();
	var api = null;
	var hasRollback = false;
	function toast(msg, kind) {
		var el = document.getElementById('antivandal-toast');
		if (!el) {
			el = document.createElement('div');
			el.id = 'antivandal-toast';
			document.body.appendChild(el);
		}
		el.className = 'rk-toast rk-toast-' + (kind || 'info');
		el.textContent = msg;
		el.style.display = 'block';
		clearTimeout(toast._t);
		toast._t = setTimeout(function () { el.style.display = 'none'; }, 4200);
	}
	function pageName() {
		return mw.config.get('wgPageName');
	}
	function relevantUser() {
		return mw.config.get('wgRelevantUserName') || null;
	}
	function talkTitleFor(user) {
		if (!user) { return null; }
		return 'User talk:' + user;
	}
	function actingUser() {
		if (mw.user && typeof mw.user.getName === 'function' && mw.user.getName()) {
			return mw.user.getName();
		}
		return mw.config.get('wgUserName') || '';
	}
	function wikiSig() {
		return '~~' + '~~';
	}
	function injectCss() {
		if (document.getElementById('antivandal-css')) { return; }
		var css = [
			'#antivandal-open.bpneo-title-btn{color:#c62828;}',
			'#antivandal-tools{display:none;align-items:center;gap:inherit;}',
			'.bpneo-title-actions.av-open > :not(#antivandal-tools){display:none !important;}',
			'.bpneo-title-actions.av-open #antivandal-tools{display:inline-flex;}',
			'#antivandal-tools .bpneo-title-btn.rk-on{color:#2a4a33;}',
			'#antivandal-more{position:absolute;right:0;top:36px;background:#fff;border:1px solid #a2a9b1;',
			'box-shadow:0 2px 8px rgba(0,0,0,.18);min-width:220px;z-index:50;display:none;padding:4px 0;}',
			'#antivandal-more button{display:block;width:100%;text-align:left;border:0;background:none;',
			'padding:8px 12px;cursor:pointer;font:13px sans-serif;}',
			'#antivandal-more button:hover{background:#eaecf0;}',
			'#antivandal-ctx{position:fixed;z-index:1300;background:#fff;border:1px solid #a2a9b1;',
			'box-shadow:0 2px 8px rgba(0,0,0,.2);min-width:180px;padding:4px 0;display:none;}',
			'#antivandal-ctx button{display:block;width:100%;text-align:left;border:0;background:none;',
			'padding:8px 12px;cursor:pointer;font:13px sans-serif;}',
			'#antivandal-ctx button:hover{background:#eaecf0;}',
			'.rk-modal-bg{position:fixed;inset:0;background:rgba(17,17,17,.4);z-index:1100;display:flex;',
			'align-items:center;justify-content:center;padding:16px;}',
			'.rk-modal{background:#fff;color:#1b1b1b;max-width:480px;width:100%;border-radius:10px;',
			'border-top:3px solid #c62828;box-shadow:0 8px 28px rgba(0,0,0,.2);overflow:hidden;}',
			'.rk-modal .rk-head{display:flex;align-items:center;justify-content:space-between;',
			'padding:10px 14px;border-bottom:1px solid #eee;}',
			'.rk-modal h2{margin:0;padding:0;background:transparent;color:#111;font-size:15px;font-weight:600;}',
			'.rk-modal .rk-x{border:0;background:none;font-size:18px;line-height:1;cursor:pointer;color:#444;}',
			'.rk-modal .rk-body{padding:12px 14px;font-size:13px;}',
			'.rk-modal label{display:block;font-weight:600;margin:8px 0 4px;}',
			'.rk-modal .rk-checkrow{display:flex;flex-wrap:wrap;gap:10px 18px;margin-top:10px;align-items:center;}',
			'.rk-modal .rk-check{display:inline-flex;align-items:center;gap:6px;font-weight:400;margin:0;}',
			'.rk-modal .rk-check input{margin:0;}',
			'.rk-modal input[type=text],.rk-modal select,.rk-modal textarea{width:100%;box-sizing:border-box;',
			'padding:7px 8px;font:13px sans-serif;}',
			'.rk-modal textarea{min-height:70px;}',
			'.rk-modal .rk-actions{display:flex;justify-content:flex-end;gap:8px;padding:10px 14px;}',
			'.rk-modal .rk-actions button{padding:6px 12px;border-radius:4px;border:1px solid #bbb;cursor:pointer;background:#fff;}',
			'.rk-modal .rk-actions .rk-primary{background:#c62828;color:#fff;border-color:#c62828;}',
			'.rk-toast{position:fixed;right:16px;bottom:16px;z-index:1200;background:#1f2a22;color:#fff;',
			'padding:10px 14px;border-radius:6px;max-width:360px;display:none;}',
			'.rk-toast-err{background:#6b2a22;}',
			'.rk-toast-ok{background:#2a4a33;}',
			'#av-patrol{position:fixed;top:0;right:0;width:320px;height:100vh;background:#111;color:#eee;',
			'z-index:1250;overflow:auto;font:13px sans-serif;}',
			'#av-patrol h3{margin:0;padding:12px 14px;background:#1f2a22;}',
			'#av-patrol .av-p-item{display:block;padding:8px 12px;border-bottom:1px solid #333;color:#eee;text-decoration:none;}',
			'.av-contrib-rev{margin-left:6px;font-size:12px;cursor:pointer;color:#c62828;border:0;background:none;}'
		].join('');
		var style = document.createElement('style');
		style.id = 'antivandal-css';
		style.textContent = css;
		document.head.appendChild(style);
	}
	function closeModal() {
		var bg = document.getElementById('antivandal-modal-bg');
		if (bg) { bg.parentNode.removeChild(bg); }
	}
	function openModal(title, bodyNode, actions) {
		closeModal();
		var bg = document.createElement('div');
		bg.id = 'antivandal-modal-bg';
		bg.className = 'rk-modal-bg';
		bg.addEventListener('click', function (e) { if (e.target === bg) { closeModal(); } });
		var box = document.createElement('div');
		box.className = 'rk-modal';
		var head = document.createElement('div');
		head.className = 'rk-head';
		var h = document.createElement('h2');
		h.textContent = title;
		var x = document.createElement('button');
		x.type = 'button';
		x.className = 'rk-x';
		x.setAttribute('aria-label', 'Close');
		x.textContent = 'x';
		x.addEventListener('click', closeModal);
		head.appendChild(h);
		head.appendChild(x);
		box.appendChild(head);
		var body = document.createElement('div');
		body.className = 'rk-body';
		body.appendChild(bodyNode);
		box.appendChild(body);
		var foot = document.createElement('div');
		foot.className = 'rk-actions';
		(actions || []).forEach(function (a) {
			var b = document.createElement('button');
			b.type = 'button';
			b.textContent = a.label;
			if (a.primary) { b.className = 'rk-primary'; }
			b.addEventListener('click', a.onClick);
			foot.appendChild(b);
		});
		box.appendChild(foot);
		bg.appendChild(box);
		document.body.appendChild(bg);
	}
	function field(labelText, node) {
		var wrap = document.createElement('div');
		var lab = document.createElement('label');
		lab.textContent = labelText;
		wrap.appendChild(lab);
		wrap.appendChild(node);
		return wrap;
	}
	function getLastRev() {
		return api.get({
			action: 'query', prop: 'revisions', titles: pageName(),
			rvlimit: 2, rvprop: 'ids|user|comment|timestamp', formatversion: 2
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			if (!page || page.missing || !page.revisions || !page.revisions.length) { return null; }
			return { page: page, latest: page.revisions[0], previous: page.revisions[1] || null };
		});
	}
	function doUndo(info, summary) {
		if (!info.previous) {
			throw new Error('Nothing to restore: that editor is the only author, or there is no earlier revision.');
		}
		return api.postWithEditToken({
			action: 'edit', title: pageName(), undo: info.latest.revid, undoafter: info.previous.revid,
			summary: summary + ' ([[Bharatpedia:AntiVandal|AntiVandal]])', formatversion: 2
		}).then(function (res) {
			if (res.error) { throw new Error(res.error.info || res.error.code); }
			return { method: 'undo', data: res };
		});
	}
	function revertAllByUser(summary, targetUser) {
		return api.get({
			action: 'query', prop: 'revisions', titles: pageName(),
			rvlimit: 50, rvprop: 'ids|user', formatversion: 2
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var revs = (page && page.revisions) || [];
			if (!revs.length) { throw new Error('Could not read this page history.'); }
			if (revs[0].user !== targetUser) {
				throw new Error('Latest edit is not by ' + targetUser + '.');
			}
			var keep = null;
			for (var i = 0; i < revs.length; i++) {
				if (revs[i].user !== targetUser) {
					keep = revs[i];
					break;
				}
			}
			if (!keep) {
				throw new Error('Nothing to restore: that editor is the only author in recent history.');
			}
			return api.postWithEditToken({
				action: 'edit', title: pageName(),
				undo: revs[0].revid, undoafter: keep.revid,
				summary: summary + ' ([[Bharatpedia:AntiVandal|AntiVandal]])', formatversion: 2
			}).then(function (res) {
				if (res.error) { throw new Error(res.error.info || res.error.code); }
				return { method: 'undo-all', data: res };
			});
		});
	}
	function revertWithSummary(summary, userToRevert, allByUser) {
		return getLastRev().then(function (info) {
			if (!info) { throw new Error('Could not read this page history.'); }
			var targetUser = userToRevert || info.latest.user;
			function errCode(err) {
				return (err && (err.code || err.name)) || (String(err.message || err));
			}
			if (allByUser && prefs.useRollback && hasRollback) {
				return api.postWithToken('rollback', {
					action: 'rollback', title: pageName(), user: targetUser,
					summary: summary + ' ([[Bharatpedia:AntiVandal|AntiVandal]])', formatversion: 2
				}).then(function (res) {
					if (res.error) { throw res.error; }
					return { method: 'rollback', data: res };
				}).catch(function (err) {
					var code = errCode(err);
					if (String(code).indexOf('onlyauthor') !== -1 ||
						String(code).indexOf('alreadyrolled') !== -1 ||
						String(err.info || '').indexOf('only author') !== -1) {
						return revertAllByUser(summary, targetUser);
					}
					throw new Error(err.info || err.message || code || String(err));
				});
			}
			if (allByUser) {
				return revertAllByUser(summary, targetUser);
			}
			return doUndo(info, summary);
		});
	}
	function restoreRevision(revid) {
		return api.get({
			action: 'query', prop: 'revisions', revids: revid,
			rvprop: 'ids|content|timestamp|user', rvslots: 'main', formatversion: 2
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var rev = page && page.revisions && page.revisions[0];
			var text = rev && rev.slots && rev.slots.main && rev.slots.main.content;
			if (!text && text !== '') { throw new Error('Could not load that revision.'); }
			return api.postWithEditToken({
				action: 'edit', title: page.title || pageName(), text: text,
				summary: 'Restored revision ' + revid + ' ([[Bharatpedia:AntiVandal|AntiVandal]])',
				formatversion: 2
			});
		});
	}
	function appendTalkNotice(user, templateName, article, extra) {
		var talk = talkTitleFor(user);
		var subst = '{' + '{subst:' + templateName;
		if (article) { subst += '|1=' + article.replace(/_/g, ' '); }
		if (extra) { subst += '|2=' + extra; }
		subst += '}}\n' + '~~' + '~~';
		return api.postWithEditToken({
			action: 'edit', title: talk, appendtext: '\n\n' + subst,
			summary: 'Notice ([[Bharatpedia:AntiVandal|AntiVandal]])', formatversion: 2
		});
	}
	function detectTalkLevel(user) {
		if (!user) { return Promise.resolve(1); }
		return api.get({
			action: 'query', prop: 'revisions', titles: talkTitleFor(user),
			rvlimit: 1, rvprop: 'content', rvslots: 'main', formatversion: 2
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var text = page && page.revisions && page.revisions[0] &&
				page.revisions[0].slots && page.revisions[0].slots.main &&
				page.revisions[0].slots.main.content || '';
			var max = 0;
			var re = /\{\{(?:subst:)?uw-[\w-]+([1-4])\b/gi;
			var m;
			while ((m = re.exec(text))) { max = Math.max(max, parseInt(m[1], 10)); }
			return Math.min(4, max + 1) || 1;
		}).catch(function () { return 1; });
	}
	function openRevertDialog() {
		getLastRev().then(function (info) {
			var user = info && info.latest ? info.latest.user : (relevantUser() || '');
			var wrap = document.createElement('div');
			var userInput = document.createElement('input');
			userInput.type = 'text';
			userInput.value = user || '';
			var sel = document.createElement('select');
			PRESETS.forEach(function (p) {
				var o = document.createElement('option');
				o.value = p.id;
				o.textContent = p.label;
				sel.appendChild(o);
			});
			var sum = document.createElement('input');
			sum.type = 'text';
			sum.value = PRESETS[0].summary;
			sel.addEventListener('change', function () {
				var p = PRESETS.filter(function (x) { return x.id === sel.value; })[0];
				if (p && p.summary) { sum.value = p.summary; }
			});
			var alsoWarn = document.createElement('input');
			alsoWarn.type = 'checkbox';
			var warnLab = document.createElement('label');
			warnLab.className = 'rk-check';
			warnLab.appendChild(alsoWarn);
			warnLab.appendChild(document.createTextNode('Warn user after revert'));
			var allBy = document.createElement('input');
			allBy.type = 'checkbox';
			allBy.checked = true;
			var allLab = document.createElement('label');
			allLab.className = 'rk-check';
			allLab.appendChild(allBy);
			allLab.appendChild(document.createTextNode('Revert all recent edits by this user when possible'));
			var row = document.createElement('div');
			row.className = 'rk-checkrow';
			row.appendChild(warnLab);
			row.appendChild(allLab);
			wrap.appendChild(field('Editor to revert', userInput));
			wrap.appendChild(field('Reason', sel));
			wrap.appendChild(field('Edit summary', sum));
			wrap.appendChild(row);
			openModal('Revert with AntiVandal', wrap, [
				{ label: 'Cancel', onClick: closeModal },
				{
					label: 'Revert', primary: true,
					onClick: function () {
						var summary = (sum.value || 'Reverted edit').trim();
						var who = userInput.value.trim();
						if (!who) { toast('Name the editor.', 'err'); return; }
						toast('Reverting...');
						revertWithSummary(summary, who, allBy.checked).then(function (res) {
							toast('Reverted with ' + res.method + '.', 'ok');
							closeModal();
							if (alsoWarn.checked) { openWarnDialog(who, sel.value); }
							else { setTimeout(function () { location.reload(); }, 700); }
						}).catch(function (err) { toast(err.message || String(err), 'err'); });
					}
				}
			]);
		}).catch(function (err) { toast(err.message || String(err), 'err'); });
	}
	function recentEditors(limit) {
		return api.get({
			action: 'query', prop: 'revisions', titles: pageName(),
			rvlimit: 40, rvprop: 'user', formatversion: 2
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var revs = (page && page.revisions) || [];
			var seen = {};
			var out = [];
			revs.forEach(function (r) {
				if (r.user && !seen[r.user]) {
					seen[r.user] = true;
					out.push(r.user);
				}
			});
			return out.slice(0, limit || 5);
		}).catch(function () { return []; });
	}
	function bindUserSuggest(input, listEl) {
		var t = null;
		input.addEventListener('input', function () {
			clearTimeout(t);
			var q = input.value.trim();
			if (q.length < 2) { return; }
			t = setTimeout(function () {
				api.get({
					action: 'query', list: 'allusers', aufrom: q, auprefix: q,
					aulimit: 8, formatversion: 2
				}).then(function (data) {
					var names = (data.query && data.query.allusers || []).map(function (u) { return u.name; });
					names.forEach(function (name) {
						var exists = false;
						for (var i = 0; i < listEl.options.length; i++) {
							if (listEl.options[i].value === name) { exists = true; break; }
						}
						if (!exists) {
							var o = document.createElement('option');
							o.value = name;
							listEl.appendChild(o);
						}
					});
				}).catch(function () {});
			}, 250);
		});
	}
	function openWarnDialog(prefillUser, presetId) {
		var wrap = document.createElement('div');
		var recentSel = document.createElement('select');
		var opt0 = document.createElement('option');
		opt0.value = '';
		opt0.textContent = 'Recent editors on this page';
		recentSel.appendChild(opt0);
		var userInput = document.createElement('input');
		userInput.type = 'text';
		userInput.setAttribute('list', 'av-user-suggest');
		userInput.autocomplete = 'off';
		userInput.value = prefillUser || relevantUser() || '';
		var suggest = document.createElement('datalist');
		suggest.id = 'av-user-suggest';
		recentSel.addEventListener('change', function () {
			if (recentSel.value) { userInput.value = recentSel.value; }
		});
		recentEditors(5).then(function (names) {
			names.forEach(function (name) {
				var o = document.createElement('option');
				o.value = name;
				o.textContent = name;
				recentSel.appendChild(o);
				var d = document.createElement('option');
				d.value = name;
				suggest.appendChild(d);
			});
			if (!userInput.value && names[0]) { userInput.value = names[0]; }
		});
		bindUserSuggest(userInput, suggest);
		var articleInput = document.createElement('input');
		articleInput.type = 'text';
		articleInput.value = pageName().replace(/_/g, ' ');
		var sel = document.createElement('select');
		NOTICES.forEach(function (n) {
			var o = document.createElement('option');
			o.value = n.id;
			o.textContent = n.label + (n.leveled ? ' (1-4)' : '');
			sel.appendChild(o);
		});
		if (presetId) {
			var mapped = { vandalism: 'uw-vandalism', blanking: 'uw-delete', test: 'uw-test', unsourced: 'uw-unsourced', spam: 'uw-spam', npov: 'uw-biog' }[presetId];
			if (mapped) { sel.value = mapped; }
		}
		var level = document.createElement('select');
		[1, 2, 3, 4].forEach(function (n) {
			var o = document.createElement('option');
			o.value = String(n);
			o.textContent = 'Level ' + n;
			level.appendChild(o);
		});
		var extra = document.createElement('textarea');
		var hint = document.createElement('p');
		hint.style.fontSize = '12px';
		hint.textContent = 'Checking talk page for prior notices...';
		function currentTemplate() {
			var meta = NOTICES.filter(function (n) { return n.id === sel.value; })[0];
			return (meta && meta.leveled) ? sel.value + level.value : sel.value;
		}
		wrap.appendChild(field('Recent editors', recentSel));
		wrap.appendChild(field('User (type to search)', userInput));
		var signLine = document.createElement('p');
		signLine.style.fontSize = '12px';
		signLine.textContent = 'Will sign as ' + actingUser();
		wrap.appendChild(signLine);
		wrap.appendChild(suggest);
		wrap.appendChild(field('Related article', articleInput));
		wrap.appendChild(field('Notice', sel));
		wrap.appendChild(field('Level', level));
		wrap.appendChild(field('Extra note', extra));
		wrap.appendChild(hint);
		detectTalkLevel(userInput.value.trim()).then(function (n) {
			level.value = String(n);
			hint.textContent = 'Suggested level ' + n + ' from the talk page.';
		});
		openModal('Post a talk-page notice', wrap, [
			{ label: 'Cancel', onClick: closeModal },
			{
				label: 'Post notice', primary: true,
				onClick: function () {
					var user = userInput.value.trim();
					if (!user) { toast('Enter a username or IP.', 'err'); return; }
					var tpl = currentTemplate();
					toast('Posting...');
					appendTalkNotice(user, tpl, articleInput.value.trim(), extra.value.trim()).then(function (res) {
						if (res.error) { throw new Error(res.error.info || res.error.code); }
						toast('Notice posted.', 'ok');
						closeModal();
					}).catch(function (err) { toast(err.message || String(err), 'err'); });
				}
			}
		]);
	}
	function openPrefs() {
		var wrap = document.createElement('div');
		var rb = document.createElement('input');
		rb.type = 'checkbox';
		rb.checked = !!prefs.useRollback;
		var rbLab = document.createElement('label');
		rbLab.style.fontWeight = 'normal';
		rbLab.textContent = ' Prefer software rollback when available';
		var row = document.createElement('div');
		row.appendChild(rb);
		row.appendChild(rbLab);
		wrap.appendChild(row);
		openModal('AntiVandal preferences', wrap, [
			{ label: 'Close', onClick: closeModal },
			{
				label: 'Save', primary: true,
				onClick: function () {
					prefs.useRollback = rb.checked;
					savePrefs(prefs);
					closeModal();
					toast('Saved.', 'ok');
				}
			}
		]);
	}
	function goLatest() {
		location.href = mw.util.getUrl(pageName(), { diff: 'cur', oldid: 'prev' });
	}
	function reportAiv() {
		getLastRev().then(function (info) {
			var user = (info && info.latest && info.latest.user) || relevantUser() || '';
			var wrap = document.createElement('div');
			var ta = document.createElement('textarea');
			ta.value = '* {' + '{vandal|' + user + '}} - unconstructive editing on [[' +
				pageName().replace(/_/g, ' ') + ']]. ' + wikiSig();
			wrap.appendChild(ta);
			openModal('Report at AIV', wrap, [
				{ label: 'Cancel', onClick: closeModal },
				{
					label: 'Open AIV', primary: true,
					onClick: function () {
						window.open(mw.util.getUrl(CONF.aivPage, { action: 'edit', section: 'new' }), '_blank');
					}
				}
			]);
		});
	}
	function requestProtection() {
		var wrap = document.createElement('div');
		var art = document.createElement('input');
		art.type = 'text';
		art.value = pageName().replace(/_/g, ' ');
		var level = document.createElement('select');
		['semi-protection', 'extended confirmed', 'full protection', 'move protection', 'unprotection'].forEach(function (v) {
			var o = document.createElement('option');
			o.value = v;
			o.textContent = v;
			level.appendChild(o);
		});
		var reason = document.createElement('textarea');
		reason.placeholder = 'Why this page needs that level';
		var current = document.createElement('p');
		current.textContent = 'Checking current protection...';
		wrap.appendChild(field('Article', art));
		wrap.appendChild(field('Requested level', level));
		wrap.appendChild(field('Reason', reason));
		wrap.appendChild(current);
		api.get({
			action: 'query', titles: pageName(), prop: 'info', inprop: 'protection', formatversion: 2
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var prot = (page && page.protection) || [];
			current.textContent = prot.length
				? ('Current: ' + prot.map(function (p) { return p.type + '=' + p.level; }).join(', '))
				: 'Current: not protected';
		}).catch(function () { current.textContent = ''; });
		function pageTitle() { return art.value.trim(); }
		function requestBody() {
			var title = pageTitle();
			return '* {' + '{pagelinks|' + title + '}}\n' +
				reason.value.trim() + ' ' + wikiSig() + '\n';
		}
		function draft() {
			return '=== [[' + pageTitle() + ']] ===\n' + requestBody();
		}
		openModal('Request page protection', wrap, [
			{ label: 'Cancel', onClick: closeModal },
			{
				label: 'Open board with draft',
				onClick: function () {
					try {
						sessionStorage.setItem('antivandal-rfpp-subject', '[[' + art.value.trim() + ']]');
						sessionStorage.setItem('antivandal-rfpp-draft', requestBody());
					} catch (e) {}
					window.open(mw.util.getUrl(CONF.rfppPage, { action: 'edit', section: 'new' }), '_blank');
				}
			},
			{
				label: 'Post request', primary: true,
				onClick: function () {
					if (!art.value.trim() || !reason.value.trim()) {
						toast('Article and reason are required.', 'err');
						return;
					}
					toast('Posting request...');
					api.postWithEditToken({
						action: 'edit', title: CONF.rfppPage, appendtext: '\n' + draft(),
						summary: 'Protection request for [[' + art.value.trim() + ']] ([[Bharatpedia:AntiVandal|AntiVandal]])',
						formatversion: 2
					}).then(function (res) {
						if (res.error) { throw new Error(res.error.info || res.error.code); }
						toast('Request posted.', 'ok');
						closeModal();
					}).catch(function (err) { toast(err.message || String(err), 'err'); });
				}
			}
		]);
	}
	function openAdminReport(kind) {
		var title = kind === 'privacy' ? CONF.privacyPage : CONF.anPage;
		window.open(mw.util.getUrl(title, { action: 'edit', section: 'new' }), '_blank');
	}
	function openNewMessage(prefillUser) {
		var wrap = document.createElement('div');
		var userInput = document.createElement('input');
		userInput.type = 'text';
		userInput.value = prefillUser || relevantUser() || '';
		var subj = document.createElement('input');
		subj.type = 'text';
		var body = document.createElement('textarea');
		wrap.appendChild(field('User', userInput));
		var signLine2 = document.createElement('p');
		signLine2.style.fontSize = '12px';
		signLine2.textContent = 'Will sign as ' + actingUser();
		wrap.appendChild(signLine2);
		wrap.appendChild(field('Subject', subj));
		wrap.appendChild(field('Message', body));
		openModal('New talk-page message', wrap, [
			{ label: 'Cancel', onClick: closeModal },
			{
				label: 'Send', primary: true,
				onClick: function () {
					var user = userInput.value.trim();
					if (!user || !subj.value.trim()) { toast('Need a user and a subject.', 'err'); return; }
					var text = '\n\n== ' + subj.value.trim() + ' ==\n' + body.value + '\n' + wikiSig();
					toast('Sending...');
					api.postWithEditToken({
						action: 'edit', title: talkTitleFor(user), appendtext: text,
						summary: 'New message ([[Bharatpedia:AntiVandal|AntiVandal]])', formatversion: 2
					}).then(function (res) {
						if (res.error) { throw new Error(res.error.info || res.error.code); }
						toast('Message posted.', 'ok');
						closeModal();
					}).catch(function (err) { toast(err.message || String(err), 'err'); });
				}
			}
		]);
	}
	function welcomeTemplates() {
		return api.get({
			action: 'query', list: 'categorymembers',
			cmtitle: CONF.welcomeCat, cmnamespace: 10,
			cmtype: 'page', cmlimit: 50, formatversion: 2
		}).then(function (data) {
			var members = (data.query && data.query.categorymembers) || [];
			var out = members.map(function (m) {
				var title = (m.title || '').replace(/^Template:/i, '');
				return { title: title, label: title.replace(/_/g, ' ') };
			}).filter(function (x) { return x.title; });
			if (!out.length) {
				out = [
					{ title: 'Welcome', label: 'Welcome' },
					{ title: 'Welcometest', label: 'Welcome after a test edit' }
				];
			}
			return out;
		}).catch(function () {
			return [
				{ title: 'Welcome', label: 'Welcome' },
				{ title: 'Welcometest', label: 'Welcome after a test edit' }
			];
		});
	}
	function openWelcomeUser(prefillUser) {
		var wrap = document.createElement('div');
		var userInput = document.createElement('input');
		userInput.type = 'text';
		userInput.value = prefillUser || relevantUser() || '';
		var sel = document.createElement('select');
		var loading = document.createElement('option');
		loading.value = '';
		loading.textContent = 'Loading welcome templates...';
		sel.appendChild(loading);
		wrap.appendChild(field('User', userInput));
		var signLine = document.createElement('p');
		signLine.style.fontSize = '12px';
		signLine.textContent = 'Will sign as ' + actingUser();
		wrap.appendChild(signLine);
		wrap.appendChild(field('Welcome template', sel));
		welcomeTemplates().then(function (list) {
			sel.innerHTML = '';
			list.forEach(function (item) {
				var o = document.createElement('option');
				o.value = item.title;
				o.textContent = item.label;
				sel.appendChild(o);
			});
		});
		openModal('Welcome new user', wrap, [
			{ label: 'Cancel', onClick: closeModal },
			{
				label: 'Post welcome', primary: true,
				onClick: function () {
					var user = userInput.value.trim();
					var tpl = sel.value;
					if (!user || !tpl) { toast('Need a user and a template.', 'err'); return; }
					toast('Posting welcome...');
					api.postWithEditToken({
						action: 'edit', title: talkTitleFor(user),
						appendtext: '\n\n{' + '{subst:' + tpl + '}}\n' + wikiSig(),
						summary: 'Welcome ([[Bharatpedia:AntiVandal|AntiVandal]])', formatversion: 2
					}).then(function (res) {
						if (res.error) { throw new Error(res.error.info || res.error.code); }
						toast('Welcome posted.', 'ok');
						closeModal();
					}).catch(function (err) { toast(err.message || String(err), 'err'); });
				}
			}
		]);
	}
	function openQuickTemplates(prefillUser) {
		openWelcomeUser(prefillUser);
	}
	function openMat() {
		toast('Loading history...');
		api.get({
			action: 'query', prop: 'revisions', titles: pageName(),
			rvlimit: 30, rvprop: 'ids|user|comment|timestamp', formatversion: 2
		}).then(function (data) {
			var page = data.query && data.query.pages && data.query.pages[0];
			var revs = (page && page.revisions) || [];
			var seen = {};
			var users = [];
			revs.forEach(function (r) {
				if (r.user && !seen[r.user]) { seen[r.user] = true; users.push(r.user); }
			});
			var wrap = document.createElement('div');
			var boxes = [];
			users.forEach(function (u) {
				var lab = document.createElement('label');
				lab.style.display = 'block';
				lab.style.fontWeight = 'normal';
				var c = document.createElement('input');
				c.type = 'checkbox';
				c.value = u;
				c.checked = true;
				lab.appendChild(c);
				lab.appendChild(document.createTextNode(' ' + u));
				wrap.appendChild(lab);
				boxes.push(c);
			});
			var sel = document.createElement('select');
			NOTICES.forEach(function (n) {
				var o = document.createElement('option');
				o.value = n.id + (n.leveled ? '1' : '');
				o.textContent = n.label;
				sel.appendChild(o);
			});
			wrap.appendChild(field('Notice', sel));
			openModal('Multiple notices', wrap, [
				{ label: 'Cancel', onClick: closeModal },
				{
					label: 'Post to checked', primary: true,
					onClick: function () {
						var chosen = boxes.filter(function (c) { return c.checked; }).map(function (c) { return c.value; });
						if (!chosen.length) { toast('None selected.', 'err'); return; }
						if (!window.confirm('Post to ' + chosen.length + ' talk pages?')) { return; }
						var chain = Promise.resolve();
						chosen.forEach(function (u) {
							chain = chain.then(function () {
								return appendTalkNotice(u, sel.value, pageName().replace(/_/g, ' '), '');
							});
						});
						chain.then(function () { toast('Done.', 'ok'); closeModal(); })
							.catch(function (err) { toast(err.message || String(err), 'err'); });
					}
				}
			]);
		}).catch(function (err) { toast(err.message || String(err), 'err'); });
	}
	function openPendingReview() {
		api.get({ action: 'query', list: 'oldreviewedpages', orlimit: 15, formatversion: 2 }).then(function (data) {
			var pages = (data.query && data.query.oldreviewedpages) || [];
			if (!pages.length) { toast('No pending-review queue on this wiki.', 'err'); return; }
			var wrap = document.createElement('div');
			pages.forEach(function (p) {
				var a = document.createElement('a');
				a.href = mw.util.getUrl(p.title);
				a.textContent = p.title;
				a.style.display = 'block';
				wrap.appendChild(a);
			});
			openModal('Pending review', wrap, [{ label: 'Close', onClick: closeModal }]);
		}).catch(function () { toast('Pending changes is not available here.', 'err'); });
	}
	function bindContribsRollback() {
		if (mw.config.get('wgCanonicalSpecialPageName') !== 'Contributions') { return; }
		var list = document.querySelector('.mw-contributions-list');
		if (!list) { return; }
		list.querySelectorAll('li').forEach(function (li) {
			if (li.querySelector('.av-contrib-rev')) { return; }
			var diff = li.querySelector('a[href*="diff="]');
			if (!diff) { return; }
			var b = document.createElement('button');
			b.type = 'button';
			b.className = 'av-contrib-rev';
			b.textContent = 'revert';
			b.addEventListener('click', function (e) {
				e.preventDefault();
				var href = diff.getAttribute('href') || '';
				var titleMatch = href.match(/title=([^&]+)/) || href.match(/\/wiki\/([^?]+)/);
				var title = titleMatch ? decodeURIComponent(titleMatch[1].replace(/\+/g, ' ')) : null;
				var user = mw.config.get('wgRelevantUserName');
				if (!title || !user) { toast('Could not read that row.', 'err'); return; }
				if (!window.confirm('Revert latest edit by ' + user + ' on ' + title + '?')) { return; }
				api.get({
					action: 'query', prop: 'revisions', titles: title.replace(/ /g, '_'),
					rvlimit: 2, rvprop: 'ids|user', formatversion: 2
				}).then(function (data) {
					var page = data.query && data.query.pages && data.query.pages[0];
					var latest = page && page.revisions && page.revisions[0];
					if (!latest) { throw new Error('No revision'); }
					if (prefs.useRollback && hasRollback) {
						return api.postWithToken('rollback', {
							action: 'rollback', title: title, user: user,
							summary: 'Reverted unconstructive edit(s) ([[Bharatpedia:AntiVandal|AntiVandal]])',
							formatversion: 2
						});
					}
					var prev = page.revisions[1];
					if (!prev) { throw new Error('No earlier revision'); }
					return api.postWithEditToken({
						action: 'edit', title: title, undo: latest.revid, undoafter: prev.revid,
						summary: 'Reverted unconstructive edit(s) ([[Bharatpedia:AntiVandal|AntiVandal]])',
						formatversion: 2
					});
				}).then(function () { toast('Reverted.', 'ok'); })
					.catch(function (err) { toast(err.message || String(err), 'err'); });
			});
			li.appendChild(b);
		});
	}
	function bindUserContextMenu() {
		document.addEventListener('contextmenu', function (e) {
			if (!e.shiftKey) { return; }
			var a = e.target.closest ? e.target.closest('a') : null;
			if (!a || !a.href) { return; }
			var m = a.href.match(/[?&]title=User:([^&#]+)/i) || a.href.match(/\/wiki\/User:([^?#]+)/);
			if (!m) { return; }
			e.preventDefault();
			var user = decodeURIComponent(m[1]).replace(/_/g, ' ').split('/')[0];
			var ctx = document.getElementById('antivandal-ctx');
			if (!ctx) {
				ctx = document.createElement('div');
				ctx.id = 'antivandal-ctx';
				document.body.appendChild(ctx);
			}
			ctx.innerHTML = '';
			function item(label, fn) {
				var b = document.createElement('button');
				b.type = 'button';
				b.textContent = label;
				b.addEventListener('click', function () { ctx.style.display = 'none'; fn(); });
				ctx.appendChild(b);
			}
			item('User page', function () { location.href = mw.util.getUrl('User:' + user); });
			item('Talk page', function () { location.href = mw.util.getUrl('User talk:' + user); });
			item('Contributions', function () { location.href = mw.util.getUrl('Special:Contributions/' + user); });
			item('New message', function () { openNewMessage(user); });
			ctx.style.left = e.pageX + 'px';
			ctx.style.top = e.pageY + 'px';
			ctx.style.display = 'block';
		});
		document.addEventListener('click', function () {
			var ctx = document.getElementById('antivandal-ctx');
			if (ctx) { ctx.style.display = 'none'; }
			var more = document.getElementById('antivandal-more');
			if (more && more.style.display === 'block') { more.style.display = 'none'; }
		});
	}
	function neoBtn(iconClass, tip, fn) {
		var b = document.createElement('button');
		b.type = 'button';
		b.className = 'bpneo-title-btn';
		b.title = tip;
		b.setAttribute('aria-label', tip);
		var i = document.createElement('i');
		i.className = iconClass;
		b.appendChild(i);
		if (fn) {
			b.addEventListener('click', function (e) {
				e.preventDefault();
				e.stopPropagation();
				fn(b);
			});
		}
		return b;
	}
	function buildBar() {
		if (document.getElementById('antivandal-open')) { return true; }
		var row = document.querySelector('.bpneo-title-actions');
		if (!row) { return false; }
		var openBtn = neoBtn('ri-error-warning-fill', 'AntiVandal tools');
		openBtn.id = 'antivandal-open';
		openBtn.style.color = '#c62828';
		var tools = document.createElement('span');
		tools.id = 'antivandal-tools';
		tools.appendChild(neoBtn('ri-close-line', 'Close AntiVandal', function () {
			row.classList.remove('av-open');
		}));
		tools.appendChild(neoBtn('ri-arrow-go-back-line', 'Revert last editor', openRevertDialog));
		tools.appendChild(neoBtn('ri-history-line', 'Restore this revision', function () {
			var oldid = mw.util.getParamValue('oldid') || mw.config.get('wgRevisionId');
			if (!oldid) { toast('Open a specific revision first.', 'err'); return; }
			if (!window.confirm('Replace current text with revision ' + oldid + '?')) { return; }
			restoreRevision(oldid).then(function () {
				toast('Restored.', 'ok');
				setTimeout(function () { location.href = mw.util.getUrl(pageName()); }, 600);
			}).catch(function (err) { toast(err.message || String(err), 'err'); });
		}));
		tools.appendChild(neoBtn('ri-refresh-line', 'Latest revision', goLatest));
		tools.appendChild(neoBtn('ri-alarm-warning-line', 'Warn user', function () { openWarnDialog(); }));
		tools.appendChild(neoBtn('ri-user-add-line', 'Welcome new user', function () { openWelcomeUser(); }));
		tools.appendChild(neoBtn('ri-shield-keyhole-line', 'Request protection', requestProtection));
		var moreWrap = document.createElement('span');
		moreWrap.style.position = 'relative';
		var moreBtn = neoBtn('ri-more-2-fill', 'More');
		var menu = document.createElement('div');
		menu.id = 'antivandal-more';
		function menuItem(label, fn) {
			var mb = document.createElement('button');
			mb.type = 'button';
			mb.textContent = label;
			mb.addEventListener('click', function (e) {
				e.stopPropagation();
				menu.style.display = 'none';
				fn();
			});
			menu.appendChild(mb);
		}
		menuItem('New talk-page message', function () { openNewMessage(); });
		menuItem('Report at AIV', reportAiv);
		menuItem('Report to administrators', function () { openAdminReport('admin'); });
		menuItem('Privacy request', function () { openAdminReport('privacy'); });
		menuItem('Multiple notices', openMat);
		menuItem('Pending review', openPendingReview);
		menuItem('Preferences', openPrefs);
		moreBtn.addEventListener('click', function (e) {
			e.preventDefault();
			e.stopPropagation();
			menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
		});
		moreWrap.appendChild(moreBtn);
		moreWrap.appendChild(menu);
		tools.appendChild(moreWrap);
		openBtn.addEventListener('click', function (e) {
			e.preventDefault();
			e.stopPropagation();
			row.classList.add('av-open');
		});
		var slot = document.getElementById('antivandal-slot');
		var editLink = row.querySelector('a.bpneo-title-btn[title="Edit"]') ||
			row.querySelector('a.bpneo-title-btn[href*="action=edit"]');
		if (slot) { slot.replaceWith(openBtn); }
		else if (editLink) { row.insertBefore(openBtn, editLink); }
		else { row.insertBefore(openBtn, row.firstChild); }
		row.appendChild(tools);
		bindUserContextMenu();
		return true;
	}
	function init() {
		if (!mw.config.get('wgUserName')) { return; }
		injectCss();
		api = new mw.Api({ parameters: { formatversion: 2 } });
		var rights = mw.config.get('wgUserGroups') || [];
		hasRollback = rights.indexOf('rollbacker') !== -1 ||
			rights.indexOf('sysop') !== -1 ||
			rights.indexOf('steward') !== -1;
		if (!buildBar()) {
			var tries = 0;
			var t = setInterval(function () {
				tries += 1;
				if (buildBar() || tries > 25) { clearInterval(t); }
			}, 200);
		}
		try { bindContribsRollback(); } catch (e) {}
		fillEditDrafts();
	}
	function fillEditDrafts() {
		if (mw.config.get('wgAction') !== 'edit') { return; }
		var page = mw.config.get('wgPageName') || '';
		var box = document.getElementById('wpTextbox1');
		var subj = document.getElementById('wpSummary');
		if (!box) { return; }
		try {
			if (page === CONF.rfppPage.replace(/ /g, '_')) {
				var d = sessionStorage.getItem('antivandal-rfpp-draft');
				var s = sessionStorage.getItem('antivandal-rfpp-subject');
				if (d && !box.value.trim()) { box.value = d; }
				if (s && subj && !subj.value.trim()) { subj.value = s; }
				sessionStorage.removeItem('antivandal-rfpp-draft');
				sessionStorage.removeItem('antivandal-rfpp-subject');
			}
			if (page === CONF.aivPage.replace(/ /g, '_')) {
				var a = sessionStorage.getItem('antivandal-aiv-draft');
				if (a && !box.value.trim()) { box.value = a; }
				sessionStorage.removeItem('antivandal-aiv-draft');
			}
		} catch (e) {}
	}
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', function () {
			mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(init);
		});
	} else {
		mw.loader.using(['mediawiki.api', 'mediawiki.util']).then(init);
	}
}());