User:Daniel Quinlan/Scripts/RangeHelper.js

class IPAddress {

static from(input) {

if (typeof input !== 'string') return null;

try {

const parsed = IPAddress.#parse(input);

return parsed ? new IPAddress(parsed) : null;

} catch {

return null;

}

}

constructor({ version, ip, mask }) {

this.version = version;

this.ip = ip;

this.mask = mask;

this.effectiveMask = mask ?? (version === 4 ? 32 : 128);

}

equals(other) {

return other instanceof IPAddress &&

this.version === other.version &&

this.ip === other.ip &&

this.effectiveMask === other.effectiveMask;

}

masked(prefixLength) {

const size = this.version === 4 ? 32 : 128;

const mask = (1n << BigInt(size - prefixLength)) - 1n;

const maskedIP = this.ip & ~mask;

return new IPAddress({

ip: maskedIP,

mask: prefixLength,

version: this.version

});

}

enumerate() {

if (this.version != 4) {

throw new Error('can only enumerate IPv4 addresses');

}

const count = 1n << BigInt(32 - this.mask);

let current = this.masked(this.mask).ip;

return Array.from({ length: Number(count) }, () =>

IPAddress.#bigIntToIPv4(current++)

);

}

toString(uppercase = true, compress = false) {

let ipString = this.version === 4

? IPAddress.#bigIntToIPv4(this.ip)

: IPAddress.#bigIntToIPv6(this.ip);

if (compress && this.version === 6) {

ipString = IPAddress.#compressIPv6(ipString);

}

if (this.mask !== null) {

ipString += `/${this.mask}`;

}

return uppercase ? ipString.toUpperCase() : ipString;

}

getRange() {

const size = this.version === 4 ? 32 : 128;

const effectiveMask = this.effectiveMask;

const hostBits = BigInt(size - effectiveMask);

const start = this.ip & (~0n << hostBits);

const end = start | ((1n << hostBits) - 1n);

return {

start: new IPAddress({ ip: start, mask: null, version: this.version }),

end: new IPAddress({ ip: end, mask: null, version: this.version })

};

}

inRange(other) {

if (!(other instanceof IPAddress)) return false;

if (this.version !== other.version) return false;

const { start, end } = this.getRange();

return other.ip >= start.ip && other.ip <= end.ip;

}

static #parse(input) {

const IPV4REGEX = /^((?:1?\d\d?|2[0-2]\d)\b(?:\.(?:1?\d\d?|2[0-4]\d|25[0-5])){3})(?:\/(1[6-9]|2\d|3[0-2]))?$/;

const IPV6REGEX = /^((?:[\dA-Fa-f]{1,4}:){7}[\dA-Fa-f]{1,4}|(?:[\dA-Fa-f]{1,4}:){1,7}:|(?:[\dA-Fa-f]{1,4}:){1,6}:[\dA-Fa-f]{1,4}|(?:[\dA-Fa-f]{1,4}:){1,5}(?:\:[\dA-Fa-f]{1,4}){1,2}|(?:[\dA-Fa-f]{1,4}:){1,4}(?:\:[\dA-Fa-f]{1,4}){1,3}|(?:[\dA-Fa-f]{1,4}:){1,3}(?:\:[\dA-Fa-f]{1,4}){1,4}|(?:[\dA-Fa-f]{1,4}:){1,2}(?:\:[\dA-Fa-f]{1,4}){1,5}|[\dA-Fa-f]{1,4}:(?:(?:\:[\dA-Fa-f]{1,4}){1,6}))(?:\/(19|[2-9]\d|1[01]\d|12[0-8]))?$/; // based on https://stackoverflow.com/a/17871737

const match = IPV4REGEX.exec(input) || IPV6REGEX.exec(input);

if (match) {

const version = match[1].includes(':') ? 6 : 4;

const ip = version === 4 ? IPAddress.#ipv4ToBigInt(match[1]) : IPAddress.#ipv6ToBigInt(match[1]);

const mask = match[2] ? parseInt(match[2], 10) : null;

return { version, ip, mask };

}

return null;

}

