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. |
mw.loader.using(['mediawiki.api', 'mediawiki.util', 'mediawiki.DateFormatter']).then(function() {
// state variables
const wikitextCache = new Map();
let api = null;
let formatTimeAndDate = null;
// activate on relevant special pages
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 = extractIP(userName);
if (ip) {
addContributionsLinks(ip, userName.includes('/'));
}
} else if (specialPage === 'Blankpage') {
const match = pageName.match(/^Special:BlankPage\/(\w+)(?:\/(.+))?$/);
if (match) {
if (match[1] === 'RangeBlocks') {
const ip = extractIP(match[2] || '');
if (ip) {
displayRangeBlocks(ip);
}
} else if (match[1] === 'RangeCalculator') {
displayRangeCalculator();
}
}
} else if (pageName === 'Special:Log/block') {
const pageParam = mw.util.getParamValue('page');
if (pageParam) {
const match = pageParam.match(/^User:(.+)$/);
if (match) {
const ip = extractIP(match[1]);
if (ip) {
let rangeIP = maskedIP(ip, ip.mask, false).toUpperCase();
if (!pageParam.includes('/')) {
rangeIP = rangeIP.split('/')[0];
}
mw.util.addPortletLink('p-tb', `/wiki/Special:BlankPage/RangeBlocks/${rangeIP}`, "Find range blocks");
}
}
}
}
return;
// adds links to user tools
function addContributionsLinks(ip, isRange) {
const userToolsContainer = document.querySelector('.mw-contributions-user-tools .mw-changeslist-links');
if (!userToolsContainer) return;
const blockLogLink = userToolsContainer.querySelector('.mw-contributions-link-block-log');
if (blockLogLink) {
const rangeLogLink = document.createElement('a');
let rangeIP = maskedIP(ip, ip.mask, false).toUpperCase();
if (!isRange) {
rangeIP = rangeIP.split('/')[0];
}
const rangeLogPage = `Special:BlankPage/RangeBlocks/${rangeIP}`;
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;
let floor = 16, ceiling = 24, steps = 8;
if (ip.version === 6) {
floor = 32;
ceiling = 64;
if (ip.mask >= 64)
steps = 16;
}
for (let mask = floor; mask <= ceiling && mask < ip.mask; mask += steps) {
const contribsLink = document.createElement('a');
const contribsIP = maskedIP(ip, mask, false).toUpperCase();
contribsLink.href = `/wiki/Special:Contributions/${contribsIP}`;
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 !== (ip.version === 6 ? 128 : 32)) {
mw.util.addPortletLink('p-tb', '/wiki/Special:BlankPage/RangeCalculator', 'Range calculator');
mw.util.addPortletLink('p-tb', '#', 'Range selector')
.addEventListener('click', event => {
event.preventDefault();
startRangeSelection();
});
}
}
// 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 === 6 ? sequence(19, 64) : sequence(16, 32);
const bestMask = masks.findLast(m => {
const base = maskedIP(ips[0], m);
return ips.every(ip => maskedIP(ip, m) === base);
});
if (!bestMask) {
return '<span style="color:red;">No common range found.</span>';
}
const resultRange = maskedIP(ips[0], bestMask);
const contribsLink = `<a href="/wiki/Special:Contributions/${resultRange}" target="_blank">${resultRange}</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>`;
}
// 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 matches = [...input.matchAll(ipRegex)].map(m => m[0]);
const ips = [];
for (const match of matches) {
const ip = extractIP(match);
if (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 = extractIP(ipText);
if (!ip) return;
if (checkbox.checked) {
ipListAdd(selectedIPs, ip);
} else {
ipListRemove(selectedIPs, ip);
}
updateRangeDisplay();
});
link.parentNode?.insertBefore(checkbox, link.nextSibling);
});
}
// find range blocks
async function displayRangeBlocks(ip) {
api = new mw.Api();
formatTimeAndDate = mw.loader.require('mediawiki.DateFormatter').formatTimeAndDate;
const rangeIP = maskedIP(ip, ip.mask, false).toUpperCase();
document.title = `Range blocks for ${rangeIP}`;
const heading = document.querySelector('#firstHeading');
if (heading) {
heading.innerHTML = `Range blocks for ${rangeIP}`;
}
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/${rangeIP}">${rangeIP}</a>...`;
contentContainer.appendChild(statusMessage);
const resultsList = document.createElement('ul');
contentContainer.appendChild(resultsList);
const masks = ip.version === 6 ? sequence(19, 64) : sequence(16, 31);
if (!masks.includes(ip.mask)) {
masks.push(ip.mask);
}
const blocks = [];
const blockPromises = masks.map(mask => {
const range = maskedIP(ip, mask);
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/${rangeIP}">${rangeIP}</a>`;
}
mw.hook('wikipage.content').fire($(contentContainer));
}
// 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,
}));
}
// extract IP address
function extractIP(userName) {
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(userName) || IPV6REGEX.exec(userName);
if (match) {
const version = match[1].includes(':') ? 6 : 4;
const ip = match[1];
const mask = parseInt(match[2] || (version === 6 ? '128' : '32'), 10);
return { version, ip, mask };
}
return null;
}
// generate sequence of numbers
function sequence(n, m, step = 1) {
for (var i = n, r = []; i <= m; i += step) r.push(i);
return r;
}
// add IP to array
function ipListAdd(ipList, ip) {
if (!ipList.some(i => i.ip === ip.ip && i.mask === ip.mask && i.version === ip.version)) {
ipList.push(ip);
}
}
// remove IP from array
function ipListRemove(ipList, ip) {
const index = ipList.findIndex(i => i.ip === ip.ip && i.mask === ip.mask && i.version === ip.version);
if (index !== -1) {
ipList.splice(index, 1);
}
}
// convert full IPv6 address to BigInt
function ipv6ToBigInt(ipv6) {
const segments = ipv6.split(':');
let bigIntValue = 0n;
const expanded = expandIPv6(segments);
expanded.forEach(segment => {
bigIntValue = (bigIntValue << 16n) + BigInt(parseInt(segment, 16));
});
return bigIntValue;
}
// expand shorthand IPv6 (e.g., '::1' to '0:0:0:0:0:0:0:1')
function 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'));
}
// apply mask to BigInt for IPv6
function applyMask(bigIntValue, prefixLength) {
const maskBits = 128 - prefixLength;
const mask = (1n << BigInt(128 - maskBits)) - 1n;
return bigIntValue & (mask << BigInt(maskBits));
}
// convert BigInt back to IPv6 string
function bigIntToIPv6(bigIntValue, prefixLength = 128, compress = true) {
function compressIPv6(ipv6) {
const zeroBlocks = ipv6.match(/\b0(?:\:0)+\b/g);
if (!zeroBlocks) return ipv6;
const longestZeroBlock = zeroBlocks.reduce((longest, current) => {
return current.length > longest.length ? current : longest;
}, "");
if (longestZeroBlock) {
return ipv6.replace(new RegExp(`:?\\b${longestZeroBlock}\\b:?`), '::');
}
return ipv6;
}
const segments = [];
for (let i = 0; i < 8; i++) {
const segment = (bigIntValue >> BigInt((7 - i) * 16)) & 0xffffn;
segments.push(segment.toString(16));
}
let ipv6 = segments.join(':')
if (compress) {
ipv6 = compressIPv6(ipv6);
}
return ipv6 + (prefixLength < 128 ? `/${prefixLength}` : '');
}
// generate masked IP range
function maskedIP(ip, prefixLength, compress = true) {
return ip.version === 6 ? maskedIPv6(ip.ip, prefixLength, compress) : maskedIPv4(ip.ip, prefixLength);
}
// generate masked IPv6 range
function maskedIPv6(ipv6, prefixLength, compress = true) {
const bigIntValue = ipv6ToBigInt(ipv6);
const shift = 128 - prefixLength;
const mask = (1n << BigInt(128 - shift)) - 1n;
const masked = bigIntValue & (mask << BigInt(shift));
return bigIntToIPv6(masked, prefixLength, compress);
}
// generate masked IPv4 range
function maskedIPv4(ipv4, prefixLength) {
const octets = ipv4.split('.').map(Number);
const intValue = (octets[0] << 24) | (octets[1] << 16) | (octets[2] << 8) | octets[3];
const mask = (1 << (32 - prefixLength)) - 1;
const masked = intValue & ~mask;
return [
(masked >>> 24) & 0xff,
(masked >>> 16) & 0xff,
(masked >>> 8) & 0xff,
masked & 0xff
].join('.') + `/${prefixLength}`;
}
// 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}</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}`;
}
// convert wikitext to HTML
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;
}
});