Jump to content

User:Daniel Quinlan/Scripts/UserHighlighter.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
"use strict";

class LocalStorageCache {
	constructor(name, modifier = null, ttl = 60, capacity = 1000) {
		this.name = name;
		this.ttl = ttl;
		this.capacity = capacity;
		this.divisor = 60000;
		this.data = null;
		this.start = null;
		this.hitCount = 0;
		this.missCount = 0;
		this.invalid = false;

		try {
			// load
			const dataString = localStorage.getItem(this.name);
			this.data = dataString ? JSON.parse(dataString) : {};

			// setup
			const currentTime = Math.floor(Date.now() / this.divisor);
			this.start = this.data['#start'] || currentTime;
			if ('#hc' in this.data && '#mc' in this.data) {
				this.hitCount = this.data['#hc'];
				this.missCount = this.data['#mc'];
			}
			delete this.data['#start'];
			delete this.data['#hc'];
			delete this.data['#mc'];
			modifier = modifier || ((key, value) => key.startsWith('#') ? 24 : 1);

			// expire
			for (const [key, value] of Object.entries(this.data)) {
				const ttl = this.ttl * modifier(key, value[1]);
				if (value[0] + this.start <= currentTime - ttl) {
					delete this.data[key];
				}
			}
		} catch (error) {
			console.error(`LocalStorageCache error reading "${this.name}":`, error);
			localStorage.removeItem(this.name);
			this.invalid = true;
		}
	}

	fetch(key) {
		if (this.invalid) {
			return undefined;
		}
		if (key in this.data) {
			this.hitCount++;
			return { time: this.data[key][0] + this.start, value: this.data[key][1] };
		} else {
			this.missCount++;
			return undefined;
		}
	}

	store(key, value, expiry = undefined) {
		if (expiry) {
			expiry = expiry instanceof Date ? expiry.getTime() : Date.parse(expiry);
			if (expiry < Date.now() + (this.ttl * 60000)) {
				return;
			}
		}
		this.data[key] = [Math.floor(Date.now() / this.divisor) - this.start, value];
	}

	invalidate(predicate) {
		Object.keys(this.data).forEach(key => predicate(key) && delete this.data[key]);
	}

	clear() {
		const specialKeys = ['#hc', '#mc', '#start'];
		this.data = Object.fromEntries(
			Object.entries(this.data).filter(([key]) => specialKeys.includes(key))
		);
	}

	save() {
		try {
			// pruning
			if (Object.keys(this.data).length > this.capacity) {
				const sortedKeys = Object.keys(this.data).sort((a, b) => this.data[a][0] - this.data[b][0]);
				let excess = sortedKeys.length - this.capacity;
				for (const key of sortedKeys) {
					if (excess <= 0) {
						break;
					}
					delete this.data[key];
					excess--;
				}
			}
			// empty
			if (!Object.keys(this.data).length) {
				localStorage.setItem(this.name, JSON.stringify(this.data));
				return;
			}
			// rebase timestamps
			const first = Math.min(...Object.values(this.data).map(entry => entry[0]));
			if (isNaN(first) && !isFinite(first)) {
				throw new Error(`Invalid first timestamp: ${first}`);
			}
			for (const key in this.data) {
				this.data[key][0] -= first;
			}
			this.start = this.start + first;
			this.data['#start'] = this.start;
			this.data['#hc'] = this.hitCount;
			this.data['#mc'] = this.missCount;
			localStorage.setItem(this.name, JSON.stringify(this.data));
			delete this.data['#start'];
			delete this.data['#hc'];
			delete this.data['#mc'];
		} catch (error) {
			console.error(`LocalStorageCache error saving "${this.name}":`, error);
			localStorage.removeItem(this.name);
			this.invalid = true;
		}
	}
}

class UserStatus {
	constructor(apiHighlimits, groupBit, callback) {
		this.api = new mw.Api();
		this.apiHighlimits = apiHighlimits;
		this.groupBit = groupBit;
		this.callback = callback;
		this.relevantUsers = this.getRelevantUsers();
		this.eventCache = new LocalStorageCache('uh-event-cache');
		this.usersCache = new LocalStorageCache('uh-users-cache', this.userModifier);
		this.bkusersCache = new LocalStorageCache('uh-bkusers-cache');
		this.bkipCache = new LocalStorageCache('uh-bkip-cache');
		this.users = new Map();
		this.ips = new Map();
	}

