Jump to content

User:Andrybak/Scripts/Contribs ranger.js

From Wikipedia, the free encyclopedia
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.
/*
 * This user script helps linking to a limited set of a user's contributions or logged actions on a wiki.
 */

/* global mw */

(function() {
	'use strict';

	const USERSCRIPT_NAME = 'Contribs ranger';
	const VERSION = 5;
	const LOG_PREFIX = `[${USERSCRIPT_NAME} v${VERSION}]:`;

	function error(...toLog) {
		console.error(LOG_PREFIX, ...toLog);
	}

	function warn(...toLog) {
		console.warn(LOG_PREFIX, ...toLog);
	}

	function info(...toLog) {
		console.info(LOG_PREFIX, ...toLog);
	}

	function debug(...toLog) {
		console.debug(LOG_PREFIX, ...toLog);
	}

	function notify(notificationMessage) {
		mw.notify(notificationMessage, {
			title: USERSCRIPT_NAME
		});
	}

	function errorAndNotify(errorMessage, rejection) {
		error(errorMessage, rejection);
		notify(errorMessage);
	}

	/*
	 * Removes separators and timezone from a timestamp formatted in ISO 8601.
	 * Example:
	 *    "2008-07-17T11:48:39Z" -> "20080717114839"
	 */
	function convertIsoTimestamp(isoTimestamp) {
		return isoTimestamp.slice(0, 4) + isoTimestamp.slice(5, 7) + isoTimestamp.slice(8, 10) +
			isoTimestamp.slice(11, 13) + isoTimestamp.slice(14, 16) + isoTimestamp.slice(17, 19);
	}

	/*
	 * Two groups of radio buttons are used:
	 *   - contribsRangerRadioGroup0
	 *   - contribsRangerRadioGroup1
	 * Left column of radio buttons defines endpoint A.
	 * Right column -- endpoint B.
	 */
	const RADIO_BUTTON_GROUP_NAME_PREFIX = 'contribsRangerRadioGroup';
	const RADIO_BUTTON_GROUP_A_NAME = RADIO_BUTTON_GROUP_NAME_PREFIX + '0';
	const RADIO_BUTTON_GROUP_B_NAME = RADIO_BUTTON_GROUP_NAME_PREFIX + '1';
	let rangeHolderSingleton = null;
	const UI_OUTPUT_LINK_ID = 'contribsRangerOutputLink';
	const UI_OUTPUT_COUNTER_ID = 'contribsRangerOutputCounter';
	const UI_OUTPUT_WIKITEXT = 'contribsRangerOutputWikitext';

	class ContribsRangeHolder {
		// indexes of selected radio buttons, which are enumerated from zero
		#indexA;
		#indexB;
		// revisionIds for the contribs at endpoints
		#revisionIdA;
		#revisionIdB;
		// titles of pages edited by contribs at endpoints
		#titleA;
		#titleB;

		static getInstance() {
			if (rangeHolderSingleton === null) {
				rangeHolderSingleton = new ContribsRangeHolder();
			}
			return rangeHolderSingleton;
		}

		updateEndpoints(radioButton) {
			const index = radioButton.value;
			const revisionId = parseInt(radioButton.parentNode.dataset.mwRevid);
			const permalink = radioButton.parentElement.querySelector('.mw-changeslist-date');
			if (!permalink) {
				errorAndNotify("Cannot find permalink for the selected radio button");
				return;
			}
			const permalinkUrlStr = permalink.href;
			if (!permalinkUrlStr) {
				errorAndNotify("Cannot access the revision for the selected radio button");
				return;
			}
			const permalinkUrl = new URL(permalinkUrlStr);
			const title = permalinkUrl.searchParams.get('title');
			// debug('ContribsRangeHolder.updateEndpoints', title);
			if (radioButton.name === RADIO_BUTTON_GROUP_A_NAME) {
				this.setEndpointA(index, revisionId, title);
			} else if (radioButton.name === RADIO_BUTTON_GROUP_B_NAME) {
				this.setEndpointB(index, revisionId, title);
			}
		}

		setEndpointA(index, revisionId, title) {
			this.#indexA = index;
			this.#revisionIdA = revisionId;
			this.#titleA = title;
		}

		setEndpointB(index, revisionId, title) {
			this.#indexB = index;
			this.#revisionIdB = revisionId;
			this.#titleB = title;
		}

		getSize() {
			return Math.abs(this.#indexA - this.#indexB) + 1;
		}

		getNewestRevisionId() {
			return Math.max(this.#revisionIdA, this.#revisionIdB);
		}

		getNewestTitle() {
			if (this.#revisionIdA > this.#revisionIdB) {
				return this.#titleA;
			} else {
				return this.#titleB;
			}
		}

		async getNewestIsoTimestamp() {
			const revisionId = this.getNewestRevisionId();
			const title = this.getNewestTitle();
			return this.getIsoTimestamp(revisionId, title);
		}

		#cachedIsoTimestamps = {};

		async getIsoTimestamp(revisionId, title) {
			if (revisionId in this.#cachedIsoTimestamps) {
				return Promise.resolve(this.#cachedIsoTimestamps[revisionId]);
			}
			return new Promise((resolve, reject) => {
				const api = new mw.Api();
				// https://wiki.riteme.site/w/api.php?action=help&modules=query%2Brevisions
				const queryParams = {
					action: 'query',
					prop: 'revisions',
					rvprop: 'ids|user|timestamp',
					rvslots: 'main',
					formatversion: 2, // v2 has nicer field names in responses

					/*
					 * Class ContribsRangeHolder doesn't need conversion via decodeURIComponent, because
					 * the titles are gotten through URLSearchParams, which does the decoding for us.
					 */
					titles: title,
					rvstartid: revisionId,
					rvendid: revisionId,
				};
				api.get(queryParams).then(
					response => {
						// debug('Q:', queryParams);
						// debug('R:', response);
						const isoTimestamp = response?.query?.pages[0]?.revisions[0]?.timestamp;
						if (!isoTimestamp) {
							reject(`Cannot get timestamp for revision ${revisionId} of ${title}.`);
							return;
						}
						this.#cachedIsoTimestamps[revisionId] = isoTimestamp;
						resolve(isoTimestamp);
					},
					rejection => {
						reject(rejection);
					}
				);
			});
		}
	}

	/*
	 * Extracts a relevant page's title from a link, which appears
	 * in entries on [[Special:Log]].
	 */
	function getLoggedActionTitle(url, pageLink) {
		const maybeParam = url.searchParams.get('title');
		if (maybeParam) {
			return maybeParam;
		}
		if (pageLink.classList.contains('mw-anonuserlink')) {
			/*
			 * Prefix 'User:' works in API queries regardless of localization
			 * of the User namespace.
			 * Example: https://ru.wikipedia.org/w/api.php?action=query&list=logevents&leuser=Deinocheirus&letitle=User:2A02:908:1A12:FD40:0:0:0:837A
			 */
			return 'User:' + url.pathname.replaceAll(/^.*\/([^\/]+)$/g, '$1');
		}
		return url.pathname.slice(6); // cut off `/wiki/`
	}

	let logRangeHolderSingleton = null;

	class LogRangeHolder {
		// indexes of selected radio buttons, which are enumerated from zero
		#indexA;
		#indexB;
		// logIds for the contribs at endpoints
		#logIdA;
		#logIdB;
		// titles of pages edited by contribs at endpoints
		#titleA;
		#titleB;

		static getInstance() {
			if (logRangeHolderSingleton === null) {
				logRangeHolderSingleton = new LogRangeHolder();
			}
			return logRangeHolderSingleton;
		}

		updateEndpoints(radioButton) {
			const index = radioButton.value;
			const logId = parseInt(radioButton.parentNode.dataset.mwLogid);
			let pageLink = radioButton.parentElement.querySelector('.mw-usertoollinks + a');
			if (!pageLink) {
				errorAndNotify("Cannot find pageLink for the selected radio button");
				return;
			}
			/*
			 * This is a very weird way to check this, but whatever.
			 * Example:
			 *   https://wiki.riteme.site/w/index.php?title=Special:Log&logid=162280736
			 * when viewed in a log, like this:
			 *   https://wiki.riteme.site/wiki/Special:Log?type=protect&user=Izno&page=&wpdate=&tagfilter=&wpfilters%5B%5D=newusers&wpFormIdentifier=logeventslist&limit=4&offset=20240526233513001
			 */
			if (pageLink.nextElementSibling?.nextElementSibling?.className === "comment") {
				// two pages are linked in the logged action, we are interested in the second page
				pageLink = pageLink.nextElementSibling;
			}
			const pageUrlStr = pageLink.href;
			if (!pageUrlStr) {
				errorAndNotify("Cannot access the logged action for the selected radio button");
				return;
			}
			const pageUrl = new URL(pageUrlStr);
			const title = getLoggedActionTitle(pageUrl, pageLink);
			// debug('LogRangeHolder.updateEndpoints:', radioButton, pageUrlStr, pageUrl, title, logId);
			if (radioButton.name === RADIO_BUTTON_GROUP_A_NAME) {
				this.setEndpointA(index, logId, title);
			} else if (radioButton.name === RADIO_BUTTON_GROUP_B_NAME) {
				this.setEndpointB(index, logId, title);
			}
		}

		setEndpointA(index, logId, title) {
			this.#indexA = index;
			this.#logIdA = logId;
			this.#titleA = title;
		}

		setEndpointB(index, logId, title) {
			this.#indexB = index;
			this.#logIdB = logId;
			this.#titleB = title;
		}

		getSize() {
			return Math.abs(this.#indexA - this.#indexB) + 1;
		}

		getNewestLogId() {
			return Math.max(this.#logIdA, this.#logIdB);
		}

		getNewestTitle() {
			if (this.#logIdA > this.#logIdB) {
				return this.#titleA;
			} else {
				return this.#titleB;
			}
		}

		async getNewestIsoTimestamp() {
			const logId = this.getNewestLogId();
			const title = this.getNewestTitle();
			return this.getIsoTimestamp(logId, title);
		}

		#cachedIsoTimestamps = {};

		async getIsoTimestamp(logId, title) {
			if (title in this.#cachedIsoTimestamps) {
				return Promise.resolve(this.#cachedIsoTimestamps[title]);
			}
			return new Promise((resolve, reject) => {
				const api = new mw.Api();
				// https://wiki.riteme.site/w/api.php?action=help&modules=query%2Blogevents
				const queryParams = {
					action: 'query',
					list: 'logevents',
					lelimit: 500,

					leuser: document.getElementById('mw-input-user').querySelector('input').value,
					/*
					 * Decoding is needed to fix `invalidtitle`:
					 *   'Wikipedia:Bureaucrats%27_noticeboard' -> "Wikipedia:Bureaucrats'_noticeboard"
					 */
					letitle: decodeURIComponent(title),
				};
				api.get(queryParams).then(
					response => {
						// debug('Q:', queryParams, logId);
						// debug('R:', response);
						const isoTimestamp = response.query?.logevents?.find(logevent => logevent.logid === logId)?.timestamp;
						if (!isoTimestamp) {
							reject(`Cannot get timestamp for logged action ${logId} of ${title}.`);
							return;
						}
						this.#cachedIsoTimestamps[title] = isoTimestamp;
						resolve(isoTimestamp);
					},
					rejection => {
						reject(rejection);
					}
				);
			});
		}
	}

	let historyRangeHolderSingleton = null;

	class HistoryRangeHolder {
		// indexes of selected radio buttons, which are enumerated from zero
		#indexA;
		#indexB;
		// revisionIds for the edits at endpoints
		#revisionIdA;
		#revisionIdB;
		// the title
		#title;

		static getInstance() {
			if (historyRangeHolderSingleton === null) {
				historyRangeHolderSingleton = new HistoryRangeHolder();
			}
			return historyRangeHolderSingleton;
		}

		constructor() {
			const params = new URLSearchParams(document.location.search);
			this.#title = params.get('title');
		}

		updateEndpoints(radioButton) {
			const index = radioButton.value;
			const revisionId = parseInt(radioButton.parentNode.dataset.mwRevid);
			const permalink = radioButton.parentElement.querySelector('.mw-changeslist-date');
			if (!permalink) {
				errorAndNotify("Cannot find permalink for the selected radio button");
				return;
			}
			const permalinkUrlStr = permalink.href;
			if (!permalinkUrlStr) {
				errorAndNotify("Cannot access the revision for the selected radio button");
				return;
			}
			if (radioButton.name === RADIO_BUTTON_GROUP_A_NAME) {
				this.setEndpointA(index, revisionId);
			} else if (radioButton.name === RADIO_BUTTON_GROUP_B_NAME) {
				this.setEndpointB(index, revisionId);
			}
		}

		setEndpointA(index, revisionId) {
			this.#indexA = index;
			this.#revisionIdA = revisionId;
		}

		setEndpointB(index, revisionId) {
			this.#indexB = index;
			this.#revisionIdB = revisionId;
		}

		getSize() {
			return Math.abs(this.#indexA - this.#indexB) + 1;
		}

		getNewestRevisionId() {
			return Math.max(this.#revisionIdA, this.#revisionIdB);
		}

		async getNewestIsoTimestamp() {
			const revisionId = this.getNewestRevisionId();
			return this.getIsoTimestamp(revisionId);
		}

		#cachedIsoTimestamps = {};

		async getIsoTimestamp(revisionId) {
			if (revisionId in this.#cachedIsoTimestamps) {
				return Promise.resolve(this.#cachedIsoTimestamps[revisionId]);
			}
			return new Promise((resolve, reject) => {
				const api = new mw.Api();
				// https://wiki.riteme.site/w/api.php?action=help&modules=query%2Brevisions
				const queryParams = {
					action: 'query',
					prop: 'revisions',
					rvprop: 'ids|user|timestamp',
					rvslots: 'main',
					formatversion: 2, // v2 has nicer field names in responses

					/*
					 * Class HistoryRangeHolder doesn't need conversion via decodeURIComponent, because
					 * the titles are gotten through URLSearchParams, which does the decoding for us.
					 */
					titles: this.#title,
					rvstartid: revisionId,
					rvendid: revisionId,
				};
				api.get(queryParams).then(
					response => {
						const isoTimestamp = response?.query?.pages[0]?.revisions[0]?.timestamp;
						if (!isoTimestamp) {
							reject(`Cannot get timestamp for revision ${revisionId}.`);
							return;
						}
						this.#cachedIsoTimestamps[revisionId] = isoTimestamp;
						resolve(isoTimestamp);
					},
					rejection => {
						reject(rejection);
					}
				);
			});
		}
	}

	function getUrl(limit, isoTimestamp) {
		const timestamp = convertIsoTimestamp(isoTimestamp);
		/*
		 * Append one millisecond to get the latest contrib/logged action in the range.
		 * Assuming users aren't doing more than one edit/logged action per millisecond.
		 */
		const offset = timestamp + "001";
		const url = new URL(document.location);
		url.searchParams.set('limit', limit);
		url.searchParams.set('offset', offset);
		return url.toString();
	}

	function updateRangeUrl(rangeHolder) {
		const outputLink = document.getElementById(UI_OUTPUT_LINK_ID);
		outputLink.textContent = "Loading";
		const outputCounter = document.getElementById(UI_OUTPUT_COUNTER_ID);
		outputCounter.textContent = "...";
		rangeHolder.getNewestIsoTimestamp().then(
			isoTimestamp => {
				const size = rangeHolder.getSize();
				const url = getUrl(size, isoTimestamp);
				outputLink.href = url;
				outputLink.textContent = url;
				outputCounter.textContent = size;
			},
			rejection => {
				errorAndNotify("Cannot load newest timestamp", rejection);
			}
		);
	}

	function onRadioButtonChanged(rangeHolder, event) {
		const radioButton = event.target;
		rangeHolder.updateEndpoints(radioButton);
		updateRangeUrl(rangeHolder);
	}

	function addRadioButtons(rangeHolder, listClass) {
		const RADIO_BUTTON_CLASS = 'contribsRangerRadioSelectors';
		if (document.querySelectorAll(`.${RADIO_BUTTON_CLASS}`).length > 0) {
			info('Already added input radio buttons. Skipping.');
			return;
		}
		mw.util.addCSS(`.${RADIO_BUTTON_CLASS} { margin: 0 1.75rem 0 0.25rem; }`);
		const listItems = document.querySelectorAll(`.${listClass} li`);
		const len = listItems.length;
		listItems.forEach((listItem, listItemIndex) => {
			for (let i = 0; i < 2; i++) {
				const radioButton = document.createElement('input');
				radioButton.type = 'radio';
				radioButton.name = RADIO_BUTTON_GROUP_NAME_PREFIX + i;
				radioButton.classList.add(RADIO_BUTTON_CLASS);
				radioButton.value = listItemIndex;
				radioButton.addEventListener('change', event => onRadioButtonChanged(rangeHolder, event));
				listItem.prepend(radioButton);
				// top and bottom radio buttons are selected by default
				if (listItemIndex === 0 && i === 0) {
					radioButton.checked = true;
					rangeHolder.updateEndpoints(radioButton);
				}
				if (listItemIndex === len - 1 && i === 1) {
					radioButton.checked = true;
					rangeHolder.updateEndpoints(radioButton);
				}
			}
		});
	}

	function createOutputLink() {
		const outputLink = document.createElement('a');
		outputLink.id = UI_OUTPUT_LINK_ID;
		outputLink.href = '#';
		return outputLink;
	}

	function createOutputCounter() {
		const outputLimitCounter = document.createElement('span');
		outputLimitCounter.id = UI_OUTPUT_COUNTER_ID;
		return outputLimitCounter;
	}

	function createOutputWikitextElement(actionNamePlural) {
		const outputWikitext = document.createElement('span');
		outputWikitext.style.fontFamily = 'monospace';
		outputWikitext.id = UI_OUTPUT_WIKITEXT;
		outputWikitext.appendChild(document.createTextNode("["));
		outputWikitext.appendChild(createOutputLink());
		outputWikitext.appendChild(document.createTextNode(" "));
		outputWikitext.appendChild(createOutputCounter());
		outputWikitext.appendChild(document.createTextNode(` ${actionNamePlural}]`));
		return outputWikitext;
	}

	function handleCopyEvent(copyEvent) {
		copyEvent.stopPropagation();
		copyEvent.preventDefault();
		const clipboardData = copyEvent.clipboardData || window.clipboardData;
		const wikitext = document.getElementById(UI_OUTPUT_WIKITEXT).innerText;
		clipboardData.setData('text/plain', wikitext);
		/*
		 * See file `ve.ce.MWWikitextSurface.js` in repository
		 * https://github.com/wikimedia/mediawiki-extensions-VisualEditor
		 */
		clipboardData.setData('text/x-wiki', wikitext);
		const url = document.getElementById(UI_OUTPUT_LINK_ID).href;
		const count = document.getElementById(UI_OUTPUT_COUNTER_ID).innerText;
		const htmlResult = `<a href=${url}>${count} edits</a>`;
		clipboardData.setData('text/html', htmlResult);
	}

	function createCopyButton() {
		const copyButton = document.createElement('button');
		copyButton.append("Copy");
		copyButton.onclick = (event) => {
			document.addEventListener('copy', handleCopyEvent);
			document.execCommand('copy');
			document.removeEventListener('copy', handleCopyEvent);
			notify("Copied!");
		};
		return copyButton;
	}

	function addOutputUi(rangeNamePrefix, actionNamePlural) {
		if (document.getElementById(UI_OUTPUT_LINK_ID)) {
			info('Already added output UI. Skipping.');
			return;
		}
		const ui = document.createElement('span');
		ui.appendChild(document.createTextNode(rangeNamePrefix));
		ui.appendChild(createOutputWikitextElement(actionNamePlural));
		ui.appendChild(document.createTextNode(' '));
		ui.appendChild(createCopyButton());
		mw.util.addSubtitle(ui);
	}

	function startRanger(rangeHolder, listClassName, rangeNamePrefix, actionNamePlural) {
		addRadioButtons(rangeHolder, listClassName);
		addOutputUi(rangeNamePrefix, actionNamePlural);
		// Populate the UI immediately to direct attention of the user.
		updateRangeUrl(rangeHolder);
	}

	function startContribsRanger() {
		startRanger(ContribsRangeHolder.getInstance(), 'mw-contributions-list', "Contributions range: ", "edits");
	}

	function startLogRanger() {
		startRanger(LogRangeHolder.getInstance(), 'mw-logevent-loglines', "Log range: ", "log actions");
	}

	function startHistoryRanger() {
		startRanger(HistoryRangeHolder.getInstance(), 'mw-contributions-list', "History range: ", "edits");
	}

	function onRangerType(logMessage, contribsRanger, logRanger, historyRanger, other) {
		const namespaceNumber = mw.config.get('wgNamespaceNumber');
		if (namespaceNumber === -1) {
			const canonicalSpecialPageName = mw.config.get('wgCanonicalSpecialPageName');
			if (canonicalSpecialPageName === 'Contributions') {
				return contribsRanger();
			}
			if (canonicalSpecialPageName === 'Log') {
				return logRanger();
			}
			info(`${logMessage}: special page "${canonicalSpecialPageName}" is not Contributions or Log.`);
		} else {
			const action = mw.config.get('wgAction');
			if (action === 'history') {
				return historyRanger();
			}
			info(`${logMessage}: this is a wikipage, but action '${action}' is not 'history'.`);
		}
		return other();
	}

	function startUserscript() {
		info('Starting up...');
		onRangerType(
			'startUserscript',
			startContribsRanger,
			startLogRanger,
			startHistoryRanger,
			() => error('startUserscript:', 'Cannot find which type to start')
		);
	}

	function getPortletTexts() {
		return onRangerType(
			'getPortletTexts',
			() => { return { link: "Contribs ranger", tooltip: "Select a range of contributions" }; },
			() => { return { link: "Log ranger", tooltip: "Select a range of log actions" }; },
			() => { return { link: "History ranger", tooltip: "Select a range of page history" }; },
			() => { return { link: "? ranger", tooltip: "Select a range of ?" }; }
		);
	}

	function addContribsRangerPortlet() {
		const texts = getPortletTexts();
		const linkText = texts.link;
		const portletId = 'ca-andrybakContribsSelector';
		const tooltip = texts.tooltip;
		const link = mw.util.addPortletLink('p-cactions', '#', linkText, portletId, tooltip);
		link.onclick = event => {
			event.preventDefault();
			// TODO maybe implement toggling the UI on-off
			mw.loader.using(
				['mediawiki.api'],
				startUserscript
			);
		};
	}

	function main() {
		if (mw?.config == undefined) {
			setTimeout(main, 200);
			return;
		}
		const good = onRangerType(
			'Function main',
			() => true,
			() => {
				const userValue = document.getElementById('mw-input-user')?.querySelector('input')?.value;
				const res = userValue !== null && userValue !== "";
				if (!res) {
					info('A log page, but user is not selected.');
				}
				return res;
			},
			() => true,
			() => false
		);
		if (!good) {
			info('Aborting.');
			return;
		}
		if (mw?.loader?.using == undefined) {
			setTimeout(main, 200);
			return;
		}
		mw.loader.using(
			['mediawiki.util'],
			addContribsRangerPortlet
		);
	}

	main();
})();