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
});
}
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;
}
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.innerHTML = `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) {
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 = [
'.noarticletext',
'#mw-content-subtitle .subpages',
'#t-whatlinkshere',
'#t-log'
];
for (const selector of elementsToRemove) {
document.querySelector(selector)?.remove();
}
const contributions = document.querySelector('#t-contributions a');
if (contributions) {
contributions.href = `/wiki/Special:Contributions/${ip}`;
}
const blockUser = document.querySelector('#t-blockip a');
if (blockUser) {
blockUser.href = `/wiki/Special:Block/${ip}`;
}
const response = await api.get({
action: 'query',
list: 'usercontribs',
uciprange: `${ip}`,
uclimit: 100,
format: 'json'
});
const ips = [];
for (const contrib of response.query.usercontribs) {
const contribIP = IPAddress.from(contrib.user);
if (contribIP) {
ipListAdd(ips, contribIP);
}
}
if (!ips.length) {
const noContributionsMessage = document.createElement('p');
noContributionsMessage.innerHTML = '<span style="color:red;">No contributions found for this IP range.</span>';
contentContainer.appendChild(noContributionsMessage);
return;
}
const titles = ips.slice(0, 50).map(ip => `User talk:${ip}`);
const infoResponse = await api.get({
action: 'query',
titles: titles.join('|'),
prop: 'info',
format: 'json',
formatversion: 2
});
const pages = infoResponse.query.pages
.filter(page => !page.missing)
.map(page => ({
title: page.title,
touched: page.touched,
redirect: !!page.redirect
}))
.sort((a, b) => b.touched.localeCompare(a.touched));
if (!pages.length) {
const noTalkPagesMessage = document.createElement('p');
noTalkPagesMessage.innerHTML = '<span style="color:red;">No user talk pages found for this IP range.</span>';
contentContainer.appendChild(noTalkPagesMessage);
return;
}
const parseTasks = [];
for (const page of pages) {
const ip = page.title.replace(/^User talk:/, '');
const relativeTime = `${timeAgo(page.touched)} 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));
}
// standalone range calculator
function displayRangeCalculator() {
document.title = 'Range calculator';
const heading = document.querySelector('#firstHeading');
if (heading) {
heading.innerHTML = '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 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);
}
});