User:Daniel Quinlan/Scripts/RangeHelper.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | This user script seems to have a documentation page at User:Daniel Quinlan/Scripts/RangeHelper. |
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 <a href="/wiki/Special:Contributions/${ip}">${ip}</a>...`;
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 = '<span style="color:red;">No blocks found.</span>';
} else {
statusMessage.innerHTML = `Range blocks for <a href="/wiki/Special:Contributions/${ip}">${ip}</a>`;
}
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}: [[Special:Contributions/${ip}|${ip}]] ([[${page.title}|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 = `
<p>Calculate the smallest range that encompasses a given list of IP addresses.</p>
<fieldset>
<legend>Enter IP addresses (one per line or space-separated)</legend>
<textarea id="range-input" rows="10" style="width: 100%"></textarea>
</fieldset>
<div style="margin-top:10px;">
<button id="range-calculate">Calculate Range</button>
</div>
`;
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 '<span style="color:red;">No valid IPs found.</span>';
}
const firstVersion = ips[0].version;
if (!ips.every(ip => ip.version === firstVersion)) {
return '<span style="color:red;">Mixed IPv4 and IPv6 addresses are not supported.</span>';
}
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 '<span style="color:red;">No common range found.</span>';
}
const resultRange = ips[0].masked(bestMask);
const contribsLink = `<a href="/wiki/Special:Contributions/${resultRange}" target="_blank">${resultRange.toString(false, true)}</a>`;
const blockLink = `<a href="/wiki/Special:Block/${resultRange}" target="_blank">block</a>`;
return `<span>${ips.length} unique IP${ips.length === 1 ? '' : 's'}: ${contribsLink} (${blockLink})</span>`;
}
// 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 = `<a href="/w/index.php?title=Special:Log&logid=${block.logid}" title="Special:Log">${formattedTimestamp}</a>`;
const userLink = `<a href="/wiki/User:${block.user}" title="User:${block.user}"><bdi>${block.user}</bdi></a>`;
const userTools = `(<a href="/wiki/User_talk:${block.user}" title="User talk:${block.user}">talk</a> | <span><a href="/wiki/Special:Contributions/${block.user}" title="Special:Contributions/${block.user}">contribs</a>)`;
const action = block.action === "reblock" ? "changed block settings for" : `${block.action}ed`;
const ipLink = `<a href="/wiki/Special:Contributions/${block.range}" title=""><bdi>${block.range.toString(false, true)}</bdi></a>`;
let restrictions = '';
if (block.params?.restrictions) {
const pages = block.params.restrictions?.pages || [];
const namespaces = block.params.restrictions?.namespaces || [];
const pageLinks = pages.map(page =>
`<a href="/wiki/${page.page_title}" title="${page.page_title}">${page.page_title}</a>`
);
const nsLinks = namespaces.map(ns => {
const prefix = mw.config.get('wgFormattedNamespaces')[ns];
const display = ns === 0 ? 'Article' : (prefix || `${ns}`);
return `<a href="/w/index.php?title=Special:AllPages&namespace=${ns}" title="Special:AllPages">(${display})</a>`;
});
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 <span class="blockExpiry" title="${block.params?.duration || 'indefinite'}">${expiryTimeStr}</span>`;
}
const translatedFlags = block.params?.flags && block.params.flags.length ? ` (${translateFlags(block.params.flags)})` : '';
const comment = block.comment ? ` <span class="comment" style="font-style: italic;">(${await wikitextToHTML(block.comment)})</span>` : '';
const actionLinks = `(<a href="/wiki/Special:Unblock/${block.range}" title="Special:Unblock/${block.range}">unblock</a> | <a href="/wiki/Special:Block/${block.range}" title="Special:Block/${block.range}">change block</a>)`;
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>(.*)<\/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);
}
});