	static IPV4REGEX = /^(?:1?\d\d?|2[0-2]\d)\b(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3}$/;
	static IPV6REGEX = /^[\dA-Fa-f]{1,4}(?:\:[\dA-Fa-f]{1,4}){7}$/;

	getRelevantUsers() {
		const { IPV4REGEX, IPV6REGEX } = UserStatus;
		let rusers = [];
		if (![-1, 2, 3].includes(mw.config.get('wgNamespaceNumber'))) {
			return new Set(rusers);
		}
		let ruser = mw.config.get('wgRelevantUserName');
		let mask;
		if (!ruser) {
			const page = mw.config.get('wgPageName');
			const match = page.match(/^Special:\w+\/([^\/]+)(?:\/(\d{2,3}$))?/i);
			if (match) {
				ruser = match[1];
				mask = match[2];
			}
		}
		if (ruser) {
			if (IPV6REGEX.test(ruser)) {
				ruser = ruser.toUpperCase();
				rusers.push(this.ipRangeKey(ruser));
			}
			rusers.push(ruser);
			if (mask && Number(mask) !== 64 && (IPV4REGEX.test(ruser) || IPV6REGEX.test(ruser))) {
				rusers.push(`${ruser}/${mask}`);
			}
			rusers = rusers.filter(key => key && key !== mw.config.get('wgUserName'));
		}
		return new Set(rusers);
	}

	userModifier = (key, value) => {
		if (value & this.groupBit.sysop)
			return 24;
		else if (value & this.groupBit.extendedconfirmed)
			return 3;
		return 1;
	};

	userFetch(cache, key) {
		const cachedState = cache.fetch(key);
		if (!cachedState || this.relevantUsers.has(key)) {
			return false;
		}
		const cachedEvent = this.eventCache.fetch(key);
		if (cachedEvent && cachedState.time <= cachedEvent.time) {
			return false;
		}
		return cachedState;
	}

	ipRangeKey(ip) {
		return ip.includes('.') ? ip : ip.split('/')[0].split(':').slice(0, 4).join(':');
	}

	query(user, context) {
		const { IPV4REGEX, IPV6REGEX } = UserStatus;

		const processIP = (ip, context) => {
			const bkusersCached = this.userFetch(this.bkusersCache, ip);
			const bkipCached = this.userFetch(this.bkipCache, this.ipRangeKey(ip));
			if (bkusersCached && bkipCached) {
				this.callback(context, bkusersCached.value | bkipCached.value);
				return;
			}
			this.ips.has(ip) ? this.ips.get(ip).push(context) : this.ips.set(ip, [context]);
		};

		const processUser = (user, context) => {
			const cached = this.userFetch(this.usersCache, user);
			if (cached) {
				this.callback(context, cached.value);
				return;
			}
			this.users.has(user) ? this.users.get(user).push(context) : this.users.set(user, [context]);
		};

		if (IPV4REGEX.test(user)) {
			processIP(user, context);
		} else if (IPV6REGEX.test(user)) {
			processIP(user.toUpperCase(), context);
		} else {
			if (user.charAt(0) === user.charAt(0).toLowerCase()) {
				user = user.charAt(0).toUpperCase() + user.slice(1);
			}
			processUser(user, context);
		}
	}

	async checkpoint(initialRun) {
		if (!this.users.size && !this.ips.size) {
			return;
		}

		// queries
		const usersPromise = this.usersQueries(this.users);
		const bkusersPromise = this.bkusersQueries(this.ips);
		usersPromise.then(usersResponses => {
			this.applyResponses(this.users, usersResponses);
		});
		bkusersPromise.then(bkusersResponses => {
			this.applyResponses(this.ips, bkusersResponses);
		});
		await bkusersPromise;
		const bkipPromise = this.bkipQueries(this.ips);
		await Promise.all([usersPromise, bkipPromise]);

		// save caches
		if (initialRun) {
			this.usersCache.save();
			this.bkusersCache.save();
			this.bkipCache.save();
		}

		// clear maps
		this.users.clear();
		this.ips.clear();
	}

	*chunks(full, n) {
		for (let i = 0; i < full.length; i += n) {
			yield full.slice(i, i + n);
		}
	}