static #ipv4ToBigInt(ipv4) {

const octets = ipv4.split('.').map(BigInt);

return (octets[0] << 24n) | (octets[1] << 16n) | (octets[2] << 8n) | octets[3];

}

static #expandIPv6(segments) {

const expanded = [];

let hasEmpty = false;

segments.forEach(segment => {

if (segment === '' && !hasEmpty) {

expanded.push(...Array(8 - segments.filter(s => s).length).fill('0'));

hasEmpty = true;

} else if (segment === '') {

expanded.push('0');

} else {

expanded.push(segment);

}

});

return expanded.map(seg => seg.padStart(4, '0'));

}

static #ipv6ToBigInt(ipv6) {

const segments = ipv6.split(':');

let bigIntValue = 0n;

const expanded = IPAddress.#expandIPv6(segments);

expanded.forEach(segment => {

bigIntValue = (bigIntValue << 16n) + BigInt(parseInt(segment, 16));

});

return bigIntValue;

}

static #bigIntToIPv4(bigIntValue) {

return [

(bigIntValue >> 24n) & 255n,

(bigIntValue >> 16n) & 255n,

(bigIntValue >> 8n) & 255n,

bigIntValue & 255n,

].join('.');

}

static #bigIntToIPv6(bigIntValue) {

const segments = [];

for (let i = 0; i < 8; i++) {

const segment = (bigIntValue >> BigInt((7 - i) * 16)) & 0xffffn;

segments.push(segment.toString(16));

}

return segments.join(':')

}

static #compressIPv6(ipv6) {

let run = null;

for (const match of ipv6.matchAll(/:?\b(0(?:\:0)+)\b:?/g)) {

if (!run || match[1].length > run[1].length) {

run = match;

}

}

return run ? `${ipv6.slice(0, run.index)}::${ipv6.slice(run.index + run[0].length)}` : ipv6;

}

}

mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.DateFormatter']).then(function() {

// state variables

const wikitextCache = new Map();

let api = null;

let formatTimeAndDate = null;

// special page handling

const pageName = mw.config.get('wgPageName');

const specialPage = mw.config.get('wgCanonicalSpecialPageName');

if (specialPage === 'Contributions') {

const userToolsBDI = document.querySelector('.mw-contributions-user-tools bdi');

const userName = userToolsBDI ? userToolsBDI.textContent.trim() : '';

const ip = IPAddress.from(userName);

if (ip) {

addContributionsLinks(ip);

}

} else if (specialPage === 'Blankpage') {

const match = pageName.match(/^Special:BlankPage\/(\w+)(?:\/(.+))?$/);

if (!match) return;

if (match[1] === 'RangeBlocks') {

const ip = IPAddress.from(match[2]);

if (ip) {

displayRangeBlocks(ip);

}

} else if (match[1] === 'RangeCalculator') {

displayRangeCalculator();

}

} else if (mw.config.get('wgCanonicalNamespace') === 'User_talk') {

const ip = IPAddress.from(mw.config.get('wgTitle'));

if (ip && ip.mask) {

displayRangeTalk(ip);

}

} else if (pageName === 'Special:Log/block') {

const pageParam = mw.util.getParamValue('page');

if (pageParam) {

const match = pageParam.match(/^User:(.+)$/);

if (!match) return;

const ip = IPAddress.from(match[1]);

if (ip) {

mw.util.addPortletLink('p-tb', `/wiki/Special:BlankPage/RangeBlocks/${ip}`, "Find range blocks");

}

}

}

return;

// adds links to user tools

