Jump to content

User:Daniel Quinlan/Scripts/UserHighlighter.js

From Wikipedia, the free encyclopedia
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
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 ('blockid' in user) {
				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));
});