"use strict";
class LocalStorageCache {
constructor(name, modifier = null, ttl = 60, capacity = 1000) {
this.name = name;
this.ttl = ttl;
this.capacity = capacity;
this.divisor = 60000;
this.data = null;
this.start = null;
this.hitCount = 0;
this.missCount = 0;
this.invalid = false;
try {
// load
const dataString = localStorage.getItem(this.name);
this.data = dataString ? JSON.parse(dataString) : {};
// setup
const currentTime = Math.floor(Date.now() / this.divisor);
this.start = this.data['#start'] || currentTime;
if ('#hc' in this.data && '#mc' in this.data) {
this.hitCount = this.data['#hc'];
this.missCount = this.data['#mc'];
}
delete this.data['#start'];
delete this.data['#hc'];
delete this.data['#mc'];
modifier = modifier || ((key, value) => key.startsWith('#') ? 24 : 1);
// expire
for (const [key, value] of Object.entries(this.data)) {
const ttl = this.ttl * modifier(key, value[1]);
if (value[0] + this.start <= currentTime - ttl) {
delete this.data[key];
}
}
} catch (error) {
console.error(`LocalStorageCache error reading "${this.name}":`, error);
localStorage.removeItem(this.name);
this.invalid = true;
}
}
fetch(key) {
if (this.invalid) {
return undefined;
}
if (key in this.data) {
this.hitCount++;
return { time: this.data[key][0] + this.start, value: this.data[key][1] };
} else {
this.missCount++;
return undefined;
}
}
store(key, value, expiry = undefined) {
if (expiry) {
expiry = expiry instanceof Date ? expiry.getTime() : Date.parse(expiry);
if (expiry < Date.now() + (this.ttl * 60000)) {
return;
}
}
this.data[key] = [Math.floor(Date.now() / this.divisor) - this.start, value];
}
invalidate(predicate) {
Object.keys(this.data).forEach(key => predicate(key) && delete this.data[key]);
}
clear() {
const specialKeys = ['#hc', '#mc', '#start'];
this.data = Object.fromEntries(
Object.entries(this.data).filter(([key]) => specialKeys.includes(key))
);
}
save() {
try {
// pruning
if (Object.keys(this.data).length > this.capacity) {
const sortedKeys = Object.keys(this.data).sort((a, b) => this.data[a][0] - this.data[b][0]);
let excess = sortedKeys.length - this.capacity;
for (const key of sortedKeys) {
if (excess <= 0) {
break;
}
delete this.data[key];
excess--;
}
}
// empty
if (!Object.keys(this.data).length) {
localStorage.setItem(this.name, JSON.stringify(this.data));
return;
}
// rebase timestamps
const first = Math.min(...Object.values(this.data).map(entry => entry[0]));
if (isNaN(first) && !isFinite(first)) {
throw new Error(`Invalid first timestamp: ${first}`);
}
for (const key in this.data) {
this.data[key][0] -= first;
}
this.start = this.start + first;
this.data['#start'] = this.start;
this.data['#hc'] = this.hitCount;
this.data['#mc'] = this.missCount;
localStorage.setItem(this.name, JSON.stringify(this.data));
delete this.data['#start'];
delete this.data['#hc'];
delete this.data['#mc'];
} catch (error) {
console.error(`LocalStorageCache error saving "${this.name}":`, error);
localStorage.removeItem(this.name);
this.invalid = true;
}
}
}
class UserStatus {
constructor(apiHighlimits, groupBit, callback) {
this.api = new mw.Api();
this.apiHighlimits = apiHighlimits;
this.groupBit = groupBit;
this.callback = callback;
this.relevantUsers = this.getRelevantUsers();
this.eventCache = new LocalStorageCache('uh-event-cache');
this.usersCache = new LocalStorageCache('uh-users-cache', this.userModifier);
this.bkusersCache = new LocalStorageCache('uh-bkusers-cache');
this.bkipCache = new LocalStorageCache('uh-bkip-cache');
this.users = new Map();
this.ips = new Map();
}
static IPV4REGEX = /^(?:1?\d\d?|2[0-2]\d)\b(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3}$/;
static IPV6REGEX = /^[\dA-Fa-f]{1,4}(?:\:[\dA-Fa-f]{1,4}){7}$/;
getRelevantUsers() {
const { IPV4REGEX, IPV6REGEX } = UserStatus;
let rusers = [];
if (![-1, 2, 3].includes(mw.config.get('wgNamespaceNumber'))) {
return new Set(rusers);
}
let ruser = mw.config.get('wgRelevantUserName');
let mask;
if (!ruser) {
const page = mw.config.get('wgPageName');
const match = page.match(/^Special:\w+\/([^\/]+)(?:\/(\d{2,3}$))?/i);
if (match) {
ruser = match[1];
mask = match[2];
}
}
if (ruser) {
if (IPV6REGEX.test(ruser)) {
ruser = ruser.toUpperCase();
rusers.push(this.ipRangeKey(ruser));
}
rusers.push(ruser);
if (mask && Number(mask) !== 64 && (IPV4REGEX.test(ruser) || IPV6REGEX.test(ruser))) {
rusers.push(`${ruser}/${mask}`);
}
rusers = rusers.filter(key => key && key !== mw.config.get('wgUserName'));
}
return new Set(rusers);
}
userModifier = (key, value) => {
if (value & this.groupBit.sysop)
return 24;
else if (value & this.groupBit.extendedconfirmed)
return 3;
return 1;
};
userFetch(cache, key) {
const cachedState = cache.fetch(key);
if (!cachedState || this.relevantUsers.has(key)) {
return false;
}
const cachedEvent = this.eventCache.fetch(key);
if (cachedEvent && cachedState.time <= cachedEvent.time) {
return false;
}
return cachedState;
}
ipRangeKey(ip) {
return ip.includes('.') ? ip : ip.split('/')[0].split(':').slice(0, 4).join(':');
}
query(user, context) {
const { IPV4REGEX, IPV6REGEX } = UserStatus;
const processIP = (ip, context) => {
const bkusersCached = this.userFetch(this.bkusersCache, ip);
const bkipCached = this.userFetch(this.bkipCache, this.ipRangeKey(ip));
if (bkusersCached && bkipCached) {
this.callback(context, bkusersCached.value | bkipCached.value);
return;
}
this.ips.has(ip) ? this.ips.get(ip).push(context) : this.ips.set(ip, [context]);
};
const processUser = (user, context) => {
const cached = this.userFetch(this.usersCache, user);
if (cached) {
this.callback(context, cached.value);
return;
}
this.users.has(user) ? this.users.get(user).push(context) : this.users.set(user, [context]);
};
if (IPV4REGEX.test(user)) {
processIP(user, context);
} else if (IPV6REGEX.test(user)) {
processIP(user.toUpperCase(), context);
} else {
if (user.charAt(0) === user.charAt(0).toLowerCase()) {
user = user.charAt(0).toUpperCase() + user.slice(1);
}
processUser(user, context);
}
}
async checkpoint(initialRun) {
if (!this.users.size && !this.ips.size) {
return;
}
// queries
const usersPromise = this.usersQueries(this.users);
const bkusersPromise = this.bkusersQueries(this.ips);
usersPromise.then(usersResponses => {
this.applyResponses(this.users, usersResponses);
});
bkusersPromise.then(bkusersResponses => {
this.applyResponses(this.ips, bkusersResponses);
});
await bkusersPromise;
const bkipPromise = this.bkipQueries(this.ips);
await Promise.all([usersPromise, bkipPromise]);
// save caches
if (initialRun) {
this.usersCache.save();
this.bkusersCache.save();
this.bkipCache.save();
}
// clear maps
this.users.clear();
this.ips.clear();
}
*chunks(full, n) {
for (let i = 0; i < full.length; i += n) {
yield full.slice(i, i + n);
}
}
async postRequest(api, data, callback, property) {
try {
const response = await api.post({ action: 'query', format: 'json', ...data });
if (response.query && response.query[property]) {
const cumulativeResult = {};
response.query[property].forEach(item => {
const result = callback(item);
if (result) {
cumulativeResult[result.key] = result.value;
}
});
return cumulativeResult;
} else {
throw new Error("JSON location not found or empty");
}
} catch (error) {
throw new Error(`Failed to fetch data: ${error.message}`);
}
}
async usersQueries(users) {
const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;
const processUser = (user) => {
let state = 0;
if ('blockid' in user) {
state = 'blockpartial' in user ? PARTIAL :
(user.blockexpiry === 'infinite' ? INDEFINITE : TEMPORARY);
}
if (user.groups) {
state = user.groups.reduce((accumulator, name) => {
return accumulator | (this.groupBit[name] || 0);
}, state);
}
return { key: user.name, value: state };
};
const responses = {};
const chunkSize = this.apiHighlimits ? 500 : 50;
const queryData = {
list: 'users',
usprop: 'blockinfo|groups'
};
for (const chunk of this.chunks(Array.from(users.keys()), chunkSize)) {
try {
queryData.ususers = chunk.join('|');
const data = await this.postRequest(this.api, queryData, processUser, 'users');
Object.assign(responses, data);
} catch (error) {
throw new Error(`Failed to fetch users: ${error.message}`);
}
}
for (const [user, state] of Object.entries(responses)) {
this.usersCache.store(user, state);
}
return responses;
}
async bkusersQueries(ips) {
const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;
const processBlock = (block) => {
const partial = block.restrictions && !Array.isArray(block.restrictions);
const state = partial ? PARTIAL : (
/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
const user = block.user.endsWith('/64') ? this.ipRangeKey(block.user) : block.user;
return { key: user, value: state };
};
const ipQueries = new Set();
for (const ip of ips.keys()) {
const cached = this.userFetch(this.bkusersCache, ip);
if (!cached) {
ipQueries.add(ip);
if (ip.includes(':')) {
ipQueries.add(this.ipRangeKey(ip) + '::/64');
}
}
}
const responses = {};
const chunkSize = this.apiHighlimits ? 500 : 50;
const queryData = {
list: 'blocks',
bklimit: 500,
bkprop: 'user|by|timestamp|expiry|reason|restrictions'
};
let queryError = false;
for (const chunk of this.chunks(Array.from(ipQueries.keys()), chunkSize)) {
try {
queryData.bkusers = chunk.join('|');
const data = await this.postRequest(this.api, queryData, processBlock, 'blocks');
Object.assign(responses, data);
} catch (error) {
queryError = true;
throw new Error(`Failed to fetch bkusers: ${error.message}`);
}
}
// check possible responses
const results = {};
for (const ip of ips.keys()) {
if (!ipQueries.has(ip)) {
continue;
}
let state = responses[ip] || 0;
if (ip.includes(':')) {
const range = this.ipRangeKey(ip);
const rangeState = responses[range] || 0;
state = Math.max(state, rangeState);
}
// store single result, only blocks are returned so skip if any errors
if (!queryError) {
this.bkusersCache.store(ip, state);
}
results[ip] = state;
}
return results;
}
async bkipQueries(ips) {
const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;
function processBlock(block) {
const partial = block.restrictions && !Array.isArray(block.restrictions);
const state = partial ? PARTIAL : (
/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
return { key: block.id, value: state };
}
const addRangeBlock = (ips, ip, state) => {
if (ips.get(ip) && state) {
ips.get(ip).forEach(context => this.callback(context, state));
}
};
// check cache and build queries
const ipRanges = {};
for (const ip of ips.keys()) {
const range = this.ipRangeKey(ip);
const cached = this.userFetch(this.bkipCache, range);
if (cached) {
addRangeBlock(ips, ip, cached.value);
} else {
if (!ipRanges.hasOwnProperty(range))
ipRanges[range] = [];
ipRanges[range].push(ip);
}
}
const queryData = {
list: 'blocks',
bklimit: 100,
bkprop: 'user|id|by|timestamp|expiry|range|reason|restrictions'
};
for (const [range, ipGroup] of Object.entries(ipRanges)) {
const responses = {};
let queryError = false;
try {
queryData.bkip = range.includes(':') ? range + '::/64' : range;
const data = await this.postRequest(this.api, queryData, processBlock, 'blocks');
Object.assign(responses, data);
} catch (error) {
queryError = true;
console.error(`Failed to fetch bkip for range ${range}: ${error.message}`);
}
let state = 0;
if (Object.keys(responses).length) {
state = Math.max(...Object.values(responses));
}
ipGroup.forEach(ip => {
addRangeBlock(ips, ip, state);
});
if (!queryError) {
this.bkipCache.store(range, state);
}
}
}
applyResponses(queries, responses) {
for (const [name, state] of Object.entries(responses)) {
queries.get(name)?.forEach(context => this.callback(context, state));
}
}
event() {
const eventCache = new LocalStorageCache('uh-event-cache');
this.relevantUsers.forEach(key => {
let mask = key.match(/\/(\d+)$/);
if (mask) {
const groups = mask[1] < 32 ? 1 : (mask[1] < 48 ? 2 : 3);
const pattern = `^(?:\\d+\\.\\d+\\.|(?:\\w+:){${groups}})`;
const match = key.match(pattern);
if (match) {
const bkipCache = new LocalStorageCache('uh-bkip-cache');
bkipCache.invalidate(str => str.startsWith(match[0]));
bkipCache.save();
}
} else {
eventCache.store(key, true);
}
});
eventCache.save();
}
async clearUsers() {
this.usersCache.clear();
this.usersCache.save();
}
}
class UserHighlighter {
constructor() {
this.initialRun = true;
this.taskQueue = new Map();
this.hrefCache = new Map();
this.siteCache = new LocalStorageCache('uh-site-cache');
this.options = null;
this.bitGroup = null;
this.groupBit = null;
this.pathnames = null;
this.serverPrefix = window.location.origin;
this.startPromise = this.start();
this.processPromise = Promise.resolve();
this.debug = localStorage.getItem('uh-debug');
}
// compact user state
static PARTIAL = 0b0001;
static TEMPORARY = 0b0010;
static INDEFINITE = 0b0011;
static BLOCK_MASK = 0b0011;
static GROUP_START = 0b0100;
// settings
static ACTION_API = 'https://en.wikipedia.org/w/api.php';
static STYLESHEET = 'User:Daniel Quinlan/Scripts/UserHighlighter.css';
static DEFAULTS = {
groups: {
extendedconfirmed: { bit: 0b0100 },
sysop: { bit: 0b1000 },
bot: { bit: 0b10000 }
},
labels: {},
stylesheet: true
};
async start() {
let apiHighLimits;
[apiHighLimits, this.options, this.pathnames] = await Promise.all([
this.getApiHighLimits(),
this.getOptions(),
this.getPathnames()
]);
this.injectStyle();
this.bitGroup = {};
this.groupBit = {};
for (const [groupName, groupData] of Object.entries(this.options.groups)) {
this.bitGroup[groupData.bit] = groupName;
this.groupBit[groupName] = groupData.bit;
}
this.userStatus = new UserStatus(apiHighLimits, this.groupBit, this.applyClasses);
this.bindEvents();
}
async execute($content) {
const enqueue = (task) => {
this.taskQueue.set(task, true);
};
const dequeue = () => {
const task = this.taskQueue.keys().next().value;
if (task) {
this.taskQueue.delete(task);
return task;
}
return null;
};
try {
// set content
if (this.initialRun) {
const target = document.getElementById('bodyContent') ||
document.getElementById('mw-content-text') ||
document.body;
if (target) {
enqueue(target);
}
await this.startPromise;
} else if ($content && $content.length) {
for (const node of $content) {
if (node.nodeType === Node.ELEMENT_NODE) {
enqueue(node);
}
}
}
// debugging
if (this.debug) {
console.debug("UserHighlighter execute: content", $content, "taskQueue size", this.taskQueue.size, "initialRun", this.initialRun, "timestamp", performance.now());
}
// process content, avoiding concurrent processing
const currentPromise = this.processPromise;
this.processPromise = currentPromise
.then(() => this.processContent(dequeue))
.catch((error) => {
console.error("UserHighlighter error in processContent:", error);
});
} catch (error) {
console.error("UserHighlighter error in execute:", error);
}
}
async processContent(dequeue) {
let task;
while (task = dequeue()) {
const elements = task.querySelectorAll('a[href]:not(.userlink)');
for (let i = 0; i < elements.length; i++) {
const href = elements[i].getAttribute('href');
let userResult = this.hrefCache.get(href);
if (userResult === undefined) {
userResult = this.getUser(href);
this.hrefCache.set(href, userResult);
}
if (userResult) {
this.userStatus.query(userResult[0], elements[i]);
}
}
}
await this.userStatus.checkpoint(this.initialRun);
if (this.initialRun) {
this.addOptionsLink();
this.checkPreferences();
}
this.initialRun = false;
}
applyClasses = (element, state) => {
const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;
let classNames = ['userlink'];
let labelNames = new Set();
// extract group bits using a technique based on Kernighan's algorithm
let userGroupBits = state & ~BLOCK_MASK;
while (userGroupBits) {
const bitPosition = userGroupBits & -userGroupBits;
if (this.bitGroup.hasOwnProperty(bitPosition)) {
const groupName = this.bitGroup[bitPosition];
classNames.push(`uh-${groupName}`);
if (this.options.labels[groupName]) {
labelNames.add(this.options.labels[groupName]);
}
}
userGroupBits &= ~bitPosition;
}
// optionally add labels
if (labelNames.size) {
const href = element.getAttribute('href');
if (href) {
let userResult = this.hrefCache.get(href);
if (userResult === undefined) {
userResult = this.getUser(href);
this.hrefCache.set(href, userResult);
}
if (userResult && userResult[1] === 2) {
if (element.hasAttribute("data-labels")) {
element.getAttribute("data-labels").slice(1, -1).split(', ').filter(Boolean)
.forEach(label => labelNames.add(label));
}
element.setAttribute('data-labels', `[${[...labelNames].join(', ')}]`);
}
}
}
// blocks
switch (state & BLOCK_MASK) {
case INDEFINITE: classNames.push('user-blocked-indef'); break;
case TEMPORARY: classNames.push('user-blocked-temp'); break;
case PARTIAL: classNames.push('user-blocked-partial'); break;
}
// add classes
classNames = classNames.filter(name => !element.classList.contains(name));
element.classList.add(...classNames);
};
// return user for 'https://demo.azizisearch.com/lite/wikipedia/page/User:', 'https://demo.azizisearch.com/lite/wikipedia/page/User_talk:', 'https://demo.azizisearch.com/lite/wikipedia/page/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="https://demo.azizisearch.com/lite/wikipedia/page/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));
});