User:Daniel Quinlan/Scripts/UserHighlighterAlpha.js

Source: 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('#') ? 168 : 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]);
	}

	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(groupBit, callback) {
		this.groupBit = groupBit;
		this.callback = callback;
		this.apiHighlimits = this.getApiHighlimits();
		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}$/;

	getApiHighlimits() {
		const highUserGroups = new Set(['sysop', 'researcher']);
		const highGlobalGroups = new Set(['apihighlimits-requestor', 'global-sysop', 'staff', 'steward', 'sysadmin', 'wmf-researcher']);

		return mw.config.get('wgUserGroups').some(group => highUserGroups.has(group)) || mw.config.get('wgGlobalGroups').some(group => highGlobalGroups.has(group));
	}

	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(data, callback, property) {
		const url = mw.util.wikiScript('api') + '?format=json&action=query';
		try {
			const response = await $.post(url, data, 'json');
			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)) {
			await new Promise((resolve, reject) => {
				queryData.ususers = chunk.join('|');
				this.postRequest(queryData, processUser, 'users')
					.then(data => {
						Object.assign(responses, data);
						resolve();
					})
					.catch(error => {
						reject(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)) {
			await new Promise((resolve, reject) => {
				queryData.bkusers = chunk.join('|');
				this.postRequest(queryData, processBlock, 'blocks')
					.then(data => {
						Object.assign(responses, data);
						resolve();
					})
					.catch(error => {
						queryError = true;
						reject(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;
			await new Promise((resolve, reject) => {
				queryData.bkip = range.includes(':') ? range + '::/64' : range;
				this.postRequest(queryData, processBlock, 'blocks')
					.then(data => {
						Object.assign(responses, data);
						resolve();
					})
					.catch(error => {
						queryError = true;
						reject(new Error(`Failed to fetch bkip: ${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();
	}
}

class UserHighlighter {
	constructor() {
		this.isExecuting = false;
		this.initialRun = true;
		this.taskQueue = new Map();
		this.siteCache = new LocalStorageCache('uh-site-cache');
		this.options = null;
		this.bitGroup = null;
		this.groupBit = null;
		this.pathnames = null;
		this.startPromise = this.start();
	}

	// 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 } }, stylesheet: true };

	async start() {
		this.options = await this.getOptions();
		this.injectStyle();
		this.pathnames = await this.getPathnames();
		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(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;
		};

		const finish = () => {
			if (this.initialRun) {
				this.checkPreferences();
				this.highlightingMode();
			}
			this.initialRun = false;
			this.isExecuting = false;
		};

		try {
			// set content
			let $target;
			if (this.initialRun) {
				$target = $('#bodyContent');
				if (!$target.length) {
					$target = $('#mw-content-text');
				}
				await this.startPromise;
			} else {
				$target = $content;
			}

			if ($target && $target.length) {
				enqueue($target);
			}

			// avoid concurrent execution
			if (this.isExecuting) {
				return;
			}

			// start execution
			this.isExecuting = true;
			let $next;
			while ($next = dequeue()) {
				this.processContent($next);
			}
			await this.userStatus.checkpoint(this.initialRun);

			// finish
			finish();
		} catch (error) {
			console.error("UserHighlighter error in execute:", error);
			finish();
		}
	}

	processContent($content) {
		const hrefCache = {};

		const elements = $content[0].querySelectorAll('a[href]');
		for (let i = 0; i < elements.length; i++) {
			const href = elements[i].getAttribute('href');
			const user = hrefCache[href] ?? (hrefCache[href] = this.getUser(href));
			if (user) {
				this.userStatus.query(user, elements[i]);
			}
		}
	}

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

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

		// 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)) {
				classNames.push(`uh-${this.bitGroup[bitPosition]}`);
			}
			userGroupBits &= ~bitPosition;
		}

		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.pathnames.serverPrefix)) {
				url = url.substring(this.pathnames.serverPrefix.length);
			}
			else {
				return false;
			}
		}

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

		// Extract title.
		let title;
		if (url.startsWith(this.pathnames.articlePath)) {
			title = url.substring(this.pathnames.articlePath.length);
		} else if (url.startsWith(mw.config.get('wgScript'))) {
			// Extract the value of "title" parameter and decode it.
			const paramsIndex = url.indexOf('?');
			if (paramsIndex !== -1) {
				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 {
			title = decodeURIComponent(title);
		} catch (error) {
			console.warn(`UserHighlighter error decoding "${title}":`, error);
			return false;
		}

		// Extract user from the title based on namespace.
		let user;
		const lowercaseTitle = title.toLowerCase();
		for (const namespaceString of this.pathnames.namespaceStrings) {
			if (lowercaseTitle.startsWith(namespaceString)) {
				user = title.substring(namespaceString.length);
				break;
			}
		}
		if (!user || user.includes('/')) {
			return false;
		}
		if (user.toLowerCase().endsWith('#top')) {
			user = user.slice(0, -4);
		}
		return user && !user.includes('#') ? user : 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 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);
		});
	}

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

	highlightingMode() {
		if (mw.config.get('wgTitle') !== mw.config.get('wgUserName') + '/common.css') {
			return;
		}
		mw.util.addPortletLink('p-tb', '#', "User highlighting mode", 'ca-userhighlighter-mode');
		$("#ca-userhighlighter-mode").click((event) => {
			event.preventDefault();
			this.options.stylesheet = !this.options.stylesheet;
			this.saveOptions(this.options);
			const mode = this.options.stylesheet ? 'default' : 'custom';
			mw.notify(`Now using ${mode} stylesheet!`, { title: "User highlighter" });
		});
	}

	async injectStyle() {
		if (!this.options.stylesheet) {
			return;
		}
		let cached = this.siteCache.fetch('#stylesheet');
		let css = cached !== undefined ? cached.value : undefined;
		if (!css) {
			try {
				const api = new mw.ForeignApi(UserHighlighter.ACTION_API);
				const response = await 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');
		if (cached && cached.value) {
			return cached.value;
		}
		// user pages
		const namespaceIds = mw.config.get('wgNamespaceIds');
		let userPages = Object.keys(namespaceIds)
			.filter(key => namespaceIds[key] === 2 || namespaceIds[key] === 3)
			.map(key => key.replaceAll('_', ' ').toLowerCase() + ':');
		if (userPages.length >= 4) {
			userPages = userPages
				.filter(item => item !== 'user:' && item !== 'user talk:');
		}
		// contributions
		let specialPages = Object.keys(namespaceIds)
			.filter(key => namespaceIds[key] === -1)
			.map(key => key.replaceAll('_', ' '));
		let contributionsPage = 'Contributions';
		try {
			const api = new mw.Api();
			const response = await 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];
			}
		} catch(error) {
			console.warn("UserHighlighter error fetching specialpagealiases", error);
		}
		if (specialPages.length > 1) {
			specialPages = specialPages.filter(item => item !== 'special');
		}
		const specialContributionsPages = specialPages
			.map(item => `${item}:${contributionsPage}/`.toLowerCase());
		// pages
		const pages = {};
		pages.serverPrefix = 'https:' + mw.config.get('wgServer');
		pages.articlePath = mw.config.get('wgArticlePath').replace(/\$1/, '');
		pages.scriptPath = mw.config.get('wgScript') + '?title=';
		pages.namespaceStrings = [...userPages, ...specialContributionsPages];
		this.siteCache.store('#pathnames', pages);
		this.siteCache.save();
		return pages;
	}

	async getGroups() {
		const groupNames = {};
		try {
			const api = new mw.Api();
			const response = await 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) {
		return;
	}
	const uh = new UserHighlighter();
	mw.hook('wikipage.content').add(uh.execute.bind(uh));
});