Jump to content

User:Daniel Quinlan/Scripts/RangeHelper.js

From Wikipedia, the free encyclopedia
The printable version is no longer supported and may have rendering errors. Please update your browser bookmarks and please use the default browser print function instead.
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
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);
	}
});