User:Andrybak/Scripts/Contribs ranger.js

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 on a wiki.
 */

/* global mw */

(function() {
	'use strict';

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

	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');
			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();
				const queryParams = {
					action: 'query',
					prop: 'revisions',
					rvprop: 'ids|user|timestamp',
					rvslots: 'main',
					formatversion: 2, // v2 has nicer field names in responses

					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);
					}
				);
			});
		}
	}

	function getUrl(limit, isoTimestamp) {
		const timestamp = convertIsoTimestamp(isoTimestamp);
		/*
		 * Append one millisecond to get the latest contrib in the range.
		 * Assuming users aren't doing more than one edit 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) {
		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 contribsListItems = document.querySelectorAll('.mw-contributions-list li');
		const len = contribsListItems.length;
		contribsListItems.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() {
		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(" edits]"));
		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() {
		if (document.getElementById(UI_OUTPUT_LINK_ID)) {
			info('Already added output UI. Skipping.');
			return;
		}
		const ui = document.createElement('span');
		ui.appendChild(document.createTextNode("Contributions range: "));
		ui.appendChild(createOutputWikitextElement());
		ui.appendChild(document.createTextNode(' '));
		ui.appendChild(createCopyButton());
		mw.util.addSubtitle(ui);
	}

	function startContribsRanger() {
		info('Starting up...');
		const rangeHolder = ContribsRangeHolder.getInstance();
		addRadioButtons(rangeHolder);
		addOutputUi();
		// Populate the UI immediately to direct attention of the user.
		updateRangeUrl(rangeHolder);
	}

	function addContribsRangerPortlet() {
		const linkText = "Contribs ranger";
		const portletId = 'ca-andrybakContribsSelector';
		const tooltip = "Select a range of contributions";
		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'],
				startContribsRanger
			);
		};
	}

	function main() {
		if (mw?.config == undefined) {
			setTimeout(main, 200);
			return;
		}
		const namespaceNumber = mw.config.get('wgNamespaceNumber');
		if (namespaceNumber !== -1) {
			info('Not a special page. Aborting.');
			return;
		}
		const canonicalSpecialPageName = mw.config.get('wgCanonicalSpecialPageName');
		if (canonicalSpecialPageName !== 'Contributions') {
			info('Not a contributions page. Aborting.');
			return;
		}
		if (mw?.loader?.using == undefined) {
			setTimeout(main, 200);
			return;
		}
		mw.loader.using(
			['mediawiki.util'],
			addContribsRangerPortlet
		);
	}

	main();
})();