	async postRequest(api, data, callback, property) {
		try {
			const response = await api.post({ action: 'query', format: 'json', ...data });
			if (response.query && response.query[property]) {
				const cumulativeResult = {};
				response.query[property].forEach(item => {
					const result = callback(item);
					if (result) {
						cumulativeResult[result.key] = result.value;
					}
				});
				return cumulativeResult;
			} else {
				throw new Error("JSON location not found or empty");
			}
		} catch (error) {
			throw new Error(`Failed to fetch data: ${error.message}`);
		}
	}

	async usersQueries(users) {
		const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;

		const processUser = (user) => {
			let state = 0;
			if (user.blockid) {
				state = 'blockpartial' in user ? PARTIAL :
					(user.blockexpiry === 'infinite' ? INDEFINITE : TEMPORARY);
			}
			if (user.groups) {
				state = user.groups.reduce((accumulator, name) => {
					return accumulator | (this.groupBit[name] || 0);
				}, state);
			}
			return { key: user.name, value: state };
		};

		const responses = {};
		const chunkSize = this.apiHighlimits ? 500 : 50;
		const queryData = {
			list: 'users',
			usprop: 'blockinfo|groups'
		};
		for (const chunk of this.chunks(Array.from(users.keys()), chunkSize)) {
			try {
				queryData.ususers = chunk.join('|');
				const data = await this.postRequest(this.api, queryData, processUser, 'users');
				Object.assign(responses, data);
			} catch (error) {
				throw new Error(`Failed to fetch users: ${error.message}`);
			}
		}

		for (const [user, state] of Object.entries(responses)) {
			this.usersCache.store(user, state);
		}
		return responses;
	}