function addContributionsLinks(ip) {

const userToolsContainer = document.querySelector('.mw-contributions-user-tools .mw-changeslist-links');

if (!userToolsContainer) return;

const existingTalkLink = userToolsContainer.querySelector('.mw-contributions-link-talk');

const rangeTalkLink = document.createElement('a');

rangeTalkLink.className = 'mw-contributions-link-talk-range';

const wrapper = document.createElement('span');

if (existingTalkLink) {

const mask = ip.version === 4 ? 24 : 64;

const range = ip.masked(mask);

rangeTalkLink.href = `/wiki/User_talk:${range}`;

rangeTalkLink.title = `User talk:${range}`;

rangeTalkLink.textContent = `(/${mask})`;

wrapper.appendChild(document.createTextNode(' '));

wrapper.appendChild(rangeTalkLink);

existingTalkLink.parentNode.insertBefore(wrapper, existingTalkLink.nextSibling);

} else {

rangeTalkLink.href = `/wiki/User_talk:${ip}`;

rangeTalkLink.title = `User talk:${ip}`;

rangeTalkLink.textContent = 'talk';

wrapper.appendChild(rangeTalkLink);

userToolsContainer.insertBefore(wrapper, userToolsContainer.firstChild);

}

const blockLogLink = userToolsContainer.querySelector('.mw-contributions-link-block-log');

if (blockLogLink) {

const rangeLogLink = document.createElement('a');

const rangeLogPage = `Special:BlankPage/RangeBlocks/${ip}`;

rangeLogLink.href = `/wiki/${rangeLogPage}`;

rangeLogLink.textContent = '(ranges)';

rangeLogLink.className = 'mw-link-range-blocks';

rangeLogLink.title = rangeLogPage;

const wrapperSpan = document.createElement('span');

wrapperSpan.appendChild(document.createTextNode(' '));

wrapperSpan.appendChild(rangeLogLink);

blockLogLink.parentNode.insertBefore(wrapperSpan, blockLogLink.nextSibling);

}

const spans = userToolsContainer.querySelectorAll('span');

let insertBefore = null;

for (const span of spans) {

if (span.textContent.toLowerCase().includes('global')) {

insertBefore = span;

break;

}

}

if (!insertBefore) return;

const floor = ip.version === 4 ? 16 : 32;

const ceiling = Math.min(ip.version === 4 ? 24 : 64, ip.effectiveMask - 1);

const steps = ip.effectiveMask >= 64 ? 16 : 8;

for (let mask = floor; mask <= ceiling; mask += steps) {

const contribsLink = document.createElement('a');

contribsLink.href = `/wiki/Special:Contributions/${ip.masked(mask)}`;

contribsLink.textContent = `/${mask}`;

contribsLink.className = 'mw-contributions-link-range-suggestion';

const span = document.createElement('span');

span.appendChild(contribsLink);

userToolsContainer.insertBefore(span, insertBefore);

}

if (ip.mask) {

mw.util.addPortletLink('p-tb', '/wiki/Special:BlankPage/RangeCalculator', 'Range calculator');

mw.util.addPortletLink('p-tb', '#', 'Range selector')

.addEventListener('click', event => {

event.preventDefault();

startRangeSelection();

});

}

}

// find range blocks

async function displayRangeBlocks(ip) {

api = new mw.Api();

formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;

document.title = `Range blocks for ${ip}`;

const heading = document.querySelector('#firstHeading');

if (heading) {

heading.textContent = `Range blocks for ${ip}`;

}

const contentContainer = document.querySelector('#mw-content-text');

if (!contentContainer) return;

contentContainer.innerHTML = '';

const statusMessage = document.createElement('p');

statusMessage.innerHTML = `Querying logs for IP blocks affecting ${ip}...`;

contentContainer.appendChild(statusMessage);

const resultsList = document.createElement('ul');

contentContainer.appendChild(resultsList);

const masks = ip.version === 4 ? sequence(16, 31) : sequence(19, 64);

const ranges = masks.map(mask => ip.masked(mask));

if (!masks.includes(ip.mask)) {

ranges.push(ip);

}

const blocks = [];

const blockPromises = ranges.map(range => {

return getBlockLogs(api, range).then(async (blockLogs) => {

for (const block of blockLogs) {

const formattedBlock = await formatBlockEntry(block);

blocks.push({ logid: block.logid, formattedBlock });

}

}).catch(error => {

console.error(`Error fetching block logs for range ${range}:`, error);

});

});

await Promise.all(blockPromises);

blocks.sort((a, b) => b.logid - a.logid);

blocks.forEach(({ formattedBlock }) => {

const li = document.createElement('li');

li.innerHTML = formattedBlock;

resultsList.appendChild(li);

});

if (!blocks.length) {

statusMessage.innerHTML = 'No blocks found.';

} else {

statusMessage.innerHTML = `Range blocks for ${ip}`;

}

mw.hook('wikipage.content').fire($(contentContainer));

}

