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.
`;
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 =>
);
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);
}
});