User:Daniel Quinlan/Scripts/UserHighlighter.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | This user script seems to have a documentation page at User:Daniel Quinlan/Scripts/UserHighlighter and an accompanying .css page at User:Daniel Quinlan/Scripts/UserHighlighter.css. |
"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]);
}
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(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();
}
async clearUsers() {
this.usersCache.clear();
this.usersCache.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.addOptionsLink();
this.checkPreferences();
}
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'];
let labelNames = [];
// 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.push(this.options.labels[groupName]);
}
}
userGroupBits &= ~bitPosition;
}
// optionally append labels after the username
if (labelNames.length && !element.textContent.endsWith(']')) {
const href = element.getAttribute('href');
if (href && (href.startsWith('/wiki/User:') || href.startsWith('/w/index.php?title=User:'))) {
element.textContent += ` [${labelNames.join(', ')}]`;
}
}
// blocks
switch (state & BLOCK_MASK) {
case INDEFINITE: groups.push('user-blocked-indef'); break;
case TEMPORARY: groups.push('user-blocked-temp'); break;
case PARTIAL: groups.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.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);
});
}
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(['jquery.ui']).done(() => {
this.showOptions();
});
});
}
async showOptions() {
const groups = await this.getGroups();
let groupForm = $('<form>');
const isStylesheetEnabled = this.options.stylesheet ? 'checked' : '';
groupForm.append(`<label><input type="checkbox" id="stylesheet-toggle" ${isStylesheetEnabled}> Enable default stylesheet</label><br><br>`);
$.each(groups, (groupName, groupNumber) => {
const isChecked = this.options.groups[groupName]?.bit ? 'checked' : '';
groupForm.append(`<label><input type="checkbox" class="group-checkbox" name="${groupName}" ${isChecked}> ${groupName}</label><br>`);
});
groupForm.append('<br><label for="group-mappings">Group mappings</label><br>');
const mappings = Object.entries(this.options.labels)
.map(([group, label]) => `${group}=${label}`)
.join(', ');
groupForm.append(`<textarea id="group-mappings" rows="5" cols="30" placeholder="format: group=label (separate with whitespace or commas)">${mappings}</textarea><br><br>`);
const $dialog = $('<div>').html(groupForm).dialog({
title: "User highlighter options",
modal: true,
buttons: {
"Save": async () => {
await this.setGroups(groupForm);
const stylesheetChecked = groupForm.find('#stylesheet-toggle').is(':checked');
this.options.stylesheet = stylesheetChecked;
const mappingsInput = groupForm.find('#group-mappings').val();
this.parseGroupMappings(mappingsInput);
await this.saveOptions(this.options);
await this.userStatus.clearUsers();
$dialog.dialog("close");
},
"Cancel": () => {
$dialog.dialog("close");
}
}
});
}
async setGroups(groupForm) {
// reinitialize groups
this.options.groups = {};
this.groupBit = {};
this.bitGroup = {};
// get selected groups and extract their names
let selectedGroups = groupForm.find('input.group-checkbox:checked')
.map((_, checkbox) => checkbox.name)
.get();
// priority groups are assigned the lowest bits
const PRIORITY_GROUPS = ['extendedconfirmed', 'reviewer', 'rollbacker', 'autoreviewer', 'sysop', 'patroller'];
selectedGroups = PRIORITY_GROUPS.filter(g => selectedGroups.includes(g))
.concat(selectedGroups.filter(g => !PRIORITY_GROUPS.includes(g)));
// assign bits to the selected groups
let nextBit = UserHighlighter.GROUP_START;
selectedGroups.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 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));
});