// display talk pages for IP range

async function displayRangeTalk(ip) {

async function getUserTalkPages(ip, maxPages = 32) {

const userTalk = new Set();

const { start, end } = ip.getRange();

const prefix = commonPrefix(start.toString(true), end.toString(true));

const validPrefix = /^\w+[.:]\w+[.:]/.test(prefix);

let url = null;

let pagesFetched = 0;

let errors = false;

if (validPrefix) {

url = `/wiki/Special:PrefixIndex?prefix=${encodeURIComponent(prefix)}&namespace=3`;

}

while (url && pagesFetched < maxPages && !errors) {

try {

const html = await fetch(url).then(res => res.text());

const parser = new DOMParser();

const fetched = parser.parseFromString(html, 'text/html');

const links = fetched.querySelectorAll('ul.mw-prefixindex-list > li > a');

for (const link of links) {

const ipText = link.textContent;

const pageIp = IPAddress.from(ipText);

if (pageIp && ip.inRange(pageIp)) {

userTalk.add(`User talk:${ipText}`);

}

}

const nextLink = fetched.querySelector('.mw-prefixindex-nav a');

if (nextLink && nextLink.textContent.includes('Next page') && nextLink.href) {

url = nextLink.href;

} else {

url = null;

}

} catch (error) {

console.error('Error fetching usertalk pages:', error);

errors = true;

break;

}

pagesFetched++;

}

if (!validPrefix || errors || pagesFetched === maxPages) {

url = `/wiki/Special:Contributions/${ip}?limit=500`;

try {

const html = await fetch(url).then(res => res.text());

const parser = new DOMParser();

const fetched = parser.parseFromString(html, 'text/html');

const talkLinks = fetched.querySelectorAll('.mw-contributions-list a.mw-usertoollinks-talk:not(.new)');

for (const link of talkLinks) {

const title = link.title;

if (title) userTalk.add(title);

}

} catch (error) {

console.error('Error fetching usertalk pages:', error);

}

}

return Array.from(userTalk);

}

function timeAgo(timestamp) {

const delta = (Date.now() - new Date(timestamp)) / 1000;

const units = { year: 31536000, month: 2628000, day: 86400, hour: 3600, minute: 60 };

for (const [unit, seconds] of Object.entries(units)) {

let count = delta / seconds;

if (count >= 1) return `${count | 0} ${unit}${count >= 2 ? 's' : ''}`;

}

return 'just now';

}

api = new mw.Api();

const contentContainer = document.querySelector('#mw-content-text');

if (!contentContainer) return;

const elementsToRemove = [

'#mw-content-subtitle .subpages',

'#mw-content-text .noarticletext',

'.vector-menu-content-list #ca-addsection',

'.vector-menu-content-list #ca-dt-page-subscribe',

'.vector-menu-content-list #ca-edit',

'.vector-menu-content-list #ca-nstab-user',

'.vector-menu-content-list #ca-protect',

'.vector-menu-content-list #ca-talk',

'.vector-menu-content-list #ca-watch',

'.vector-menu-content-list #ca-wikilove',

'.vector-menu-content-list #t-info',

'.vector-menu-content-list #t-log',

'.vector-menu-content-list #t-urlshortener',

'.vector-menu-content-list #t-urlshortener-qrcode',

'.vector-menu-content-list #t-whatlinkshere',

];

for (const selector of elementsToRemove) {

document.querySelector(selector)?.remove();

}

const cactions = document.getElementById('p-cactions');

if (cactions) {

const listItems = cactions.querySelectorAll('li');

const anyVisible = Array.from(listItems).some(li => {

return li.offsetParent !== null;

});

if (!anyVisible) {

cactions.style.display = 'none';

}

}

const contributions = document.querySelector('#t-contributions a');

if (contributions) {

contributions.href = `/wiki/Special:Contributions/${ip}`;

}

const globalContributions = document.querySelector('#t-global-contributions a');

if (globalContributions) {

globalContributions.href = `/wiki/Special:GlobalContributions/${ip}`;

}

const blockUser = document.querySelector('#t-blockip a');

if (blockUser) {

blockUser.href = `/wiki/Special:Block/${ip}`;

}

let userTalk;

let userTalkMethod;

if (ip.version === 4 && ip.mask >= 24) {

userTalk = ip.enumerate().map(ipString => `User talk:${ipString}`);

userTalkMethod = "enumerate";

} else {

userTalk = await getUserTalkPages(ip);

userTalkMethod = "contributions";

if (!userTalk.length) {

const resultMessage = document.createElement('p');

resultMessage.style.color = 'var(--color-notice, gray)';

resultMessage.textContent = 'No user talk pages found for recent contributions from this IP range.';

contentContainer.appendChild(resultMessage);

return;

}

}

const infoResponses = await Promise.all(

batch(userTalk, 50).map(titles => api.get({

action: 'query',

titles: titles.join('|'),

prop: 'info|revisions',

format: 'json',

formatversion: 2

}))

);

const pages = infoResponses

.flatMap(response => response.query.pages)

.filter(page => !page.missing && page.revisions && page.revisions.length > 0)

.map(page => ({

title: page.title,

timestamp: page.revisions[0].timestamp,

redirect: !!page.redirect

}))

.sort((a, b) => b.timestamp.localeCompare(a.timestamp));

if (!pages.length) {

const resultMessage = document.createElement('p');

if (userTalkMethod === "enumerate") {

resultMessage.style.color = 'var(--color-notice, gray)';

resultMessage.textContent = 'No user talk pages found.';

} else {

resultMessage.style.color = 'var(--color-error, red)';

resultMessage.textContent = 'An error occurred while retrieving timestamps for user talk pages in this IP range.';

}

contentContainer.appendChild(resultMessage);

return;

}

const parseTasks = [];

for (const page of pages) {

const ip = page.title.replace(/^User talk:/, '');

const relativeTime = `${timeAgo(page.timestamp)} ago`;

const headerText = `== ${relativeTime}: ${ip} (talk) ==`;

const inclusionText = `{{${page.title}}}`;

parseTasks.push({ text: headerText, disableeditsection: true, });

parseTasks.push({ text: inclusionText, disableeditsection: false, });

}

const parsePromises = parseTasks.map(task =>

api.post({

action: 'parse',

format: 'json',

prop: 'text',

contentmodel: 'wikitext',

title: `Special:BlankPage/RangeTalk/${ip}`,

text: task.text,

disableeditsection: task.disableeditsection,

})

);

for (const promise of parsePromises) {

const result = await promise;

const html = result.parse.text['*'];

const fragment = document.createRange().createContextualFragment(html);

contentContainer.appendChild(fragment);

}

mw.hook('wikipage.content').fire($(contentContainer));

const twinkleElementsToRemove = [

'.vector-menu-content-list #tw-block',

'.vector-menu-content-list #tw-rpp',

'.vector-menu-content-list #tw-unlink',

'.vector-menu-content-list #tw-warn',

'.vector-menu-content-list #twinkle-talkback',

'.vector-menu-content-list #twinkle-welcome',

];

for (const selector of twinkleElementsToRemove) {

document.querySelector(selector)?.remove();

}

}

