Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5.
/**
 * AntiVandal — Bharatpedia patrol helper
 *
 * Original userscript for en.bharatpedia.org.
 * Inspired by the general idea of icon-driven revert + notice tools,
 * written from scratch for this wiki's API, templates, and noticeboards.
 *
 * Host: User:WikiDwarf/AntiVandal.js
 * Install (Special:MyPage/common.js):
 *   mw.loader.load('/w/index.php?title=User:WikiDwarf/AntiVandal.js&action=raw&ctype=text/javascript');
 *
 * You are responsible for every action this script performs on your account.
 */
(function () {
	'use strict';

	if (window.AntiVandal && window.AntiVandal.loaded) {
		return;
	}

	var RK = (window.AntiVandal = window.AntiVandal || {});
	RK.loaded = true;
	RK.version = '1.0.0';

	var CONF = {
		storageKey: 'antivandal-prefs-v1',
		tag: 'AntiVandal',
		aivPage: 'Bharatpedia:Administrator intervention against vandalism',
		rfppPage: 'Bharatpedia:Requests for page protection',
		docsPage: 'Bharatpedia:AntiVandal'
	};

	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) { /* ignore quota */ }
	}

	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';
			el.setAttribute('role', 'status');
			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;
		}
		if (/^(?:(?:25[0-5]|2[0-4]\d|[01]?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|[01]?\d?\d)$/.test(user) ||
			user.indexOf(':') !== -1 && user.match(/^[0-9a-fA-F:.]+$/)) {
			return 'User talk:' + user;
		}
		return 'User talk:' + user;
	}

	function injectCss() {
		if (document.getElementById('antivandal-css')) {
			return;
		}
		var css = [
			'#antivandal-bar{display:flex;align-items:center;gap:2px;flex-wrap:wrap;',
			'margin:4px 0 8px;height:auto;line-height:1;z-index:3;position:relative;clear:both;}',
			'.page-actions #antivandal-bar,.citizen-page-heading #antivandal-bar{margin:0 0 0 8px;display:inline-flex;}',
			'#antivandal-bar button.rk-ico{background:transparent;border:0;cursor:pointer;width:28px;height:28px;',
			'border-radius:50%;padding:0;font-size:16px;line-height:28px;color:#202122;}',
			'#antivandal-bar button.rk-ico:hover{background:rgba(0,0,0,.08);}',
			'#antivandal-bar button.rk-ico.rk-on{background:#2a4a33;color:#fff;}',
			'.rk-tip{position:absolute;bottom:-1.8em;left:50%;transform:translateX(-50%);background:#323232;color:#fff;',
			'font:11px sans-serif;padding:3px 6px;border-radius:2px;white-space:nowrap;display:none;z-index:20;}',
			'#antivandal-bar button.rk-ico:hover .rk-tip{display:block;}',
			'#antivandal-more{position:absolute;right:0;top:32px;background:#fff;border:1px solid #a2a9b1;',
			'box-shadow:0 2px 8px rgba(0,0,0,.18);min-width:200px;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(0,0,0,.45);z-index:1100;display:flex;',
			'align-items:flex-start;justify-content:center;padding:8vh 16px 16px;}',
			'.rk-modal{background:#fff;color:#1b1b1b;max-width:560px;width:100%;border-radius:8px;',
			'box-shadow:0 12px 40px rgba(0,0,0,.28);overflow:hidden;}',
			'.rk-modal h2{margin:0;padding:12px 16px;background:#1f2a22;color:#f4efe4;font-size:16px;}',
			'.rk-modal .rk-body{padding:14px 16px;}',
			'.rk-modal label{display:block;font-weight:600;margin:10px 0 4px;}',
			'.rk-modal input[type=text],.rk-modal select,.rk-modal textarea{width:100%;box-sizing:border-box;',
			'padding:7px 8px;font:14px sans-serif;}',
			'.rk-modal textarea{min-height:90px;}',
			'.rk-modal .rk-actions{display:flex;justify-content:flex-end;gap:8px;padding:10px 16px;background:#f3f1ea;}',
			'.rk-modal .rk-actions button{padding:7px 14px;border-radius:4px;border:1px solid #bbb;cursor:pointer;}',
			'.rk-modal .rk-actions .rk-primary{background:#1f2a22;color:#fff;border-color:#1f2a22;}',
			'.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;}'
		].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 h = document.createElement('h2');
		h.textContent = title;
		box.appendChild(h);
		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);
		return box;
	}

	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 revertWithSummary(summary, userToRevert) {
		return getLastRev().then(function (info) {
			if (!info) {
				throw new Error('Could not read this page history.');
			}
			var latest = info.latest;
			var targetUser = userToRevert || latest.user;
			if (prefs.useRollback && hasRollback) {
				return api.postWithToken('rollback', {
					action: 'rollback',
					title: pageName(),
					user: targetUser,
					summary: summary + ' ([[' + CONF.docsPage + '|AntiVandal]])',
					formatversion: 2
				}).then(function (res) {
					if (res.error) {
						throw new Error(res.error.info || res.error.code);
					}
					return { method: 'rollback', data: res };
				});
			}
			if (!info.previous) {
				throw new Error('No earlier revision to restore.');
			}
			return api.postWithEditToken({
				action: 'edit',
				title: pageName(),
				undo: latest.revid,
				undoafter: info.previous.revid,
				summary: summary + ' ([[' + CONF.docsPage + '|AntiVandal]])',
				formatversion: 2
			}).then(function (res) {
				if (res.error) {
					throw new Error(res.error.info || res.error.code);
				}
				if (res.edit && res.edit.result !== 'Success') {
					throw new Error('Edit did not succeed.');
				}
				return { method: 'undo', data: res };
			});
		});
	}

	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 + ' ([[' + CONF.docsPage + '|AntiVandal]])',
				basetimestamp: undefined,
				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 += '}} [[User:WikiDwarf|WikiDwarf]] ([[User talk:WikiDwarf|talk]]) 12:28, 21 September 2026 (IST)';
		return api.postWithEditToken({
			action: 'edit',
			title: talk,
			appendtext: '\n\n' + subst,
			summary: 'Notice regarding [[' + (article || pageName()).replace(/_/g, ' ') + ']] ([[' + CONF.docsPage + '|AntiVandal]])',
			formatversion: 2
		});
	}

	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';
			alsoWarn.id = 'rk-also-warn';
			var warnLab = document.createElement('label');
			warnLab.htmlFor = 'rk-also-warn';
			warnLab.style.fontWeight = 'normal';
			warnLab.textContent = ' Open notice dialog after revert';
			var row = document.createElement('div');
			row.style.marginTop = '10px';
			row.appendChild(alsoWarn);
			row.appendChild(warnLab);

			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 whose edit you are reverting.', 'err');
							return;
						}
						toast('Reverting…');
						revertWithSummary(summary, who).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 openWarnDialog(prefillUser, presetId) {
		var wrap = document.createElement('div');
		var userInput = document.createElement('input');
		userInput.type = 'text';
		userInput.value = prefillUser || relevantUser() || '';
		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');
		extra.placeholder = 'Optional extra sentence for templates that accept a second parameter';

		function currentTemplate() {
			var meta = NOTICES.filter(function (n) { return n.id === sel.value; })[0];
			if (meta && meta.leveled) {
				return sel.value + level.value;
			}
			return sel.value;
		}

		wrap.appendChild(field('User', userInput));
		wrap.appendChild(field('Related article', articleInput));
		wrap.appendChild(field('Notice', sel));
		wrap.appendChild(field('Level (if the template has levels)', level));
		wrap.appendChild(field('Extra note', extra));

		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 {{' + tpl + '}}…');
					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 on User talk:' + user, 'ok');
						closeModal();
					}).catch(function (err) {
						toast(err.message || String(err), 'err');
					});
				}
			}
		]);
	}

	function openPrefs() {
		var wrap = document.createElement('div');
		var pos = document.createElement('select');
		['top', 'bottom'].forEach(function (v) {
			var o = document.createElement('option');
			o.value = v;
			o.textContent = v === 'top' ? 'Under the page title' : 'Stick to the bottom';
			pos.appendChild(o);
		});
		pos.value = prefs.barPosition;
		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 this account has the right';
		var row = document.createElement('div');
		row.appendChild(rb);
		row.appendChild(rbLab);
		wrap.appendChild(field('Toolbar placement', pos));
		wrap.appendChild(row);
		openModal('AntiVandal preferences', wrap, [
			{ label: 'Close', onClick: closeModal },
			{
				label: 'Save',
				primary: true,
				onClick: function () {
					prefs.barPosition = pos.value;
					prefs.useRollback = rb.checked;
					savePrefs(prefs);
					closeModal();
					toast('Preferences saved. Reload to move the bar.', '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 p = document.createElement('p');
			p.textContent = 'This opens the AIV noticeboard. Paste the suggested line, or edit it first.';
			var ta = document.createElement('textarea');
			ta.value = '* {{vandal|' + user + '}} – repeated unconstructive editing on [[' +
				pageName().replace(/_/g, ' ') + ']]. [[User:WikiDwarf|WikiDwarf]] ([[User talk:WikiDwarf|talk]]) 12:28, 21 September 2026 (IST)';
			wrap.appendChild(p);
			wrap.appendChild(ta);
			openModal('Report at AIV', wrap, [
				{ label: 'Cancel', onClick: closeModal },
				{
					label: 'Open AIV',
					primary: true,
					onClick: function () {
						try {
							sessionStorage.setItem('antivandal-aiv-draft', ta.value);
						} catch (e) { /* ignore */ }
						window.open(mw.util.getUrl(CONF.aivPage, { action: 'edit', section: 'new' }), '_blank');
					}
				}
			]);
		});
	}

	function requestProtection() {
		window.open(mw.util.getUrl(CONF.rfppPage, { 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');
		body.placeholder = 'Wikitext of the new section';
		wrap.appendChild(field('User', userInput));
		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[[User:WikiDwarf|WikiDwarf]] ([[User talk:WikiDwarf|talk]]) 12:28, 21 September 2026 (IST)';
					toast('Sending…');
					api.postWithEditToken({
						action: 'edit',
						title: talkTitleFor(user),
						appendtext: text,
						summary: 'New message ([[' + CONF.docsPage + '|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');
					});
				}
			}
		]);
	}

	var alertTimer = null;
	var alertLastRev = null;
	function toggleAlert(btn) {
		if (alertTimer) {
			clearInterval(alertTimer);
			alertTimer = null;
			btn.classList.remove('rk-on');
			toast('Alert on change off.');
			return;
		}
		btn.classList.add('rk-on');
		toast('Watching this page for new edits.');
		getLastRev().then(function (info) {
			alertLastRev = info && info.latest ? info.latest.revid : null;
		});
		alertTimer = setInterval(function () {
			getLastRev().then(function (info) {
				var id = info && info.latest ? info.latest.revid : null;
				if (id && alertLastRev && id !== alertLastRev) {
					alertLastRev = id;
					if (window.Notification && Notification.permission === 'granted') {
						new Notification('AntiVandal', { body: pageName().replace(/_/g, ' ') + ' was edited.' });
					}
					toast('New edit on this page.', 'ok');
					goLatest();
				} else if (id) {
					alertLastRev = id;
				}
			});
		}, 12000);
		if (window.Notification && Notification.permission === 'default') {
			Notification.requestPermission();
		}
	}

	function openMoreMenu(anchor) {
		var menu = document.getElementById('antivandal-more');
		if (!menu) {
			return;
		}
		menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
	}

	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); });
			item('Notice', function () { openWarnDialog(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 addDiffRestore() {
		if (mw.config.get('wgDiffNewId') || mw.config.get('wgDiffOldId') ||
			mw.config.get('wgAction') === 'history') {
			var oldid = mw.util.getParamValue('oldid') || mw.config.get('wgRevisionId');
			if (!oldid) {
				return;
			}
			var bar = document.getElementById('antivandal-bar');
			if (!bar) {
				return;
			}
			var b = document.createElement('button');
			b.type = 'button';
			b.textContent = 'Restore this revision';
			b.addEventListener('click', function () {
				if (!window.confirm('Replace the current page text with revision ' + oldid + '?')) {
					return;
				}
				toast('Restoring…');
				restoreRevision(oldid).then(function (res) {
					if (res.error) {
						throw new Error(res.error.info || res.error.code);
					}
					toast('Revision restored.', 'ok');
					setTimeout(function () { location.href = mw.util.getUrl(pageName()); }, 600);
				}).catch(function (err) {
					toast(err.message || String(err), 'err');
				});
			});
			bar.appendChild(b);
		}
	}

	function buildBar() {
		if (document.getElementById('antivandal-bar')) {
			return;
		}
		if (mw.config.get('wgCanonicalNamespace') === 'Special') {
			return;
		}

		var bar = document.createElement('div');
		bar.id = 'antivandal-bar';

		function ico(symbol, tip, fn) {
			var b = document.createElement('button');
			b.type = 'button';
			b.className = 'rk-ico';
			b.setAttribute('aria-label', tip);
			b.appendChild(document.createTextNode(symbol));
			var t = document.createElement('span');
			t.className = 'rk-tip';
			t.textContent = tip;
			b.appendChild(t);
			b.addEventListener('click', function (e) {
				e.stopPropagation();
				fn(b);
			});
			bar.appendChild(b);
			return b;
		}

		ico('↶', 'Revert last editor', openRevertDialog);
		ico('!', 'Post a notice', function () { openWarnDialog(); });
		ico('✉', 'New talk-page message', function () { openNewMessage(); });
		ico('⟳', 'Latest revision', goLatest);
		var alertBtn = ico('◎', 'Alert on change', function () { toggleAlert(alertBtn); });
		ico('⛨', 'Request protection', requestProtection);

		var moreWrap = document.createElement('span');
		moreWrap.style.position = 'relative';
		var moreBtn = document.createElement('button');
		moreBtn.type = 'button';
		moreBtn.className = 'rk-ico';
		moreBtn.setAttribute('aria-label', 'More');
		moreBtn.appendChild(document.createTextNode('⋮'));
		var moreTip = document.createElement('span');
		moreTip.className = 'rk-tip';
		moreTip.textContent = 'More';
		moreBtn.appendChild(moreTip);
		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('Report at AIV', reportAiv);
		menuItem('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 the current page text with revision ' + oldid + '?')) {
				return;
			}
			toast('Restoring…');
			restoreRevision(oldid).then(function (res) {
				if (res.error) {
					throw new Error(res.error.info || res.error.code);
				}
				toast('Revision restored.', 'ok');
				setTimeout(function () { location.href = mw.util.getUrl(pageName()); }, 600);
			}).catch(function (err) {
				toast(err.message || String(err), 'err');
			});
		});
		menuItem('Preferences', openPrefs);
		moreBtn.addEventListener('click', function (e) {
			e.stopPropagation();
			menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
		});
		moreWrap.appendChild(moreBtn);
		moreWrap.appendChild(menu);
		bar.appendChild(moreWrap);

		var mount =
			document.querySelector('.page-actions') ||
			document.querySelector('.citizen-page-heading__actions') ||
			document.querySelector('.vector-page-toolbar-container') ||
			document.querySelector('#p-views') ||
			null;
		var heading = document.getElementById('firstHeading');
		if (mount) {
			mount.appendChild(bar);
		} else if (heading && heading.parentNode) {
			heading.parentNode.insertBefore(bar, heading.nextSibling);
		} else {
			var content = document.getElementById('mw-content-text') || document.getElementById('content');
			if (content) {
				content.parentNode.insertBefore(bar, content);
			} else {
				document.body.insertBefore(bar, document.body.firstChild);
			}
		}

		bindUserContextMenu();
	}

	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;
		buildBar();
	}

	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);
	}
}());