	async bkusersQueries(ips) {
		const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;

		const processBlock = (block) => {
			const partial = block.restrictions && !Array.isArray(block.restrictions);
			const state = partial ? PARTIAL : (
				/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
			const user = block.user.endsWith('/64') ? this.ipRangeKey(block.user) : block.user;
			return { key: user, value: state };
		};

		const ipQueries = new Set();
		for (const ip of ips.keys()) {
			const cached = this.userFetch(this.bkusersCache, ip);
			if (!cached) {
				ipQueries.add(ip);
				if (ip.includes(':')) {
					ipQueries.add(this.ipRangeKey(ip) + '::/64');
				}
			}
		}

		const responses = {};
		const chunkSize = this.apiHighlimits ? 500 : 50;
		const queryData = {
			list: 'blocks',
			bklimit: 500,
			bkprop: 'user|by|timestamp|expiry|reason|restrictions'
		};
		let queryError = false;
		for (const chunk of this.chunks(Array.from(ipQueries.keys()), chunkSize)) {
			try {
				queryData.bkusers = chunk.join('|');
				const data = await this.postRequest(this.api, queryData, processBlock, 'blocks');
				Object.assign(responses, data);
			} catch (error) {
				queryError = true;
				throw new Error(`Failed to fetch bkusers: ${error.message}`);
			}
		}

		// check possible responses
		const results = {};
		for (const ip of ips.keys()) {
			if (!ipQueries.has(ip)) {
				continue;
			}
			let state = responses[ip] || 0;
			if (ip.includes(':')) {
				const range = this.ipRangeKey(ip);
				const rangeState = responses[range] || 0;
				state = Math.max(state, rangeState);
			}
			// store single result, only blocks are returned so skip if any errors
			if (!queryError) {
				this.bkusersCache.store(ip, state);
			}
			results[ip] = state;
		}
		return results;
	}

	async bkipQueries(ips) {
		const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;

		function processBlock(block) {
			const partial = block.restrictions && !Array.isArray(block.restrictions);
			const state = partial ? PARTIAL : (
				/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
			return { key: block.id, value: state };
		}

		const addRangeBlock = (ips, ip, state) => {
			if (ips.get(ip) && state) {
				ips.get(ip).forEach(context => this.callback(context, state));
			}
		};

		// check cache and build queries
		const ipRanges = {};
		for (const ip of ips.keys()) {
			const range = this.ipRangeKey(ip);
			const cached = this.userFetch(this.bkipCache, range);
			if (cached) {
				addRangeBlock(ips, ip, cached.value);
			} else {
				if (!ipRanges.hasOwnProperty(range))
					ipRanges[range] = [];
				ipRanges[range].push(ip);
			}
		}

		const queryData = {
			list: 'blocks',
			bklimit: 100,
			bkprop: 'user|id|by|timestamp|expiry|range|reason|restrictions'
		};
		for (const [range, ipGroup] of Object.entries(ipRanges)) {
			const responses = {};
			let queryError = false;
			try {
				queryData.bkip = range.includes(':') ? range + '::/64' : range;
				const data = await this.postRequest(this.api, queryData, processBlock, 'blocks');
				Object.assign(responses, data);
			} catch (error) {
				queryError = true;
				console.error(`Failed to fetch bkip for range ${range}: ${error.message}`);
			}
			let state = 0;
			if (Object.keys(responses).length) {
				state = Math.max(...Object.values(responses));
			}
			ipGroup.forEach(ip => {
				addRangeBlock(ips, ip, state);
			});
			if (!queryError) {
				this.bkipCache.store(range, state);
			}
		}
	}

	applyResponses(queries, responses) {
		for (const [name, state] of Object.entries(responses)) {
			queries.get(name)?.forEach(context => this.callback(context, state));
		}
	}

	event() {
		const eventCache = new LocalStorageCache('uh-event-cache');
		this.relevantUsers.forEach(key => {
			let mask = key.match(/\/(\d+)$/);
			if (mask) {
				const groups = mask[1] < 32 ? 1 : (mask[1] < 48 ? 2 : 3);
				const pattern = `^(?:\\d+\\.\\d+\\.|(?:\\w+:){${groups}})`;
				const match = key.match(pattern);
				if (match) {
					const bkipCache = new LocalStorageCache('uh-bkip-cache');
					bkipCache.invalidate(str => str.startsWith(match[0]));
					bkipCache.save();
				}
			} else {
				eventCache.store(key, true);
			}
		});
		eventCache.save();
	}

	async clearUsers() {
		this.usersCache.clear();
		this.usersCache.save();
	}
}

class UserHighlighter {
	constructor() {
		this.initialRun = true;
		this.taskQueue = new Map();
		this.hrefCache = new Map();
		this.siteCache = new LocalStorageCache('uh-site-cache');
		this.options = null;
		this.bitGroup = null;
		this.groupBit = null;
		this.pathnames = null;
		this.serverPrefix = window.location.origin;
		this.startPromise = this.start();
		this.processPromise = Promise.resolve();
		this.debug = localStorage.getItem('uh-debug');
	}

	// compact user state
	static PARTIAL = 0b0001;
	static TEMPORARY = 0b0010;
	static INDEFINITE = 0b0011;
	static BLOCK_MASK = 0b0011;
	static GROUP_START = 0b0100;

	// settings
	static ACTION_API = 'https://en.wikipedia.org/w/api.php';
	static STYLESHEET = 'User:Daniel Quinlan/Scripts/UserHighlighter.css';
	static DEFAULTS = {
		groups: {
			extendedconfirmed: { bit: 0b0100 },
			sysop: { bit: 0b1000 },
			bot: { bit: 0b10000 }
		},
		labels: {},
		stylesheet: true
	};

	async start() {
		let apiHighLimits;
		[apiHighLimits, this.options, this.pathnames] = await Promise.all([
			this.getApiHighLimits(),
			this.getOptions(),
			this.getPathnames()
		]);
		this.injectStyle();
		this.bitGroup = {};
		this.groupBit = {};
		for (const [groupName, groupData] of Object.entries(this.options.groups)) {
			this.bitGroup[groupData.bit] = groupName;
			this.groupBit[groupName] = groupData.bit;
		}
		this.userStatus = new UserStatus(apiHighLimits, this.groupBit, this.applyClasses);
		this.bindEvents();
	}

	async execute($content) {
		const enqueue = (task) => {
			this.taskQueue.set(task, true);
		};

		const dequeue = () => {
			const task = this.taskQueue.keys().next().value;
			if (task) {
				this.taskQueue.delete(task);
				return task;
			}
			return null;
		};

		try {
			// set content
			if (this.initialRun) {
				const target = document.getElementById('bodyContent') ||
					document.getElementById('mw-content-text') ||
					document.body;
				if (target) {
					enqueue(target);
				}
				await this.startPromise;
			} else if ($content && $content.length) {
				for (const node of $content) {
					if (node.nodeType === Node.ELEMENT_NODE) {
						enqueue(node);
					}
				}
			}

			// debugging
			if (this.debug) {
				console.debug("UserHighlighter execute: content", $content, "taskQueue size", this.taskQueue.size, "initialRun", this.initialRun, "timestamp", performance.now());
			}

			// process content, avoiding concurrent processing
			const currentPromise = this.processPromise;
			this.processPromise = currentPromise
				.then(() => this.processContent(dequeue))
				.catch((error) => {
					console.error("UserHighlighter error in processContent:", error);
				});
		} catch (error) {
			console.error("UserHighlighter error in execute:", error);
		}
	}

	async processContent(dequeue) {
		let task;

		while (task = dequeue()) {
			const elements = task.querySelectorAll('a[href]:not(.userlink)');

			for (let i = 0; i < elements.length; i++) {
				const href = elements[i].getAttribute('href');
				let userResult = this.hrefCache.get(href);
				if (userResult === undefined) {
					userResult = this.getUser(href);
					this.hrefCache.set(href, userResult);
				}
				if (userResult) {
					this.userStatus.query(userResult[0], elements[i]);
				}
			}
		}
		await this.userStatus.checkpoint(this.initialRun);

		if (this.initialRun) {
			this.addOptionsLink();
			this.checkPreferences();
		}
		this.initialRun = false;
	}

	applyClasses = (element, state) => {
		const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;
		let classNames = ['userlink'];
		let labelNames = new Set();

		// extract group bits using a technique based on Kernighan's algorithm
		let userGroupBits = state & ~BLOCK_MASK;
		while (userGroupBits) {
			const bitPosition = userGroupBits & -userGroupBits;
			if (this.bitGroup.hasOwnProperty(bitPosition)) {
				const groupName = this.bitGroup[bitPosition];
				classNames.push(`uh-${groupName}`);
				if (this.options.labels[groupName]) {
					labelNames.add(this.options.labels[groupName]);
				}
			}
			userGroupBits &= ~bitPosition;
		}

		// optionally add labels
		if (labelNames.size) {
			const href = element.getAttribute('href');
			if (href) {
				let userResult = this.hrefCache.get(href);
				if (userResult === undefined) {
					userResult = this.getUser(href);
					this.hrefCache.set(href, userResult);
				}
				if (userResult && userResult[1] === 2) {
					if (element.hasAttribute("data-labels")) {
						element.getAttribute("data-labels").slice(1, -1).split(', ').filter(Boolean)
							.forEach(label => labelNames.add(label));
					}
					element.setAttribute('data-labels', `[${[...labelNames].join(', ')}]`);
				}
			}
		}

		// blocks
		switch (state & BLOCK_MASK) {
			case INDEFINITE: classNames.push('user-blocked-indef'); break;
			case TEMPORARY: classNames.push('user-blocked-temp'); break;
			case PARTIAL: classNames.push('user-blocked-partial'); break;
		}

		// add classes
		classNames = classNames.filter(name => !element.classList.contains(name));
		element.classList.add(...classNames);
	};

	// return user for '/wiki/User:', '/wiki/User_talk:', '/wiki/Special:Contributions/',
	// and '/w/index.php?title=User:' links
	getUser(url) {
		// skip links that won't be user pages
		if (!url || !(url.startsWith('/') || url.startsWith('https://')) || url.startsWith('//')) {
			return false;
		}

		// skip links that aren't to user pages
		if (!url.includes(this.pathnames.articlePath) && !url.includes(this.pathnames.scriptPath)) {
			return false;
		}

		// strip server prefix
		if (!url.startsWith('/')) {
			if (url.startsWith(this.serverPrefix)) {
				url = url.substring(this.serverPrefix.length);
			}
			else {
				return false;
			}
		}

		// skip links without ':'
		if (!url.includes(':')) {
			return false;
		}

		// extract title
		let title;
		const paramsIndex = url.indexOf('?');
		if (url.startsWith(this.pathnames.articlePath)) {
			title = url.substring(this.pathnames.articlePath.length, paramsIndex === -1 ? url.length : paramsIndex);
		} else if (paramsIndex !== -1 && url.startsWith(mw.config.get('wgScript'))) {
			// extract the value of "title" parameter and decode it
			const queryString = url.substring(paramsIndex + 1);
			const queryParams = new URLSearchParams(queryString);
			title = queryParams.get('title');
			// skip links with disallowed parameters
			if (title) {
				const allowedParams = ['action', 'redlink', 'safemode', 'title'];
				const hasDisallowedParams = Array.from(queryParams.keys()).some(name => !allowedParams.includes(name));
				if (hasDisallowedParams) {
					return false;
				}
			}
		}
		if (!title) {
			return false;
		}
		title = title.replaceAll('_', ' ');
		try {
			if (/\%[\dA-Fa-f][\dA-Fa-f]/.test(title)) {
				title = decodeURIComponent(title);
			}
		} catch (error) {
			console.warn(`UserHighlighter error decoding "${title}":`, error);
			return false;
		}

		// determine user and namespace from the title
		let user;
		let namespace;
		const lowercaseTitle = title.toLowerCase();
		for (const [userString, namespaceNumber] of Object.entries(this.pathnames.userStrings)) {
			if (lowercaseTitle.startsWith(userString)) {
				user = title.substring(userString.length);
				namespace = namespaceNumber;
				break;
			}
		}
		if (!user || user.includes('/')) {
			return false;
		}
		if (user.toLowerCase().endsWith('#top')) {
			user = user.slice(0, -4);
		}
		return user && !user.includes('#') ? [user, namespace] : false;
	}

	bindEvents() {
		const buttonClick = (event) => {
			try {
				const button = $(event.target).text();
				if (/block|submit/i.test(button)) {
					this.userStatus.event();
				}
			} catch (error) {
				console.error("UserHighlighter error in buttonClick:", error);
			}
		};

		const dialogOpen = (event, ui) => {
			try {
				const dialog = $(event.target).closest('.ui-dialog');
				const title = dialog.find('.ui-dialog-title').text();
				if (title.toLowerCase().includes('block')) {
					dialog.find('button').on('click', buttonClick);
				}
			} catch (error) {
				console.error("UserHighlighter error in dialogOpen:", error);
			}
		};

		if (!this.userStatus.relevantUsers.size) {
			return;
		}

		if (['Block', 'Unblock'].includes(mw.config.get('wgCanonicalSpecialPageName'))) {
			$(document.body).on('click', 'button', buttonClick);
		}

		$(document.body).on('dialogopen', dialogOpen);
	}

	async getOptions() {
		const optionString = mw.user.options.get('userjs-userhighlighter');
		let options;
		try {
			if (optionString !== null) {
				const options = JSON.parse(optionString);
				if (typeof options === 'object')
					return options;
			}
		} catch (error) {
			console.error("UserHighlighter error reading options:", error);
		}
		await this.saveOptions(UserHighlighter.DEFAULTS);
		return JSON.parse(JSON.stringify(UserHighlighter.DEFAULTS));
	}

	async saveOptions(options) {
		const value = JSON.stringify(options);
		await new mw.Api().saveOption('userjs-userhighlighter', value).then(function() {
			mw.user.options.set('userjs-userhighlighter', value);
		}).fail(function(xhr, status, error) {
			console.error("UserHighlighter error saving options:", error);
		});
	}

	addOptionsLink() {
		if (mw.config.get('wgTitle') !== mw.config.get('wgUserName') + '/common.css') {
			return;
		}
		mw.util.addPortletLink('p-tb', '#', "User highlighter options", 'ca-userhighlighter-options');
		$("#ca-userhighlighter-options").click((event) => {
			event.preventDefault();
			mw.loader.using(['oojs-ui']).done(() => {
				this.showOptions();
			});
		});
	}

	async showOptions() {
		// create fieldsets
		const appearanceFieldset = new OO.ui.FieldsetLayout({ label: 'Appearance' });
		const stylesheetToggle = new OO.ui.CheckboxInputWidget({
			selected: !!this.options.stylesheet
		});
		appearanceFieldset.addItems([
			new OO.ui.FieldLayout(stylesheetToggle, {
				label: 'Enable default stylesheet',
				align: 'inline'
			})
		]);

		const groupsFieldset = new OO.ui.FieldsetLayout({ label: 'User groups' });
		const groups = await this.getGroups();
		Object.entries(groups).forEach(([groupName, groupNumber]) => {
			const groupCheckbox = new OO.ui.CheckboxInputWidget({
				selected: !!this.options.groups[groupName]?.bit
			});
			const groupFieldLayout = new OO.ui.FieldLayout(groupCheckbox, {
				label: `${groupName} (${groupNumber})`,
				align: 'inline'
			});
			groupsFieldset.addItems(groupFieldLayout);
		});

		const labelsFieldset = new OO.ui.FieldsetLayout({ label: 'Group labels' });
		const mappings = Object.entries(this.options.labels)
			.map(([group, label]) => `${group}=${label}`)
			.join(', ');
		const mappingsTextarea = new OO.ui.MultilineTextInputWidget({
			value: mappings,
			autosize: true,
			placeholder: 'format: group=label (separate with whitespace or commas)'
		});
		labelsFieldset.addItems([mappingsTextarea]);

		const defaultsFieldset = new OO.ui.FieldsetLayout({ label: 'Load default options' });
		const defaultsButton = new OO.ui.ButtonWidget({
			label: 'Load defaults',
			flags: ['safe'],
			title: 'Load defaults (does not save automatically)'
		});
		defaultsFieldset.addItems([defaultsButton]);

		// define options dialog
		class OptionsDialog extends OO.ui.ProcessDialog {
			static static = {
				name: 'user-highlighter-options',
				title: 'User highlighter options',
				escapable: true,
				actions: [
					{ action: 'save', label: 'Save', flags: ['primary', 'progressive'], title: 'Save options' },
					{ action: 'cancel', label: 'Cancel', flags: ['safe', 'close'] }
				]
			};

			initialize() {
				super.initialize();
				this.content = new OO.ui.PanelLayout({ padded: true, expanded: false });
				this.content.$element.append(appearanceFieldset.$element, groupsFieldset.$element, labelsFieldset.$element, defaultsFieldset.$element);
				this.$body.append(this.content.$element);
				defaultsButton.connect(this, { click: 'loadDefaults' });
			}

			getActionProcess(action) {
				if (action === 'save') {
					return new OO.ui.Process(async () => {
						await this.parent.setGroups(groups, groupsFieldset);
						this.parent.options.stylesheet = stylesheetToggle.isSelected();
						this.parent.parseGroupMappings(mappingsTextarea.getValue());
						await this.parent.saveOptions(this.parent.options);
						await this.parent.userStatus.clearUsers();
						this.close({ action: 'save' });
					});
				} else if (action === 'cancel') {
					return new OO.ui.Process(() => {
						this.close({ action: 'cancel' });
					});
				}
				return super.getActionProcess(action);
			}

			loadDefaults() {
				this.parent.options = JSON.parse(JSON.stringify(UserHighlighter.DEFAULTS));
				appearanceFieldset.items[0].fieldWidget.setSelected(!!this.parent.options.stylesheet);
				groupsFieldset.items.forEach(item => {
					const groupName = item.label.split(' ')[0];
					item.fieldWidget.setSelected(!!this.parent.options.groups[groupName]?.bit);
				});
				const newMappings = Object.entries(this.parent.options.labels)
					.map(([group, label]) => `${group}=${label}`)
					.join(', ');
				mappingsTextarea.setValue(newMappings);
			}
		}

		// create and open the dialog
		const windowManager = new OO.ui.WindowManager();
		$('body').append(windowManager.$element);
		const dialog = new OptionsDialog();
		dialog.parent = this; // set parent reference for methods
		windowManager.addWindows([dialog]);
		windowManager.openWindow(dialog);
	}

	async setGroups(groups, groupsFieldset) {
		// reinitialize groups
		this.options.groups = {};
		this.groupBit = {};
		this.bitGroup = {};

		// filter selected checkboxes, extract labels, and sort by number in descending order
		const orderedGroups = groupsFieldset.items
			.filter(item => item.fieldWidget.isSelected())
			.map(item => item.label.split(' ')[0])
			.sort((a, b) => (groups[b] ?? 0) - (groups[a] ?? 0));

		// assign bits to the selected groups
		let nextBit = UserHighlighter.GROUP_START;
		orderedGroups.forEach(groupName => {
			this.options.groups[groupName] = { bit: nextBit };
			this.groupBit[groupName] = nextBit;
			this.bitGroup[nextBit] = groupName;
			nextBit <<= 1;
		});
	}

	parseGroupMappings(text) {
		this.options.labels = {};
		Object.keys(this.options.groups).forEach(groupName => {
			const pattern = new RegExp(`\\b${groupName}\\b[^\\w\\-]+([\\w\\-]+)`);
			const match = text.match(pattern);
			if (match) {
				this.options.labels[groupName] = match[1];
			}
		});
	}

	checkPreferences() {
		if (mw.user.options.get('gadget-markblocked')) {
			mw.notify($('<span>If you are using UserHighlighter, disable <a href="/wiki/Special:Preferences#mw-prefsection-gadgets" style="text-decoration: underline;">Strike out usernames that have been blocked</a> in preferences.</span>'), { autoHide: false, tag: 'uh-warning', title: "User highlighter", type: 'warn' });
		}
	}

	async injectStyle() {
		if (!this.options.stylesheet) {
			return;
		}
		let cached = this.siteCache.fetch('#stylesheet');
		let css = cached !== undefined ? cached.value : undefined;
		if (!css) {
			try {
				const response = await new mw.ForeignApi(UserHighlighter.ACTION_API).get({
					action: 'query',
					formatversion: '2',
					prop: 'revisions',
					rvprop: 'content',
					rvslots: 'main',
					titles: UserHighlighter.STYLESHEET
				});
				css = response.query.pages[0].revisions[0].slots.main.content;
				css = css.replace(/\n\s*|\s+(?=[!\{])|;(?=\})|(?<=[,:])\s+/g, '');
				this.siteCache.store('#stylesheet', css);
				this.siteCache.save();
			} catch (error) {
				console.error("UserHighlighter error fetching CSS:", error);
			}
		}
		if (css) {
			const style = document.createElement("style");
			style.textContent = css;
			document.head.appendChild(style);
		}
	}

	async getPathnames() {
		let cached = this.siteCache.fetch('#pathnames');
		// last condition can be removed after one day
		if (cached && cached.value && cached.value.userStrings) {
			return cached.value;
		}
		// contributions
		let contributionsPage = 'contributions';
		try {
			const response = await new mw.Api().get({
				action: 'query',
				format: 'json',
				formatversion: '2',
				meta: 'siteinfo',
				siprop: 'specialpagealiases'
			});
			const contributionsItem = response.query.specialpagealiases
				.find(item => item.realname === 'Contributions');
			if (contributionsItem && contributionsItem.aliases) {
				contributionsPage = contributionsItem.aliases[0].toLowerCase();
			}
		} catch(error) {
			console.warn("UserHighlighter error fetching specialpagealiases", error);
		}
		// namespaces
		const namespaceIds = mw.config.get('wgNamespaceIds');
		const userStrings = Object.keys(namespaceIds)
			.filter((key) => [-1, 2, 3].includes(namespaceIds[key]))
			.reduce((acc, key) => {
				const value = namespaceIds[key];
				const formattedKey = key.replaceAll('_', ' ').toLowerCase() + ':';
				acc[value === -1 ? `${formattedKey}${contributionsPage}/` : formattedKey] = value;
				return acc;
			}, {});
		// pages
		const pages = {};
		pages.articlePath = mw.config.get('wgArticlePath').replace(/\$1/, '');
		pages.scriptPath = mw.config.get('wgScript') + '?title=';
		pages.userStrings = userStrings;
		this.siteCache.store('#pathnames', pages);
		this.siteCache.save();
		return pages;
	}

	async getApiHighLimits() {
		let cached = this.siteCache.fetch('#apihighlimits');
		if (cached && cached.value) {
			return cached.value;
		}
		const rights = await mw.user.getRights().catch(() => []);
		const apiHighLimits = rights.includes('apihighlimits');
		this.siteCache.store('#apihighlimits', apiHighLimits);
		this.siteCache.save();
		return apiHighLimits;
	}

	async getGroups() {
		const groupNames = {};
		try {
			const response = await new mw.Api().get({
				action: 'query',
				format: 'json',
				formatversion: '2',
				meta: 'siteinfo',
				sinumberingroup: true,
				siprop: 'usergroups'
			});
			const groups = response.query.usergroups
				.filter(group => group.number && group.name && /^[\w-]+$/.test(group.name) && group.name !== 'user');
			for (const group of groups) {
				groupNames[group.name] = group.number;
			}
		} catch(error) {
			console.warn("UserHighlighter error fetching usergroups", error);
		}
		return groupNames;
	}
}

mw.loader.using(['mediawiki.api', 'mediawiki.util', 'user.options'], function() {
	if (mw.config.get('wgNamespaceNumber') === 0 && mw.config.get('wgAction') === 'view' && !window.location.search && mw.config.get('wgArticleId')) {
		return;
	}
	const uh = new UserHighlighter();
	mw.hook('wikipage.content').add(uh.execute.bind(uh));
});