// standalone range calculator

function displayRangeCalculator() {

document.title = 'Range calculator';

const heading = document.querySelector('#firstHeading');

if (heading) {

heading.textContent = 'Range calculator';

}

const contentContainer = document.querySelector('#mw-content-text');

if (!contentContainer) return;

contentContainer.innerHTML = '';

const wrapper = document.createElement('div');

wrapper.innerHTML = `

Calculate the smallest range that encompasses a given list of IP addresses.

Enter IP addresses (one per line or space-separated)

`;

contentContainer.appendChild(wrapper);

document.getElementById('range-calculate').addEventListener('click', event => {

event.preventDefault();

let results = document.getElementById('range-display');

if (!results) {

results = createRangeDisplay();

wrapper.appendChild(results);

}

const input = document.getElementById('range-input').value;

const ipRegex = /\b(?:\d{1,3}(?:\.\d{1,3}){3})\b|\b(?:[\dA-Fa-f]{1,4}:){4,}[\dA-Fa-f:]+/g;

const ips = [];

input.matchAll(ipRegex)

.map(match => IPAddress.from(match[0]))

.filter(Boolean)

.forEach(ip => ipListAdd(ips, ip));

results.innerHTML = computeCommonRange(ips);

});

}

// select IPs to compute common IP range

function startRangeSelection() {

function updateRangeDisplay() {

if (!selectedIPs.length) {

display.innerHTML = 'No IPs selected.';

} else {

display.innerHTML = computeCommonRange(selectedIPs);

}

}

if (document.getElementById('range-display')) return;

const selectedIPs = [];

const display = createRangeDisplay();

updateRangeDisplay();

document.querySelector('#mw-content-text')?.prepend(display);

document.querySelectorAll('a.mw-anonuserlink').forEach(link => {

const checkbox = document.createElement('input');

checkbox.type = 'checkbox';

checkbox.style.marginLeft = '0.5em';

checkbox.addEventListener('change', () => {

const ipText = link.textContent.trim();

const ip = IPAddress.from(ipText);

if (!ip) return;

if (checkbox.checked) {

ipListAdd(selectedIPs, ip);

} else {

ipListRemove(selectedIPs, ip);

}

updateRangeDisplay();

});

link.parentNode?.insertBefore(checkbox, link.nextSibling);

});

}

