User:Daniel Quinlan/Scripts/UserHighlighterAlpha.js

"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($(' If you are using UserHighlighter, disable Strike out usernames that have been blocked in preferences. '), { 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)); });