User:Daniel Quinlan/Scripts/UserHighlighterAlpha.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. |
![]() | Documentation for this user script can be added at User:Daniel Quinlan/Scripts/UserHighlighterAlpha. |
"use strict";
class LocalStorageCache {
constructor(name, modifier = null, ttl = 60, capacity = 1000) {
this.name = name;
this.ttl = ttl;
this.capacity = capacity;
this.divisor = 60000;
this.data = null;
this.start = null;
this.hitCount = 0;
this.missCount = 0;
this.invalid = false;
try {
// load
const dataString = localStorage.getItem(this.name);
this.data = dataString ? JSON.parse(dataString) : {};
// setup
const currentTime = Math.floor(Date.now() / this.divisor);
this.start = this.data['#start'] || currentTime;
if ('#hc' in this.data && '#mc' in this.data) {
this.hitCount = this.data['#hc'];
this.missCount = this.data['#mc'];
}
delete this.data['#start'];
delete this.data['#hc'];
delete this.data['#mc'];
modifier = modifier || ((key, value) => key.startsWith('#') ? 168 : 1);
// expire
for (const [key, value] of Object.entries(this.data)) {
const ttl = this.ttl * modifier(key, value[1]);
if (value[0] + this.start <= currentTime - ttl) {
delete this.data[key];
}
}
} catch (error) {
console.error(`LocalStorageCache error reading "${this.name}":`, error);
localStorage.removeItem(this.name);
this.invalid = true;
}
}
fetch(key) {
if (this.invalid) {
return undefined;
}
if (key in this.data) {
this.hitCount++;
return { time: this.data[key][0] + this.start, value: this.data[key][1] };
} else {
this.missCount++;
return undefined;
}
}
store(key, value, expiry = undefined) {
if (expiry) {
expiry = expiry instanceof Date ? expiry.getTime() : Date.parse(expiry);
if (expiry < Date.now() + (this.ttl * 60000)) {
return;
}
}
this.data[key] = [Math.floor(Date.now() / this.divisor) - this.start, value];
}
invalidate(predicate) {
Object.keys(this.data).forEach(key => predicate(key) && delete this.data[key]);
}
save() {
try {
// pruning
if (Object.keys(this.data).length > this.capacity) {
const sortedKeys = Object.keys(this.data).sort((a, b) => this.data[a][0] - this.data[b][0]);
let excess = sortedKeys.length - this.capacity;
for (const key of sortedKeys) {
if (excess <= 0) {
break;
}
delete this.data[key];
excess--;
}
}
// empty
if (!Object.keys(this.data).length) {
localStorage.setItem(this.name, JSON.stringify(this.data));
return;
}
// rebase timestamps
const first = Math.min(...Object.values(this.data).map(entry => entry[0]));
if (isNaN(first) && !isFinite(first)) {
throw new Error(`Invalid first timestamp: ${first}`);
}
for (const key in this.data) {
this.data[key][0] -= first;
}
this.start = this.start + first;
this.data['#start'] = this.start;
this.data['#hc'] = this.hitCount;
this.data['#mc'] = this.missCount;
localStorage.setItem(this.name, JSON.stringify(this.data));
delete this.data['#start'];
delete this.data['#hc'];
delete this.data['#mc'];
} catch (error) {
console.error(`LocalStorageCache error saving "${this.name}":`, error);
localStorage.removeItem(this.name);
this.invalid = true;
}
}
}
class UserStatus {
constructor(groupBit, callback) {
this.groupBit = groupBit;
this.callback = callback;
this.apiHighlimits = this.getApiHighlimits();
this.relevantUsers = this.getRelevantUsers();
this.eventCache = new LocalStorageCache('uh-event-cache');
this.usersCache = new LocalStorageCache('uh-users-cache', this.userModifier);
this.bkusersCache = new LocalStorageCache('uh-bkusers-cache');
this.bkipCache = new LocalStorageCache('uh-bkip-cache');
this.users = new Map();
this.ips = new Map();
}
static IPV4REGEX = /^(?:1?\d\d?|2[0-2]\d)\b(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3}$/;
static IPV6REGEX = /^[\dA-Fa-f]{1,4}(?:\:[\dA-Fa-f]{1,4}){7}$/;
getApiHighlimits() {
const highUserGroups = new Set(['sysop', 'researcher']);
const highGlobalGroups = new Set(['apihighlimits-requestor', 'global-sysop', 'staff', 'steward', 'sysadmin', 'wmf-researcher']);
return mw.config.get('wgUserGroups').some(group => highUserGroups.has(group)) || mw.config.get('wgGlobalGroups').some(group => highGlobalGroups.has(group));
}
getRelevantUsers() {
const { IPV4REGEX, IPV6REGEX } = UserStatus;
let rusers = [];
if (![-1, 2, 3].includes(mw.config.get('wgNamespaceNumber'))) {
return new Set(rusers);
}
let ruser = mw.config.get('wgRelevantUserName');
let mask;
if (!ruser) {
const page = mw.config.get('wgPageName');
const match = page.match(/^Special:\w+\/([^\/]+)(?:\/(\d{2,3}$))?/i);
if (match) {
ruser = match[1];
mask = match[2];
}
}
if (ruser) {
if (IPV6REGEX.test(ruser)) {
ruser = ruser.toUpperCase();
rusers.push(this.ipRangeKey(ruser));
}
rusers.push(ruser);
if (mask && Number(mask) !== 64 && (IPV4REGEX.test(ruser) || IPV6REGEX.test(ruser))) {
rusers.push(`${ruser}/${mask}`);
}
rusers = rusers.filter(key => key && key !== mw.config.get('wgUserName'));
}
return new Set(rusers);
}
userModifier = (key, value) => {
if (value & this.groupBit.sysop)
return 24;
else if (value & this.groupBit.extendedconfirmed)
return 3;
return 1;
};
userFetch(cache, key) {
const cachedState = cache.fetch(key);
if (!cachedState || this.relevantUsers.has(key)) {
return false;
}
const cachedEvent = this.eventCache.fetch(key);
if (cachedEvent && cachedState.time <= cachedEvent.time) {
return false;
}
return cachedState;
}
ipRangeKey(ip) {
return ip.includes('.') ? ip : ip.split('/')[0].split(':').slice(0, 4).join(':');
}
query(user, context) {
const { IPV4REGEX, IPV6REGEX } = UserStatus;
const processIP = (ip, context) => {
const bkusersCached = this.userFetch(this.bkusersCache, ip);
const bkipCached = this.userFetch(this.bkipCache, this.ipRangeKey(ip));
if (bkusersCached && bkipCached) {
this.callback(context, bkusersCached.value | bkipCached.value);
return;
}
this.ips.has(ip) ? this.ips.get(ip).push(context) : this.ips.set(ip, [context]);
};
const processUser = (user, context) => {
const cached = this.userFetch(this.usersCache, user);
if (cached) {
this.callback(context, cached.value);
return;
}
this.users.has(user) ? this.users.get(user).push(context) : this.users.set(user, [context]);
};
if (IPV4REGEX.test(user)) {
processIP(user, context);
} else if (IPV6REGEX.test(user)) {
processIP(user.toUpperCase(), context);
} else {
if (user.charAt(0) === user.charAt(0).toLowerCase()) {
user = user.charAt(0).toUpperCase() + user.slice(1);
}
processUser(user, context);
}
}
async checkpoint(initialRun) {
if (!this.users.size && !this.ips.size) {
return;
}
// queries
const usersPromise = this.usersQueries(this.users);
const bkusersPromise = this.bkusersQueries(this.ips);
usersPromise.then(usersResponses => {
this.applyResponses(this.users, usersResponses);
});
bkusersPromise.then(bkusersResponses => {
this.applyResponses(this.ips, bkusersResponses);
});
await bkusersPromise;
const bkipPromise = this.bkipQueries(this.ips);
await Promise.all([usersPromise, bkipPromise]);
// save caches
if (initialRun) {
this.usersCache.save();
this.bkusersCache.save();
this.bkipCache.save();
}
// clear maps
this.users.clear();
this.ips.clear();
}
*chunks(full, n) {
for (let i = 0; i < full.length; i += n) {
yield full.slice(i, i + n);
}
}
async postRequest(data, callback, property) {
const url = mw.util.wikiScript('api') + '?format=json&action=query';
try {
const response = await $.post(url, data, 'json');
if (response.query && response.query[property]) {
const cumulativeResult = {};
response.query[property].forEach(item => {
const result = callback(item);
if (result) {
cumulativeResult[result.key] = result.value;
}
});
return cumulativeResult;
} else {
throw new Error("JSON location not found or empty");
}
} catch (error) {
throw new Error(`Failed to fetch data: ${error.message}`);
}
}
async usersQueries(users) {
const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;
const processUser = (user) => {
let state = 0;
if (user.blockid) {
state = 'blockpartial' in user ? PARTIAL :
(user.blockexpiry === 'infinite' ? INDEFINITE : TEMPORARY);
}
if (user.groups) {
state = user.groups.reduce((accumulator, name) => {
return accumulator | (this.groupBit[name] || 0);
}, state);
}
return { key: user.name, value: state };
};
const responses = {};
const chunkSize = this.apiHighlimits ? 500 : 50;
const queryData = {
list: 'users',
usprop: 'blockinfo|groups'
};
for (const chunk of this.chunks(Array.from(users.keys()), chunkSize)) {
await new Promise((resolve, reject) => {
queryData.ususers = chunk.join('|');
this.postRequest(queryData, processUser, 'users')
.then(data => {
Object.assign(responses, data);
resolve();
})
.catch(error => {
reject(new Error(`Failed to fetch users: ${error.message}`));
});
});
}
for (const [user, state] of Object.entries(responses)) {
this.usersCache.store(user, state);
}
return responses;
}
async bkusersQueries(ips) {
const { PARTIAL, TEMPORARY, INDEFINITE } = UserHighlighter;
const processBlock = (block) => {
const partial = block.restrictions && !Array.isArray(block.restrictions);
const state = partial ? PARTIAL : (
/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
const user = block.user.endsWith('/64') ? this.ipRangeKey(block.user) : block.user;
return { key: user, value: state };
};
const ipQueries = new Set();
for (const ip of ips.keys()) {
const cached = this.userFetch(this.bkusersCache, ip);
if (!cached) {
ipQueries.add(ip);
if (ip.includes(':')) {
ipQueries.add(this.ipRangeKey(ip) + '::/64');
}
}
}
const responses = {};
const chunkSize = this.apiHighlimits ? 500 : 50;
const queryData = {
list: 'blocks',
bklimit: 500,
bkprop: 'user|by|timestamp|expiry|reason|restrictions'
};
let queryError = false;
for (const chunk of this.chunks(Array.from(ipQueries.keys()), chunkSize)) {
await new Promise((resolve, reject) => {
queryData.bkusers = chunk.join('|');
this.postRequest(queryData, processBlock, 'blocks')
.then(data => {
Object.assign(responses, data);
resolve();
})
.catch(error => {
queryError = true;
reject(new Error(`Failed to fetch bkusers: ${error.message}`));
});
});
}
// check possible responses
const results = {};
for (const ip of ips.keys()) {
if (!ipQueries.has(ip)) {
continue;
}
let state = responses[ip] || 0;
if (ip.includes(':')) {
const range = this.ipRangeKey(ip);
const rangeState = responses[range] || 0;
state = Math.max(state, rangeState);
}
// store single result, only blocks are returned so skip if any errors
if (!queryError) {
this.bkusersCache.store(ip, state);
}
results[ip] = state;
}
return results;
}
async bkipQueries(ips) {
const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;
function processBlock(block) {
const partial = block.restrictions && !Array.isArray(block.restrictions);
const state = partial ? PARTIAL : (
/^in/.test(block.expiry) ? INDEFINITE : TEMPORARY);
return { key: block.id, value: state };
}
const addRangeBlock = (ips, ip, state) => {
if (ips.get(ip) && state) {
ips.get(ip).forEach(context => this.callback(context, state));
}
};
// check cache and build queries
const ipRanges = {};
for (const ip of ips.keys()) {
const range = this.ipRangeKey(ip);
const cached = this.userFetch(this.bkipCache, range);
if (cached) {
addRangeBlock(ips, ip, cached.value);
} else {
if (!ipRanges.hasOwnProperty(range))
ipRanges[range] = [];
ipRanges[range].push(ip);
}
}
const queryData = {
list: 'blocks',
bklimit: 100,
bkprop: 'user|id|by|timestamp|expiry|range|reason|restrictions'
};
for (const [range, ipGroup] of Object.entries(ipRanges)) {
const responses = {};
let queryError = false;
await new Promise((resolve, reject) => {
queryData.bkip = range.includes(':') ? range + '::/64' : range;
this.postRequest(queryData, processBlock, 'blocks')
.then(data => {
Object.assign(responses, data);
resolve();
})
.catch(error => {
queryError = true;
reject(new Error(`Failed to fetch bkip: ${error.message}`));
});
});
let state = 0;
if (Object.keys(responses).length) {
state = Math.max(...Object.values(responses));
}
ipGroup.forEach(ip => {
addRangeBlock(ips, ip, state);
});
if (!queryError) {
this.bkipCache.store(range, state);
}
}
}
applyResponses(queries, responses) {
for (const [name, state] of Object.entries(responses)) {
queries.get(name)?.forEach(context => this.callback(context, state));
}
}
event() {
const eventCache = new LocalStorageCache('uh-event-cache');
this.relevantUsers.forEach(key => {
let mask = key.match(/\/(\d+)$/);
if (mask) {
const groups = mask[1] < 32 ? 1 : (mask[1] < 48 ? 2 : 3);
const pattern = `^(?:\\d+\\.\\d+\\.|(?:\\w+:){${groups}})`;
const match = key.match(pattern);
if (match) {
const bkipCache = new LocalStorageCache('uh-bkip-cache');
bkipCache.invalidate(str => str.startsWith(match[0]));
bkipCache.save();
}
} else {
eventCache.store(key, true);
}
});
eventCache.save();
}
}
class UserHighlighter {
constructor() {
this.isExecuting = false;
this.initialRun = true;
this.taskQueue = new Map();
this.siteCache = new LocalStorageCache('uh-site-cache');
this.options = null;
this.bitGroup = null;
this.groupBit = null;
this.pathnames = null;
this.startPromise = this.start();
}
// Compact user state
static PARTIAL = 0b0001;
static TEMPORARY = 0b0010;
static INDEFINITE = 0b0011;
static BLOCK_MASK = 0b0011;
static GROUP_START = 0b0100;
// Settings
static ACTION_API = 'https://en.wikipedia.org/w/api.php';
static STYLESHEET = 'User:Daniel Quinlan/Scripts/UserHighlighter.css';
static DEFAULTS = { groups: { extendedconfirmed: { bit: 0b0100 }, sysop: { bit: 0b1000 } }, stylesheet: true };
async start() {
this.options = await this.getOptions();
this.injectStyle();
this.pathnames = await this.getPathnames();
this.bitGroup = {};
this.groupBit = {};
for (const [groupName, groupData] of Object.entries(this.options.groups)) {
this.bitGroup[groupData.bit] = groupName;
this.groupBit[groupName] = groupData.bit;
}
this.userStatus = new UserStatus(this.groupBit, this.applyClasses);
this.bindEvents();
}
async execute($content) {
const enqueue = ($task) => {
this.taskQueue.set($task, true);
};
const dequeue = () => {
const $task = this.taskQueue.keys().next().value;
if ($task) {
this.taskQueue.delete($task);
return $task;
}
return null;
};
const finish = () => {
if (this.initialRun) {
this.checkPreferences();
this.highlightingMode();
}
this.initialRun = false;
this.isExecuting = false;
};
try {
// set content
let $target;
if (this.initialRun) {
$target = $('#bodyContent');
if (!$target.length) {
$target = $('#mw-content-text');
}
await this.startPromise;
} else {
$target = $content;
}
if ($target && $target.length) {
enqueue($target);
}
// avoid concurrent execution
if (this.isExecuting) {
return;
}
// start execution
this.isExecuting = true;
let $next;
while ($next = dequeue()) {
this.processContent($next);
}
await this.userStatus.checkpoint(this.initialRun);
// finish
finish();
} catch (error) {
console.error("UserHighlighter error in execute:", error);
finish();
}
}
processContent($content) {
const hrefCache = {};
const elements = $content[0].querySelectorAll('a[href]');
for (let i = 0; i < elements.length; i++) {
const href = elements[i].getAttribute('href');
const user = hrefCache[href] ?? (hrefCache[href] = this.getUser(href));
if (user) {
this.userStatus.query(user, elements[i]);
}
}
}
applyClasses = (element, state) => {
const { PARTIAL, TEMPORARY, INDEFINITE, BLOCK_MASK } = UserHighlighter;
let classNames = ['userlink'];
switch (state & BLOCK_MASK) {
case INDEFINITE: classNames.push('user-blocked-indef'); break;
case TEMPORARY: classNames.push('user-blocked-temp'); break;
case PARTIAL: classNames.push('user-blocked-partial'); break;
}
// extract group bits using a technique based on Kernighan's algorithm
let userGroupBits = state & ~BLOCK_MASK;
while (userGroupBits) {
const bitPosition = userGroupBits & -userGroupBits;
if (this.bitGroup.hasOwnProperty(bitPosition)) {
classNames.push(`uh-${this.bitGroup[bitPosition]}`);
}
userGroupBits &= ~bitPosition;
}
classNames = classNames.filter(name => !element.classList.contains(name));
element.classList.add(...classNames);
};
// Return user for '/wiki/User:', '/wiki/User_talk:', '/wiki/Special:Contributions/',
// and '/w/index.php?title=User:' links.
getUser(url) {
// Skip links that won't be user pages.
if (!url || !(url.startsWith('/') || url.startsWith('https://')) || url.startsWith('//')) {
return false;
}
// Skip links that aren't to user pages.
if (!url.includes(this.pathnames.articlePath) && !url.includes(this.pathnames.scriptPath)) {
return false;
}
// Strip server prefix.
if (!url.startsWith('/')) {
if (url.startsWith(this.pathnames.serverPrefix)) {
url = url.substring(this.pathnames.serverPrefix.length);
}
else {
return false;
}
}
// Skip links without ':'.
if (!url.includes(':')) {
return false;
}
// Extract title.
let title;
if (url.startsWith(this.pathnames.articlePath)) {
title = url.substring(this.pathnames.articlePath.length);
} else if (url.startsWith(mw.config.get('wgScript'))) {
// Extract the value of "title" parameter and decode it.
const paramsIndex = url.indexOf('?');
if (paramsIndex !== -1) {
const queryString = url.substring(paramsIndex + 1);
const queryParams = new URLSearchParams(queryString);
title = queryParams.get('title');
// Skip links with disallowed parameters.
if (title) {
const allowedParams = ['action', 'redlink', 'safemode', 'title'];
const hasDisallowedParams = Array.from(queryParams.keys()).some(name => !allowedParams.includes(name));
if (hasDisallowedParams) {
return false;
}
}
}
}
if (!title) {
return false;
}
title = title.replaceAll('_', ' ');
try {
title = decodeURIComponent(title);
} catch (error) {
console.warn(`UserHighlighter error decoding "${title}":`, error);
return false;
}
// Extract user from the title based on namespace.
let user;
const lowercaseTitle = title.toLowerCase();
for (const namespaceString of this.pathnames.namespaceStrings) {
if (lowercaseTitle.startsWith(namespaceString)) {
user = title.substring(namespaceString.length);
break;
}
}
if (!user || user.includes('/')) {
return false;
}
if (user.toLowerCase().endsWith('#top')) {
user = user.slice(0, -4);
}
return user && !user.includes('#') ? user : false;
}
bindEvents() {
const buttonClick = (event) => {
try {
const button = $(event.target).text();
if (/block|submit/i.test(button)) {
this.userStatus.event();
}
} catch (error) {
console.error("UserHighlighter error in buttonClick:", error);
}
};
const dialogOpen = (event, ui) => {
try {
const dialog = $(event.target).closest('.ui-dialog');
const title = dialog.find('.ui-dialog-title').text();
if (title.toLowerCase().includes('block')) {
dialog.find('button').on('click', buttonClick);
}
} catch (error) {
console.error("UserHighlighter error in dialogOpen:", error);
}
};
if (!this.userStatus.relevantUsers.size) {
return;
}
if (['Block', 'Unblock'].includes(mw.config.get('wgCanonicalSpecialPageName'))) {
$(document.body).on('click', 'button', buttonClick);
}
$(document.body).on('dialogopen', dialogOpen);
}
async getOptions() {
const optionString = mw.user.options.get('userjs-userhighlighter');
let options;
try {
if (optionString !== null) {
const options = JSON.parse(optionString);
if (typeof options === 'object')
return options;
}
} catch (error) {
console.error("UserHighlighter error reading options:", error);
}
await this.saveOptions(UserHighlighter.DEFAULTS);
return UserHighlighter.DEFAULTS;
}
async saveOptions(options) {
const value = JSON.stringify(options);
await new mw.Api().saveOption('userjs-userhighlighter', value).then(function() {
mw.user.options.set('userjs-userhighlighter', value);
}).fail(function(xhr, status, error) {
console.error("UserHighlighter error saving options:", error);
});
}
checkPreferences() {
if (mw.user.options.get('gadget-markblocked')) {
mw.notify($('<span>If you are using UserHighlighter, disable <a href="/wiki/Special:Preferences#mw-prefsection-gadgets" style="text-decoration: underline;">Strike out usernames that have been blocked</a> in preferences.</span>'), { autoHide: false, tag: 'uh-warning', title: "User highlighter", type: 'warn' });
}
}
highlightingMode() {
if (mw.config.get('wgTitle') !== mw.config.get('wgUserName') + '/common.css') {
return;
}
mw.util.addPortletLink('p-tb', '#', "User highlighting mode", 'ca-userhighlighter-mode');
$("#ca-userhighlighter-mode").click((event) => {
event.preventDefault();
this.options.stylesheet = !this.options.stylesheet;
this.saveOptions(this.options);
const mode = this.options.stylesheet ? 'default' : 'custom';
mw.notify(`Now using ${mode} stylesheet!`, { title: "User highlighter" });
});
}
async injectStyle() {
if (!this.options.stylesheet) {
return;
}
let cached = this.siteCache.fetch('#stylesheet');
let css = cached !== undefined ? cached.value : undefined;
if (!css) {
try {
const api = new mw.ForeignApi(UserHighlighter.ACTION_API);
const response = await api.get({
action: 'query',
formatversion: '2',
prop: 'revisions',
rvprop: 'content',
rvslots: 'main',
titles: UserHighlighter.STYLESHEET
});
css = response.query.pages[0].revisions[0].slots.main.content;
css = css.replace(/\n\s*|\s+(?=[!\{])|;(?=\})|(?<=[,:])\s+/g, '');
this.siteCache.store('#stylesheet', css);
this.siteCache.save();
} catch (error) {
console.error("UserHighlighter error fetching CSS:", error);
}
}
if (css) {
const style = document.createElement("style");
style.textContent = css;
document.head.appendChild(style);
}
}
async getPathnames() {
let cached = this.siteCache.fetch('#pathnames');
if (cached && cached.value) {
return cached.value;
}
// user pages
const namespaceIds = mw.config.get('wgNamespaceIds');
let userPages = Object.keys(namespaceIds)
.filter(key => namespaceIds[key] === 2 || namespaceIds[key] === 3)
.map(key => key.replaceAll('_', ' ').toLowerCase() + ':');
if (userPages.length >= 4) {
userPages = userPages
.filter(item => item !== 'user:' && item !== 'user talk:');
}
// contributions
let specialPages = Object.keys(namespaceIds)
.filter(key => namespaceIds[key] === -1)
.map(key => key.replaceAll('_', ' '));
let contributionsPage = 'Contributions';
try {
const api = new mw.Api();
const response = await api.get({
action: 'query',
format: 'json',
formatversion: '2',
meta: 'siteinfo',
siprop: 'specialpagealiases'
});
const contributionsItem = response.query.specialpagealiases
.find(item => item.realname === 'Contributions');
if (contributionsItem && contributionsItem.aliases) {
contributionsPage = contributionsItem.aliases[0];
}
} catch(error) {
console.warn("UserHighlighter error fetching specialpagealiases", error);
}
if (specialPages.length > 1) {
specialPages = specialPages.filter(item => item !== 'special');
}
const specialContributionsPages = specialPages
.map(item => `${item}:${contributionsPage}/`.toLowerCase());
// pages
const pages = {};
pages.serverPrefix = 'https:' + mw.config.get('wgServer');
pages.articlePath = mw.config.get('wgArticlePath').replace(/\$1/, '');
pages.scriptPath = mw.config.get('wgScript') + '?title=';
pages.namespaceStrings = [...userPages, ...specialContributionsPages];
this.siteCache.store('#pathnames', pages);
this.siteCache.save();
return pages;
}
async getGroups() {
const groupNames = {};
try {
const api = new mw.Api();
const response = await api.get({
action: 'query',
format: 'json',
formatversion: '2',
meta: 'siteinfo',
sinumberingroup: true,
siprop: 'usergroups'
});
const groups = response.query.usergroups
.filter(group => group.number && group.name && /^[\w-]+$/.test(group.name) && group.name !== 'user');
for (const group of groups) {
groupNames[group.name] = group.number;
}
} catch(error) {
console.warn("UserHighlighter error fetching usergroups", error);
}
return groupNames;
}
}
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'user.options'], function() {
if (mw.config.get('wgNamespaceNumber') === 0 && mw.config.get('wgAction') === 'view' && !window.location.search) {
return;
}
const uh = new UserHighlighter();
mw.hook('wikipage.content').add(uh.execute.bind(uh));
});