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('#') ? 24 : 1);
// expire
for (const [key, value] of Object.entries(this.data)) {
const ttl = this.ttl * modifier(key, value[1]);
if (value[0] + this.start <= currentTime - ttl) {
delete this.data[key];
}
}
} catch (error) {
console.error(`LocalStorageCache error reading "${this.name}":`, error);
localStorage.removeItem(this.name);
this.invalid = true;
}
}
fetch(key) {
if (this.invalid) {
return undefined;
}
if (key in this.data) {
this.hitCount++;
return { time: this.data[key][0] + this.start, value: this.data[key][1] };
} else {
this.missCount++;
return undefined;
}
}
store(key, value, expiry = undefined) {
if (expiry) {
expiry = expiry instanceof Date ? expiry.getTime() : Date.parse(expiry);
if (expiry < Date.now() + (this.ttl * 60000)) {
return;
}
}
this.data[key] = [Math.floor(Date.now() / this.divisor) - this.start, value];
}
invalidate(predicate) {
Object.keys(this.data).forEach(key => predicate(key) && delete this.data[key]);
}
clear() {
const specialKeys = ['#hc', '#mc', '#start'];
this.data = Object.fromEntries(
Object.entries(this.data).filter(([key]) => specialKeys.includes(key))
);
}
save() {
try {
// pruning
if (Object.keys(this.data).length > this.capacity) {
const sortedKeys = Object.keys(this.data).sort((a, b) => this.data[a][0] - this.data[b][0]);
let excess = sortedKeys.length - this.capacity;
for (const key of sortedKeys) {
if (excess <= 0) {
break;
}
delete this.data[key];
excess--;
}
}
// empty
if (!Object.keys(this.data).length) {
localStorage.setItem(this.name, JSON.stringify(this.data));
return;
}
// rebase timestamps
const first = Math.min(...Object.values(this.data).map(entry => entry[0]));
if (isNaN(first) && !isFinite(first)) {
throw new Error(`Invalid first timestamp: ${first}`);
}
for (const key in this.data) {
this.data[key][0] -= first;
}
this.start = this.start + first;
this.data['#start'] = this.start;
this.data['#hc'] = this.hitCount;
this.data['#mc'] = this.missCount;
localStorage.setItem(this.name, JSON.stringify(this.data));
delete this.data['#start'];
delete this.data['#hc'];
delete this.data['#mc'];
} catch (error) {
console.error(`LocalStorageCache error saving "${this.name}":`, error);
localStorage.removeItem(this.name);
this.invalid = true;
}
}
}
class UserStatus {
constructor(apiHighlimits, groupBit, callback) {
this.api = new mw.Api();
this.apiHighlimits = apiHighlimits;
this.groupBit = groupBit;
this.callback = callback;
this.relevantUsers = this.getRelevantUsers();
this.eventCache = new LocalStorageCache('uh-event-cache');
this.usersCache = new LocalStorageCache('uh-users-cache', this.userModifier);
this.bkusersCache = new LocalStorageCache('uh-bkusers-cache');
this.bkipCache = new LocalStorageCache('uh-bkip-cache');
this.users = new Map();
this.ips = new Map();
}
static IPV4REGEX = /^(?:1?\d\d?|2[0-2]\d)\b(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3}$/;
static IPV6REGEX = /^[\dA-Fa-f]{1,4}(?:\:[\dA-Fa-f]{1,4}){7}$/;
getRelevantUsers() {
const { IPV4REGEX, IPV6REGEX } = UserStatus;
let rusers = [];
if (![-1, 2, 3].includes(mw.config.get('wgNamespaceNumber'))) {
return new Set(rusers);
}
let ruser = mw.config.get('wgRelevantUserName');
let mask;
if (!ruser) {
const page = mw.config.get('wgPageName');
const match = page.match(/^Special:\w+\/([^\/]+)(?:\/(\d{2,3}$))?/i);
if (match) {
ruser = match[1];
mask = match[2];
}
}
if (ruser) {
if (IPV6REGEX.test(ruser)) {
ruser = ruser.toUpperCase();
rusers.push(this.ipRangeKey(ruser));
}
rusers.push(ruser);
if (mask && Number(mask) !== 64 && (IPV4REGEX.test(ruser) || IPV6REGEX.test(ruser))) {
rusers.push(`${ruser}/${mask}`);
}
rusers = rusers.filter(key => key && key !== mw.config.get('wgUserName'));
}
return new Set(rusers);
}
userModifier = (key, value) => {
if (value & this.groupBit.sysop)
return 24;
else if (value & this.groupBit.extendedconfirmed)
return 3;
return 1;
};
userFetch(cache, key) {
const cachedState = cache.fetch(key);
if (!cachedState || this.relevantUsers.has(key)) {
return false;
}
const cachedEvent = this.eventCache.fetch(key);
if (cachedEvent && cachedState.time <= cachedEvent.time) {
return false;
}
return cachedState;
}
ipRangeKey(ip) {
return ip.includes('.') ? ip : ip.split('/')[0].split(':').slice(0, 4).join(':');
}
query(user, context) {
const { IPV4REGEX, IPV6REGEX } = UserStatus;
const processIP = (ip, context) => {
const bkusersCached = this.userFetch(this.bkusersCache, ip);
const bkipCached = this.userFetch(this.bkipCache, this.ipRangeKey(ip));
if (bkusersCached && bkipCached) {
this.callback(context, bkusersCached.value | bkipCached.value);
return;
}
this.ips.has(ip) ? this.ips.get(ip).push(context) : this.ips.set(ip, [context]);
};
const processUser = (user, context) => {
const cached = this.userFetch(this.usersCache, user);
if (cached) {
this.callback(context, cached.value);
return;
}
this.users.has(user) ? this.users.get(user).push(context) : this.users.set(user, [context]);
};
if (IPV4REGEX.test(user)) {
processIP(user, context);
} else if (IPV6REGEX.test(user)) {
processIP(user.toUpperCase(), context);
} else {
if (user.charAt(0) === user.charAt(0).toLowerCase()) {
user = user.charAt(0).toUpperCase() + user.slice(1);
}
processUser(user, context);
}
}
async checkpoint(initialRun) {
if (!this.users.size && !this.ips.size) {
return;
}
// queries
const usersPromise = this.usersQueries(this.users);
const bkusersPromise = this.bkusersQueries(this.ips);
usersPromise.then(usersResponses => {
this.applyResponses(this.users, usersResponses);
});
bkusersPromise.then(bkusersResponses => {
this.applyResponses(this.ips, bkusersResponses);
});
await bkusersPromise;
const bkipPromise = this.bkipQueries(this.ips);
await Promise.all([usersPromise, bkipPromise]);
// save caches
if (initialRun) {
this.usersCache.save();
this.bkusersCache.save();
this.bkipCache.save();
}
// clear maps
this.users.clear();
this.ips.clear();
}
*chunks(full, n) {
for (let i = 0; i < full.length; i += n) {
yield full.slice(i, i + n);
}
}
async postRequest(api, data, callback, property) {
try {
const response = await api.post({ action: 'query', format: 'json', ...data });
if (response.query && response.query[property]) {
const cumulativeResult = {};
response.query[property].forEach(item => {
const result = callback(item);
if (result) {
cumulativeResult[result.key] = result.value;
}
});
return cumulativeResult;
} else {
throw new Error("JSON location not found or empty");
}
} catch (error) {
throw new Error(`Failed to fetch data: ${error.message}`);
}
}
async usersQueries(users) {
const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;
const processUser = (user) => {
let state = 0;
if (user.blockid) {
state = 'blockpartial' in user ? PARTIAL :
(user.blockexpiry === 'infinite' ? INDEFINITE : TEMPORARY);
}
if (user.groups) {
state = user.groups.reduce((accumulator, name) => {
return accumulator | (this.groupBit[name] || 0);
}, state);
}
return { key: user.name, value: state };
};
const responses = {};
const chunkSize = this.apiHighlimits ? 500 : 50;
const queryData = {
list: 'users',
usprop: 'blockinfo|groups'
};
for (const chunk of this.chunks(Array.from(users.keys()), chunkSize)) {
try {
queryData.ususers = chunk.join('|');
const data = await this.postRequest(this.api, queryData, processUser, 'users');
Object.assign(responses, data);
} catch (error) {
throw new Error(`Failed to fetch users: ${error.message}`);
}
}
for (const [user, state] of Object.entries(responses)) {
this.usersCache.store(user, state);
}
return responses;
}
async bkusersQueries(ips) {
const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;
const processBlock = (block) => {
const partial = block.restrictions && !Array.isArray(block.restrictions);
const state = partial ? PARTIAL : (
/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
const user = block.user.endsWith('/64') ? this.ipRangeKey(block.user) : block.user;
return { key: user, value: state };
};
const ipQueries = new Set();
for (const ip of ips.keys()) {
const cached = this.userFetch(this.bkusersCache, ip);
if (!cached) {
ipQueries.add(ip);
if (ip.includes(':')) {
ipQueries.add(this.ipRangeKey(ip) + '::/64');
}
}
}
const responses = {};
const chunkSize = this.apiHighlimits ? 500 : 50;
const queryData = {
list: 'blocks',
bklimit: 500,
bkprop: 'user|by|timestamp|expiry|reason|restrictions'
};
let queryError = false;
for (const chunk of this.chunks(Array.from(ipQueries.keys()), chunkSize)) {
try {
queryData.bkusers = chunk.join('|');
const data = await this.postRequest(this.api, queryData, processBlock, 'blocks');
Object.assign(responses, data);
} catch (error) {
queryError = true;
throw new Error(`Failed to fetch bkusers: ${error.message}`);
}
}
// check possible responses
const results = {};
for (const ip of ips.keys()) {
if (!ipQueries.has(ip)) {
continue;
}
let state = responses[ip] || 0;
if (ip.includes(':')) {
const range = this.ipRangeKey(ip);
const rangeState = responses[range] || 0;
state = Math.max(state, rangeState);
}
// store single result, only blocks are returned so skip if any errors
if (!queryError) {
this.bkusersCache.store(ip, state);
}
results[ip] = state;
}
return results;
}
async bkipQueries(ips) {
const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;
function processBlock(block) {
const partial = block.restrictions && !Array.isArray(block.restrictions);
const state = partial ? PARTIAL : (
/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
return { key: block.id, value: state };
}
const addRangeBlock = (ips, ip, state) => {
if (ips.get(ip) && state) {
ips.get(ip).forEach(context => this.callback(context, state));
}
};
// check cache and build queries
const ipRanges = {};
for (const ip of ips.keys()) {
const range = this.ipRangeKey(ip);
const cached = this.userFetch(this.bkipCache, range);
if (cached) {
addRangeBlock(ips, ip, cached.value);
} else {
if (!ipRanges.hasOwnProperty(range))
ipRanges[range] = [];
ipRanges[range].push(ip);
}
}
const queryData = {
list: 'blocks',
bklimit: 100,
bkprop: 'user|id|by|timestamp|expiry|range|reason|restrictions'
};
for (const [range, ipGroup] of Object.entries(ipRanges)) {
const responses = {};
let queryError = false;
try {
queryData.bkip = range.includes(':') ? range + '::/64' : range;
const data = await this.postRequest(this.api, queryData, processBlock, 'blocks');
Object.assign(responses, data);
} catch (error) {
queryError = true;
console.error(`Failed to fetch bkip for range ${range}: ${error.message}`);
}
let state = 0;
if (Object.keys(responses).length) {
state = Math.max(...Object.values(responses));
}
ipGroup.forEach(ip => {
addRangeBlock(ips, ip, state);
});
if (!queryError) {
this.bkipCache.store(range, state);
}
}
}
applyResponses(queries, responses) {
for (const [name, state] of Object.entries(responses)) {
queries.get(name)?.forEach(context => this.callback(context, state));
}
}
event() {
const eventCache = new LocalStorageCache('uh-event-cache');
this.relevantUsers.forEach(key => {
let mask = key.match(/\/(\d+)$/);
if (mask) {
const groups = mask[1] < 32 ? 1 : (mask[1] < 48 ? 2 : 3);
const pattern = `^(?:\\d+\\.\\d+\\.|(?:\\w+:){${groups}})`;
const match = key.match(pattern);
if (match) {
const bkipCache = new LocalStorageCache('uh-bkip-cache');
bkipCache.invalidate(str => str.startsWith(match[0]));
bkipCache.save();
}
} else {
eventCache.store(key, true);
}
});
eventCache.save();
}
async clearUsers() {
this.usersCache.clear();
this.usersCache.save();
}
}
class UserHighlighter {
constructor() {
this.initialRun = true;
this.taskQueue = new Map();
this.hrefCache = new Map();
this.siteCache = new LocalStorageCache('uh-site-cache');
this.options = null;
this.bitGroup = null;
this.groupBit = null;
this.pathnames = null;
this.serverPrefix = window.location.origin;
this.startPromise = this.start();
this.processPromise = Promise.resolve();
this.debug = localStorage.getItem('uh-debug');
}
// compact user state
static PARTIAL = 0b0001;
static TEMPORARY = 0b0010;
static INDEFINITE = 0b0011;
static BLOCK_MASK = 0b0011;
static GROUP_START = 0b0100;
// settings
static ACTION_API = 'https://en.wikipedia.org/w/api.php';
static STYLESHEET = 'User:Daniel Quinlan/Scripts/UserHighlighter.css';
static DEFAULTS = {
groups: {
extendedconfirmed: { bit: 0b0100 },
sysop: { bit: 0b1000 },
bot: { bit: 0b10000 }
},
labels: {},
stylesheet: true
};
async start() {
let apiHighLimits;
[apiHighLimits, this.options, this.pathnames] = await Promise.all([
this.getApiHighLimits(),
this.getOptions(),
this.getPathnames()
]);
this.injectStyle();
this.bitGroup = {};
this.groupBit = {};
for (const [groupName, groupData] of Object.entries(this.options.groups)) {
this.bitGroup[groupData.bit] = groupName;
this.groupBit[groupName] = groupData.bit;
}
this.userStatus = new UserStatus(apiHighLimits, this.groupBit, this.applyClasses);
this.bindEvents();
}
async execute($content) {
const enqueue = (task) => {
this.taskQueue.set(task, true);
};
const dequeue = () => {
const task = this.taskQueue.keys().next().value;
if (task) {
this.taskQueue.delete(task);
return task;
}
return null;
};
try {
// set content
if (this.initialRun) {
const target = document.getElementById('bodyContent') ||
document.getElementById('mw-content-text') ||
document.body;
if (target) {
enqueue(target);
}
await this.startPromise;
} else if ($content && $content.length) {
for (const node of $content) {
if (node.nodeType === Node.ELEMENT_NODE) {
enqueue(node);
}
}
}
// debugging
if (this.debug) {
console.debug("UserHighlighter execute: content", $content, "taskQueue size", this.taskQueue.size, "initialRun", this.initialRun, "timestamp", performance.now());
}
// process content, avoiding concurrent processing
const currentPromise = this.processPromise;
this.processPromise = currentPromise
.then(() => this.processContent(dequeue))
.catch((error) => {
console.error("UserHighlighter error in processContent:", error);
});
} catch (error) {
console.error("UserHighlighter error in execute:", error);
}
}
async processContent(dequeue) {
let task;
while (task = dequeue()) {
const elements = task.querySelectorAll('a[href]:not(.userlink)');
for (let i = 0; i < elements.length; i++) {
const href = elements[i].getAttribute('href');
let userResult = this.hrefCache.get(href);
if (userResult === undefined) {
userResult = this.getUser(href);
this.hrefCache.set(href, userResult);
}
if (userResult) {
this.userStatus.query(userResult[0], elements[i]);
}
}
}
await this.userStatus.checkpoint(this.initialRun);
if (this.initialRun) {
this.addOptionsLink();
this.checkPreferences();
}
this.initialRun = false;
}
applyClasses = (element, state) => {
const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;
let classNames = ['userlink'];
let labelNames = new Set();
// extract group bits using a technique based on Kernighan's algorithm
let userGroupBits = state & ~BLOCK_MASK;
while (userGroupBits) {
const bitPosition = userGroupBits & -userGroupBits;
if (this.bitGroup.hasOwnProperty(bitPosition)) {
const groupName = this.bitGroup[bitPosition];
classNames.push(`uh-${groupName}`);
if (this.options.labels[groupName]) {
labelNames.add(this.options.labels[groupName]);
}
}
userGroupBits &= ~bitPosition;
}
// optionally add labels
if (labelNames.size) {
const href = element.getAttribute('href');
if (href) {
let userResult = this.hrefCache.get(href);
if (userResult === undefined) {
userResult = this.getUser(href);
this.hrefCache.set(href, userResult);
}
if (userResult && userResult[1] === 2) {
if (element.hasAttribute("data-labels")) {
element.getAttribute("data-labels").slice(1, -1).split(', ').filter(Boolean)
.forEach(label => labelNames.add(label));
}
element.setAttribute('data-labels', `[${[...labelNames].join(', ')}]`);
}
}
}
// blocks
switch (state & BLOCK_MASK) {
case INDEFINITE: classNames.push('user-blocked-indef'); break;
case TEMPORARY: classNames.push('user-blocked-temp'); break;
case PARTIAL: classNames.push('user-blocked-partial'); break;
}
// add classes
classNames = classNames.filter(name => !element.classList.contains(name));
element.classList.add(...classNames);
};
// return user for '/wiki/User:', '/wiki/User_talk:', '/wiki/Special:Contributions/',
// and '/w/index.php?title=User:' links
getUser(url) {
// skip links that won't be user pages
if (!url || !(url.startsWith('/') || url.startsWith('https://')) || url.startsWith('//')) {
return false;
}
// skip links that aren't to user pages
if (!url.includes(this.pathnames.articlePath) && !url.includes(this.pathnames.scriptPath)) {
return false;
}
// strip server prefix
if (!url.startsWith('/')) {
if (url.startsWith(this.serverPrefix)) {
url = url.substring(this.serverPrefix.length);
}
else {
return false;
}
}
// skip links without ':'
if (!url.includes(':')) {
return false;
}
// extract title
let title;
const paramsIndex = url.indexOf('?');
if (url.startsWith(this.pathnames.articlePath)) {
title = url.substring(this.pathnames.articlePath.length, paramsIndex === -1 ? url.length : paramsIndex);
} else if (paramsIndex !== -1 && url.startsWith(mw.config.get('wgScript'))) {
// extract the value of "title" parameter and decode it
const queryString = url.substring(paramsIndex + 1);
const queryParams = new URLSearchParams(queryString);
title = queryParams.get('title');
// skip links with disallowed parameters
if (title) {
const allowedParams = ['action', 'redlink', 'safemode', 'title'];
const hasDisallowedParams = Array.from(queryParams.keys()).some(name => !allowedParams.includes(name));
if (hasDisallowedParams) {
return false;
}
}
}
if (!title) {
return false;
}
title = title.replaceAll('_', ' ');
try {
if (/\%[\dA-Fa-f][\dA-Fa-f]/.test(title)) {
title = decodeURIComponent(title);
}
} catch (error) {
console.warn(`UserHighlighter error decoding "${title}":`, error);
return false;
}
// determine user and namespace from the title
let user;
let namespace;
const lowercaseTitle = title.toLowerCase();
for (const [userString, namespaceNumber] of Object.entries(this.pathnames.userStrings)) {
if (lowercaseTitle.startsWith(userString)) {
user = title.substring(userString.length);
namespace = namespaceNumber;
break;
}
}
if (!user || user.includes('/')) {
return false;
}
if (user.toLowerCase().endsWith('#top')) {
user = user.slice(0, -4);
}
return user && !user.includes('#') ? [user, namespace] : false;
}
bindEvents() {
const buttonClick = (event) => {
try {
const button = $(event.target).text();
if (/block|submit/i.test(button)) {
this.userStatus.event();
}
} catch (error) {
console.error("UserHighlighter error in buttonClick:", error);
}
};
const dialogOpen = (event, ui) => {
try {
const dialog = $(event.target).closest('.ui-dialog');
const title = dialog.find('.ui-dialog-title').text();
if (title.toLowerCase().includes('block')) {
dialog.find('button').on('click', buttonClick);
}
} catch (error) {
console.error("UserHighlighter error in dialogOpen:", error);
}
};
if (!this.userStatus.relevantUsers.size) {
return;
}
if (['Block', 'Unblock'].includes(mw.config.get('wgCanonicalSpecialPageName'))) {
$(document.body).on('click', 'button', buttonClick);
}
$(document.body).on('dialogopen', dialogOpen);
}
async getOptions() {
const optionString = mw.user.options.get('userjs-userhighlighter');
let options;
try {
if (optionString !== null) {
const options = JSON.parse(optionString);
if (typeof options === 'object')
return options;
}
} catch (error) {
console.error("UserHighlighter error reading options:", error);
}
await this.saveOptions(UserHighlighter.DEFAULTS);
return JSON.parse(JSON.stringify(UserHighlighter.DEFAULTS));
}
async saveOptions(options) {
const value = JSON.stringify(options);
await new mw.Api().saveOption('userjs-userhighlighter', value).then(function() {
mw.user.options.set('userjs-userhighlighter', value);
}).fail(function(xhr, status, error) {
console.error("UserHighlighter error saving options:", error);
});
}
addOptionsLink() {
if (mw.config.get('wgTitle') !== mw.config.get('wgUserName') + '/common.css') {
return;
}
mw.util.addPortletLink('p-tb', '#', "User highlighter options", 'ca-userhighlighter-options');
$("#ca-userhighlighter-options").click((event) => {
event.preventDefault();
mw.loader.using(['oojs-ui']).done(() => {
this.showOptions();
});
});
}
async showOptions() {
// create fieldsets
const appearanceFieldset = new OO.ui.FieldsetLayout({ label: 'Appearance' });
const stylesheetToggle = new OO.ui.CheckboxInputWidget({
selected: !!this.options.stylesheet
});
appearanceFieldset.addItems([
new OO.ui.FieldLayout(stylesheetToggle, {
label: 'Enable default stylesheet',
align: 'inline'
})
]);
const groupsFieldset = new OO.ui.FieldsetLayout({ label: 'User groups' });
const groups = await this.getGroups();
Object.entries(groups).forEach(([groupName, groupNumber]) => {
const groupCheckbox = new OO.ui.CheckboxInputWidget({
selected: !!this.options.groups[groupName]?.bit
});
const groupFieldLayout = new OO.ui.FieldLayout(groupCheckbox, {
label: `${groupName} (${groupNumber})`,
align: 'inline'
});
groupsFieldset.addItems(groupFieldLayout);
});
const labelsFieldset = new OO.ui.FieldsetLayout({ label: 'Group labels' });
const mappings = Object.entries(this.options.labels)
.map(([group, label]) => `${group}=${label}`)
.join(', ');
const mappingsTextarea = new OO.ui.MultilineTextInputWidget({
value: mappings,
autosize: true,
placeholder: 'format: group=label (separate with whitespace or commas)'
});
labelsFieldset.addItems([mappingsTextarea]);
const defaultsFieldset = new OO.ui.FieldsetLayout({ label: 'Load default options' });
const defaultsButton = new OO.ui.ButtonWidget({
label: 'Load defaults',
flags: ['safe'],
title: 'Load defaults (does not save automatically)'
});
defaultsFieldset.addItems([defaultsButton]);
// define options dialog
class OptionsDialog extends OO.ui.ProcessDialog {
static static = {
name: 'user-highlighter-options',
title: 'User highlighter options',
escapable: true,
actions: [
{ action: 'save', label: 'Save', flags: ['primary', 'progressive'], title: 'Save options' },
{ action: 'cancel', label: 'Cancel', flags: ['safe', 'close'] }
]
};
initialize() {
super.initialize();
this.content = new OO.ui.PanelLayout({ padded: true, expanded: false });
this.content.$element.append(appearanceFieldset.$element, groupsFieldset.$element, labelsFieldset.$element, defaultsFieldset.$element);
this.$body.append(this.content.$element);
defaultsButton.connect(this, { click: 'loadDefaults' });
}
getActionProcess(action) {
if (action === 'save') {
return new OO.ui.Process(async () => {
await this.parent.setGroups(groups, groupsFieldset);
this.parent.options.stylesheet = stylesheetToggle.isSelected();
this.parent.parseGroupMappings(mappingsTextarea.getValue());
await this.parent.saveOptions(this.parent.options);
await this.parent.userStatus.clearUsers();
this.close({ action: 'save' });
});
} else if (action === 'cancel') {
return new OO.ui.Process(() => {
this.close({ action: 'cancel' });
});
}
return super.getActionProcess(action);
}
loadDefaults() {
this.parent.options = JSON.parse(JSON.stringify(UserHighlighter.DEFAULTS));
appearanceFieldset.items[0].fieldWidget.setSelected(!!this.parent.options.stylesheet);
groupsFieldset.items.forEach(item => {
const groupName = item.label.split(' ')[0];
item.fieldWidget.setSelected(!!this.parent.options.groups[groupName]?.bit);
});
const newMappings = Object.entries(this.parent.options.labels)
.map(([group, label]) => `${group}=${label}`)
.join(', ');
mappingsTextarea.setValue(newMappings);
}
}
// create and open the dialog
const windowManager = new OO.ui.WindowManager();
$('body').append(windowManager.$element);
const dialog = new OptionsDialog();
dialog.parent = this; // set parent reference for methods
windowManager.addWindows([dialog]);
windowManager.openWindow(dialog);
}
async setGroups(groups, groupsFieldset) {
// reinitialize groups
this.options.groups = {};
this.groupBit = {};
this.bitGroup = {};
// filter selected checkboxes, extract labels, and sort by number in descending order
const orderedGroups = groupsFieldset.items
.filter(item => item.fieldWidget.isSelected())
.map(item => item.label.split(' ')[0])
.sort((a, b) => (groups[b] ?? 0) - (groups[a] ?? 0));
// assign bits to the selected groups
let nextBit = UserHighlighter.GROUP_START;
orderedGroups.forEach(groupName => {
this.options.groups[groupName] = { bit: nextBit };
this.groupBit[groupName] = nextBit;
this.bitGroup[nextBit] = groupName;
nextBit <<= 1;
});
}
parseGroupMappings(text) {
this.options.labels = {};
Object.keys(this.options.groups).forEach(groupName => {
const pattern = new RegExp(`\\b${groupName}\\b[^\\w\\-]+([\\w\\-]+)`);
const match = text.match(pattern);
if (match) {
this.options.labels[groupName] = match[1];
}
});
}
checkPreferences() {
if (mw.user.options.get('gadget-markblocked')) {
mw.notify($('<span>If you are using UserHighlighter, disable <a href="/wiki/Special:Preferences#mw-prefsection-gadgets" style="text-decoration: underline;">Strike out usernames that have been blocked</a> in preferences.</span>'), { autoHide: false, tag: 'uh-warning', title: "User highlighter", type: 'warn' });
}
}
async injectStyle() {
if (!this.options.stylesheet) {
return;
}
let cached = this.siteCache.fetch('#stylesheet');
let css = cached !== undefined ? cached.value : undefined;
if (!css) {
try {
const response = await new mw.ForeignApi(UserHighlighter.ACTION_API).get({
action: 'query',
formatversion: '2',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
titles: UserHighlighter.STYLESHEET
});
css = response.query.pages[0].revisions[0].slots.main.content;
css = css.replace(/\n\s*|\s+(?=[!\{])|;(?=\})|(?<=[,:])\s+/g, '');
this.siteCache.store('#stylesheet', css);
this.siteCache.save();
} catch (error) {
console.error("UserHighlighter error fetching CSS:", error);
}
}
if (css) {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
}
async getPathnames() {
let cached = this.siteCache.fetch('#pathnames');
// last condition can be removed after one day
if (cached && cached.value && cached.value.userStrings) {
return cached.value;
}
// contributions
let contributionsPage = 'contributions';
try {
const response = await new mw.Api().get({
action: 'query',
format: 'json',
formatversion: '2',
meta: 'siteinfo',
siprop: 'specialpagealiases'
});
const contributionsItem = response.query.specialpagealiases
.find(item => item.realname === 'Contributions');
if (contributionsItem && contributionsItem.aliases) {
contributionsPage = contributionsItem.aliases[0].toLowerCase();
}
} catch(error) {
console.warn("UserHighlighter error fetching specialpagealiases", error);
}
// namespaces
const namespaceIds = mw.config.get('wgNamespaceIds');
const userStrings = Object.keys(namespaceIds)
.filter((key) => [-1, 2, 3].includes(namespaceIds[key]))
.reduce((acc, key) => {
const value = namespaceIds[key];
const formattedKey = key.replaceAll('_', ' ').toLowerCase() + ':';
acc[value === -1 ? `${formattedKey}${contributionsPage}/` : formattedKey] = value;
return acc;
}, {});
// pages
const pages = {};
pages.articlePath = mw.config.get('wgArticlePath').replace(/\$1/, '');
pages.scriptPath = mw.config.get('wgScript') + '?title=';
pages.userStrings = userStrings;
this.siteCache.store('#pathnames', pages);
this.siteCache.save();
return pages;
}
async getApiHighLimits() {
let cached = this.siteCache.fetch('#apihighlimits');
if (cached && cached.value) {
return cached.value;
}
const rights = await mw.user.getRights().catch(() => []);
const apiHighLimits = rights.includes('apihighlimits');
this.siteCache.store('#apihighlimits', apiHighLimits);
this.siteCache.save();
return apiHighLimits;
}
async getGroups() {
const groupNames = {};
try {
const response = await new mw.Api().get({
action: 'query',
format: 'json',
formatversion: '2',
meta: 'siteinfo',
sinumberingroup: true,
siprop: 'usergroups'
});
const groups = response.query.usergroups
.filter(group => group.number && group.name && /^[\w-]+$/.test(group.name) && group.name !== 'user');
for (const group of groups) {
groupNames[group.name] = group.number;
}
} catch(error) {
console.warn("UserHighlighter error fetching usergroups", error);
}
return groupNames;
}
}
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'user.options'], function() {
if (mw.config.get('wgNamespaceNumber') === 0 && mw.config.get('wgAction') === 'view' && !window.location.search && mw.config.get('wgArticleId')) {
return;
}
const uh = new UserHighlighter();
mw.hook('wikipage.content').add(uh.execute.bind(uh));
});