// generate styled div for IP range calculation results

function createRangeDisplay() {

const display = document.createElement('div');

display.id = 'range-display';

display.style.fontWeight = 'bold';

display.style.border = '1px solid var(--border-color-base, #a2a9b1)';

display.style.borderRadius = '2px';

display.style.padding = '16px';

display.style.fontSize = '1rem';

display.style.margin = '1em 0';

return display;

}

// compute common IP range for IP list

function computeCommonRange(ips) {

if (!ips.length) {

return 'No valid IPs found.';

}

const firstVersion = ips[0].version;

if (!ips.every(ip => ip.version === firstVersion)) {

return 'Mixed IPv4 and IPv6 addresses are not supported.';

}

const masks = firstVersion === 4 ? sequence(16, 32) : sequence(19, 64);

const bestMask = masks.findLast(m => {

const base = ips[0].masked(m);

return ips.every(ip => ip.masked(m).equals(base));

});

if (!bestMask) {

return 'No common range found.';

}

const resultRange = ips[0].masked(bestMask);

const contribsLink = `${resultRange.toString(false, true)}`;

const blockLink = `block`;

return `${ips.length} unique IP${ips.length === 1 ? '' : 's'}: ${contribsLink} (${blockLink})`;

}

// query API for blocks

async function getBlockLogs(api, range) {

const response = await api.get({

action: 'query',

list: 'logevents',

letype: 'block',

letitle: `User:${range}`,

format: 'json'

});

return response.query.logevents.map(event => ({

logid: event.logid,

timestamp: event.timestamp,

user: event.user,

action: event.action,

comment: event.comment || '',

params: event.params || {},

url: mw.util.getUrl('Special:Log', { logid: event.logid }),

range: range

}));

}

// generate HTML for a block log entry

