Jump to content

User:Andrybak/Scripts/Contribs ranger.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.
/*
 * 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://en.wikipedia.org/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://en.wikipedia.org/w/index.php?title=Special:Log&logid=162280736
			 * when viewed in a log, like this:
			 *   https://en.wikipedia.org/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://en.wikipedia.org/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://en.wikipedia.org/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();
})();