async function formatBlockEntry(block) {

function textList(items) {

if (!items || items.length === 0) return '';

if (items.length === 1) return items[0];

if (items.length === 2) return `${items[0]} and ${items[1]}`;

return `${items.slice(0, -1).join(', ')}, and ${items[items.length - 1]}`;

}

function translateFlags(flags) {

const flagMap = {

'anononly': 'anon. only',

'nocreate': 'account creation blocked',

'nousertalk': 'cannot edit own talk page',

};

return flags.map(flag => flagMap[flag] || flag).join(', ');

}

const formattedTimestamp = formatTimeAndDate(new Date(block.timestamp));

const logLink = `${formattedTimestamp}`;

const userLink = `${block.user}`;

const userTools = `(talk | contribs)`;

const action = block.action === "reblock" ? "changed block settings for" : `${block.action}ed`;

const ipLink = `${block.range.toString(false, true)}`;

let restrictions = '';

if (block.params?.restrictions) {

const pages = block.params.restrictions?.pages || [];

const namespaces = block.params.restrictions?.namespaces || [];

const pageLinks = pages.map(page =>

`${page.page_title}`

);

const nsLinks = namespaces.map(ns => {

const prefix = mw.config.get('wgFormattedNamespaces')[ns];

const display = ns === 0 ? 'Article' : (prefix || `${ns}`);

return `(${display})`;

});

const firstWord = block.action === "reblock" ? "blocking" : "from";

const pageText = pageLinks.length ? ` ${firstWord} the page${pages.length === 1 ? : 's'} ${textList(pageLinks)} ` : ;

const nsText = nsLinks.length ? ` ${firstWord} the namespace${namespaces.length === 1 ? : 's'} ${textList(nsLinks)} ` : ;

if (pageText && nsText) {

restrictions = `${pageText}and${nsText}`;

} else {

restrictions = pageText || nsText;

}

}

let expiryTime = '';

if (block.action !== "unblock") {

let expiryTimeStr = block.params?.duration;

if (!expiryTimeStr || ['infinite', 'indefinite', 'infinity'].includes(expiryTimeStr)) {

expiryTimeStr = 'indefinite';

} else if (!isNaN(Date.parse(expiryTimeStr))) {

const expiryDate = new Date(expiryTimeStr);

expiryTimeStr = formatTimeAndDate(expiryDate);

}

expiryTime = ` with an expiration time of ${expiryTimeStr}`;

}

const translatedFlags = block.params?.flags && block.params.flags.length ? ` (${translateFlags(block.params.flags)})` : '';

const comment = block.comment ? ` (${await wikitextToHTML(block.comment)})` : '';

const actionLinks = `(unblock | change block)`;

return `${logLink} ${userLink} ${userTools} ${action} ${ipLink}${restrictions}${expiryTime}${translatedFlags}${comment} ${actionLinks}`;

}

async function wikitextToHTML(wikitext) {

if (wikitextCache.has(wikitext)) {

return wikitextCache.get(wikitext);

}

try {

wikitext = wikitext.replace(/{{/g, '\\{\\{').replace(/}}/g, '\\}\\}');

const response = await api.post({

action: 'parse',

disableeditsection: true,

prop: 'text',

format: 'json',

text: wikitext

});

if (response.parse && response.parse.text) {

const pattern = new RegExp('^.*?

(.*)<\/p>.*$', 's');

const html = response.parse.text['*']

.replace(pattern, '$1')

.replace(/\\{\\{/g, '{{')

.replace(/\\}\\}/g, '}}')

.trim();

wikitextCache.set(wikitext, html);

return html;

}

} catch (error) {

console.error('Error converting wikitext to HTML:', error);

}

return wikitext;

}

function batch(items, maxSize) {

const minBins = Math.ceil(items.length / maxSize);

const bins = Array.from({length: minBins}, () => []);

items.forEach((item, i) => {

bins[i % minBins].push(item);

});

return bins;

}

function sequence(n, m, step = 1) {

let r = [];

for (let i = n; i <= m; i += step) r.push(i);

return r;

}

function ipListAdd(ipList, ip) {

if (!ipList.some(i => i.equals(ip))) ipList.push(ip);

}

function ipListRemove(ipList, ip) {

const index = ipList.findIndex(i => i.equals(ip));

if (index !== -1) ipList.splice(index, 1);

}

function commonPrefix(a, b) {

let i = 0;

while (i < a.length && i < b.length && a[i] === b[i]) {

i++;

}

return a.slice(0, i);

}

});