User:Andrybak/Scripts/Unsigned helper.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 is a fork of https://en.wikipedia.org/w/index.php?title=User:Anomie/unsignedhelper.js&oldid=1219219971
 */
(function () {
	const LOG_PREFIX = `[Unsigned Helper]:`;

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

	const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];

	const CONFIG = {
		undated: 'Undated', // [[Template:Undated]]
		unsignedLoggedIn: 'Unsigned', // [[Template:Unsigned]]
		unsignedIp: 'Unsigned IP', // [[Template:Unsigned IP]]
	};

	info('Loading...');

	function formatErrorSpan(errorMessage) {
		return `<span style="color:maroon;"><b>Error:</b> ${errorMessage}</span>`;
	}

	const LAZY_REVISION_LOADING_INTERVAL = 50;

	/**
	 * Lazily loads revision IDs for a page.
	 * Gives zero-indexed access to the revisions. Zeroth revision is the newest revision.
	 */
	class LazyRevisionIdsLoader {
		#pagename;
		#indexedRevisionPromises = [];
		/**
		 * We are loading revision IDs per LAZY_REVISION_LOADING_INTERVAL
		 * Each of requests gives us LAZY_REVISION_LOADING_INTERVAL revision IDs.
		 */
		#historyIntervalPromises = [];
		#api = new mw.Api();

		constructor(pagename) {
			this.#pagename = pagename;
		}

		#getLastLoadedInterval(upToIndex) {
			let i = 0;
			while (this.#historyIntervalPromises[i] != undefined && i <= upToIndex) {
				i++;
			}
			return [i, this.#historyIntervalPromises[i - 1]];
		}

		#createIntervalFromResponse(response) {
			if ('missing' in response.query.pages[0]) {
				return undefined;
			}
			return {
				rvcontinue: response.continue?.rvcontinue,
				revisions: response.query.pages[0].revisions,
			};
		}

		async #loadIntervalsRecursive(index, upToIndex, rvcontinue) {
			return new Promise(async (resolve, reject) => {
				// reference documentation: https://en.wikipedia.org/w/api.php?action=help&modules=query%2Brevisions
				const intervalQuery = {
					action: 'query',
					prop: 'revisions',
					rvlimit: LAZY_REVISION_LOADING_INTERVAL,
					rvprop: 'ids|user', // no 'content' here; 'user' is just for debugging purposes
					rvslots: 'main',
					formatversion: 2, // v2 has nicer field names in responses

					titles: this.#pagename,
				};
				if (rvcontinue) {
					intervalQuery.rvcontinue = rvcontinue;
				}
				debug('loadIntervalsRecursive Q: index =', index, 'upToIndex =', upToIndex, 'intervalQuery =', intervalQuery);
				this.#api.get(intervalQuery).then(async (response) => {
					try {
						// debug('loadIntervalsRecursive R:', response);
						const interval = this.#createIntervalFromResponse(response);
						this.#historyIntervalPromises[index] = Promise.resolve(interval);
						if (index == upToIndex) {
							// we've hit the limit of what we want to load so far
							resolve(interval);
							return;
						}
						if (response.batchcomplete) {
							for (let i = index; i <= upToIndex; i++) {
								this.#historyIntervalPromises[i] = Promise.resolve(undefined);
							}
							// we've asked for an interval of history which doesn't exist
							resolve(undefined);
							return;
						}
						// recursive call for one more interval
						const ignored = await this.#loadIntervalsRecursive(index + 1, upToIndex, interval.rvcontinue);
						if (this.#historyIntervalPromises[upToIndex] == undefined) {
							resolve(undefined);
							return;
						}
						this.#historyIntervalPromises[upToIndex].then(
							result => resolve(result),
							rejection => reject(rejection)
						);
					} catch (e) {
						reject('loadIntervalsRecursive: ' + e);
					}
				}, rejection => {
					reject('loadIntervalsRecursive via api: ' + rejection);
				});
			});
		}

		async #loadInterval(intervalIndex) {
			const [firstNotLoadedIntervalIndex, latestLoadedInterval] = this.#getLastLoadedInterval(intervalIndex);
			if (firstNotLoadedIntervalIndex > intervalIndex) {
				return this.#historyIntervalPromises[intervalIndex];
			}
			const rvcontinue = latestLoadedInterval?.rvcontinue;
			return this.#loadIntervalsRecursive(firstNotLoadedIntervalIndex, intervalIndex, rvcontinue);
		}

		#indexToIntervalIndex(index) {
			return Math.floor(index / LAZY_REVISION_LOADING_INTERVAL);
		}

		#indexToIndexInInterval(index) {
			return index % LAZY_REVISION_LOADING_INTERVAL;
		}

		/**
		 * @param index zero-based index of a revision to load
		 */
		async loadRevision(index) {
			if (this.#indexedRevisionPromises[index]) {
				return this.#indexedRevisionPromises[index];
			}
			const promise = new Promise(async (resolve, reject) => {
				const intervalIndex = this.#indexToIntervalIndex(index);
				try {
					const interval = await this.#loadInterval(intervalIndex);
					if (interval == undefined) {
						resolve(undefined);
						return;
					}
					const theRevision = interval.revisions[this.#indexToIndexInInterval(index)];
					debug('loadRevision: loaded revision', index, theRevision);
					resolve(theRevision);
				} catch (e) {
					reject('loadRevision: ' + e);
				}
			});
			this.#indexedRevisionPromises[index] = promise;
			return promise;
		}
	}

	/**
	 * Lazily loads full revisions (wikitext, user, revid, tags, edit summary, etc) for a page.
	 * Gives zero-indexed access to the revisions. Zeroth revision is the newest revision.
	 */
	class LazyFullRevisionsLoader {
		#pagename;
		#revisionsLoader;
		#indexedContentPromises = [];
		#api = new mw.Api();

		constructor(pagename) {
			this.#pagename = pagename;
			this.#revisionsLoader = new LazyRevisionIdsLoader(pagename);
		}

		/**
		 * Returns a {@link Promise} with full revision for given index.
		 */
		async loadContent(index) {
			if (this.#indexedContentPromises[index]) {
				return this.#indexedContentPromises[index];
			}
			const promise = new Promise(async (resolve, reject) => {
				try {
					const revision = await this.#revisionsLoader.loadRevision(index);
					if (revision == undefined) {
						// this revision doesn't seem to exist
						resolve(undefined);
						return;
					}
					// reference documentation: https://en.wikipedia.org/w/api.php?action=help&modules=query%2Brevisions
					const contentQuery = {
						action: 'query',
						prop: 'revisions',
						rvlimit: 1, // load the big wikitext only for the revision
						rvprop: 'ids|user|timestamp|tags|parsedcomment|content',
						rvslots: 'main',
						formatversion: 2, // v2 has nicer field names in responses

						titles: this.#pagename,
						rvstartid: revision.revid,
					};
					debug('loadContent: contentQuery = ', contentQuery);
					this.#api.get(contentQuery).then(response => {
						try {
							const theRevision = response.query.pages[0].revisions[0];
							resolve(theRevision);
						} catch (e) {
							// just in case the chain `response.query.pages[0].revisions[0]`
							// is broken somehow
							error('loadContent:', e);
							reject('loadContent:' + e);
						}
					}, rejection => {
						reject('loadContent via api:' + rejection);
					});
				} catch (e) {
					error('loadContent:', e);
					reject('loadContent: ' + e);
				}
			});
			this.#indexedContentPromises[index] = promise;
			return promise;
		}

		async loadRevisionId(index) {
			return this.#revisionsLoader.loadRevision(index);
		}
	}

	function midPoint(lower, upper) {
		return Math.floor(lower + (upper - lower) / 2);
	}

	/**
	 * Based on https://en.wikipedia.org/wiki/Module:Exponential_search
	 */
	async function exponentialSearch(lower, upper, candidateIndex, testFunc) {
		if (upper === null && lower === candidateIndex) {
			throw new Error(`Wrong arguments for exponentialSearch (${lower}, ${upper}, ${candidateIndex}).`);
		}
		if (lower === upper && lower === candidateIndex) {
			throw new Error("Cannot find it");
		}
		const progressMessage = `Examining [${lower}, ${upper ? upper : '...'}]. Current candidate: ${candidateIndex}`;
		if (await testFunc(candidateIndex, progressMessage)) {
			if (candidateIndex + 1 == upper) {
				return candidateIndex;
			}
			lower = candidateIndex;
			if (upper) {
				candidateIndex = midPoint(lower, upper);
			} else {
				candidateIndex = candidateIndex * 2;
			}
			return exponentialSearch(lower, upper, candidateIndex, testFunc);
		} else {
			upper = candidateIndex;
			candidateIndex = midPoint(lower, upper);
			return exponentialSearch(lower, upper, candidateIndex, testFunc);
		}
	}

	class PageHistoryContentSearcher {
		#pagename;
		#contentLoader;
		#progressCallback;

		constructor(pagename, progressCallback) {
			this.#pagename = pagename;
			this.#contentLoader = new LazyFullRevisionsLoader(this.#pagename);
			this.#progressCallback = progressCallback;
		}

		setProgressCallback(progressCallback) {
			this.#progressCallback = progressCallback;
		}

		async #findMaxIndex() {
			return exponentialSearch(0, null, 1, async (candidateIndex, progressInfo) => {
				this.#progressCallback(progressInfo + ' (max search)');
				const candidateRevision = await this.#contentLoader.loadRevisionId(candidateIndex);
				if (candidateRevision == undefined) {
					return false;
				}
				return true;
			});
		}

		async findRevisionWhenTextAdded(text, startIndex) {
			info('findRevisionWhenTextAdded: searching for', text);
			return new Promise(async (resolve, reject) => {
				try {
					const startRevision = await this.#contentLoader.loadRevisionId(startIndex);
					if (startRevision == undefined) {
						if (startIndex === 0) {
							reject("Cannot find the latest revision. Does this page exist?");
						} else {
							reject(`Cannot find the start revision (index=${startIndex}).`);
						}
						return;
					}
					if (startIndex === 0) {
						const latestFullRevision = await this.#contentLoader.loadContent(startIndex);
						if (!latestFullRevision.slots.main.content.includes(text)) {
							reject("Cannot find text in the latest revision. Did you edit it?");
							return;
						}
					}
					const maxIndex = (startIndex === 0) ? null : (await this.#findMaxIndex());
					const foundIndex = await exponentialSearch(startIndex, maxIndex, startIndex + 10, async (candidateIndex, progressInfo) => {
						try {
							this.#progressCallback(progressInfo);
							const candidateFullRevision = await this.#contentLoader.loadContent(candidateIndex);
							if (candidateFullRevision == undefined) {
								return undefined;
							}
							// debug('testFunc: checking text of revision:', candidateFullRevision, candidateFullRevision?.slots, candidateFullRevision?.slots?.main);
							return candidateFullRevision.slots.main.content.includes(text);
						} catch (e) {
							reject('testFunc: ' + e);
						}
					});
					if (foundIndex === undefined) {
						reject("Cannot find this text.");
						return;
					}
					const foundFullRevision = await this.#contentLoader.loadContent(foundIndex);
					resolve({
						fullRevision: foundFullRevision,
						index: foundIndex,
					});
				} catch (e) {
					reject(e);
				}
			});
		}
	}

	function isRevisionARevert(fullRevision) {
		if (fullRevision.tags.includes('mw-rollback')) {
			return true;
		}
		if (fullRevision.tags.includes('mw-undo')) {
			return true;
		}
		if (fullRevision.parsedcomment.includes('Undid')) {
			return true;
		}
		if (fullRevision.parsedcomment.includes('Reverted')) {
			return true;
		}
		return false;
	}

	function chooseUnsignedTemplateFromRevision(fullRevision) {
		if (typeof (fullRevision.anon) !== 'undefined') {
			return CONFIG.unsignedIp;
		} else if (typeof (fullRevision.temp) !== 'undefined') {
			// Seems unlikely "temporary" users will have a user page, so this seems the better template for them for now.
			return CONFIG.unsignedIp;
		} else {
			return CONFIG.unsignedLoggedIn;
		}
	}

	function chooseTemplate(selectedText, fullRevision) {
		const user = fullRevision.user;
		if (selectedText.includes(`[[User talk:${user}|`)) {
			/*
			 * assume that presense of something that looks like a wikilink to the user's talk page
			 * means that the message is just undated, not unsigned
			 * NB: IP editors have `Special:Contributions` and `User talk` in their signature.
			 */
			return CONFIG.undated;
		}
		if (selectedText.includes(`[[User:${user}|`)) {
			// some ancient undated signatures have only `[[User:` links
			return CONFIG.undated;
		}
		return chooseUnsignedTemplateFromRevision(fullRevision);
	}

	function createTimestampWikitext(timestamp) {
		/*
		 * Format is from https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##time_format_like_in_signatures
		 *
		 * The unicode escapes are needed to avoid actual substitution, see
		 * https://en.wikipedia.org/w/index.php?title=User:Andrybak/Scripts/Unsigned_generator.js&diff=prev&oldid=1229098580
		 */
		return `\u007B\u007Bsubst:#time:H:i, j xg Y "(UTC)"|${timestamp}}}`;
	}

	function makeTemplate(user, timestamp, template) {
		// <nowiki>
		const formattedTimestamp = createTimestampWikitext(timestamp);
		if (template == CONFIG.undated) {
			return '{{subst:' + template + '|' + formattedTimestamp + '}}';
		}
		return '{{subst:' + template + '|' + user + '|' + formattedTimestamp + '}}';
		// </nowiki>
	}

	function constructAd() {
		return " (using [[w:User:Andrybak/Scripts/Unsigned helper|Unsigned helper]])";
	}

	function appendToEditSummary(newSummary) {
		const editSummaryField = $("#wpSummary:first");
		if (editSummaryField.length == 0) {
			warn('Cannot find edit summary text field.');
			return;
		}
		// get text without trailing whitespace
		let oldText = editSummaryField.val().trimEnd();
		const ad = constructAd();
		if (oldText.includes(ad)) {
			oldText = oldText.replace(ad, '');
		}
		let newText = "";
		if (oldText.match(/[*]\/$/)) {
			// check if "/* section name */" is present
			newText = oldText + " " + newSummary;
		} else if (oldText.length != 0) {
			newText = oldText + ", " + newSummary;
		} else {
			newText = newSummary;
		}
		editSummaryField.val(newText + ad);
	}

	// kept outside of doAddUnsignedTemplate() to keep all the caches
	let searcher;
	function getSearcher() {
		if (searcher) {
			return searcher;
		}
		const pagename = mw.config.get('wgPageName');
		searcher = new PageHistoryContentSearcher(pagename, progressInfo => {
			info('Default progress callback', progressInfo);
		});
		return searcher;
	}

	async function doAddUnsignedTemplate() {
		const form = document.getElementById('editform');
		const wikitextEditor = form.elements.wpTextbox1;
		let pos = $(wikitextEditor).textSelection('getCaretPosition', { startAndEnd: true });
		let txt;
		if (pos[0] != pos[1]) {
			txt = wikitextEditor.value.substring(pos[0], pos[1]);
			pos = pos[1];
		} else {
			pos = pos[1];
			if (pos <= 0) {
				pos = wikitextEditor.value.length;
			}
			txt = wikitextEditor.value.substr(0, pos);
			txt = txt.replace(new RegExp('[\\s\\S]*\\d\\d:\\d\\d, \\d+ (' + months.join('|') + ') \\d\\d\\d\\d \\(UTC\\)'), '');
			txt = txt.replace(/[\s\S]*\n=+.*=+\s*\n/, '');
		}
		txt = txt.replace(/^\s+|\s+$/g, '');

		// TODO maybe migrate to https://www.mediawiki.org/wiki/OOUI/Windows/Message_Dialogs
		const mainDialog = $('<div>Examining...</div>').dialog({
			buttons: {
				Cancel: function () {
					mainDialog.dialog('close');
				}
			},
			modal: true,
			title: 'Adding {{unsigned}}'
		});

		getSearcher().setProgressCallback(debugInfo => {
			/* progressCallback */
			info('Showing to user:', debugInfo);
			mainDialog.html(debugInfo);
		});

		function applySearcherResult(searcherResult) {
			const fullRevision = searcherResult.fullRevision;
			const template = chooseTemplate(txt, fullRevision);
			const templateWikitext = makeTemplate(
				fullRevision.user,
				fullRevision.timestamp,
				template
			);
			const newWikitextTillSelection = wikitextEditor.value.substr(0, pos).replace(/\s*$/, ' ') + templateWikitext;
			wikitextEditor.value = newWikitextTillSelection + wikitextEditor.value.substr(pos);
			$(wikitextEditor).textSelection('setSelection', { start: newWikitextTillSelection.length });
			appendToEditSummary(`mark [[Template:${template}|{{${template}}}]] [[Special:Diff/${fullRevision.revid}]]`);
			mainDialog.dialog('close');
		}

		function reportSearcherResultToUser(searcherResult, dialogTitle, useCb, keepLookingCb, cancelCb, createMainMessageDivFn) {
			const fullRevision = searcherResult.fullRevision;
			const revid = fullRevision.revid;
			const comment = fullRevision.parsedcomment;
			const questionDialog = createMainMessageDivFn()
			.dialog({
				title: dialogTitle,
				modal: true,
				buttons: {
					"Use that revision": function () {
						questionDialog.dialog('close');
						useCb();
					},
					"Keep looking": function () {
						questionDialog.dialog('close');
						keepLookingCb();
					},
					"Cancel": function () {
						questionDialog.dialog('close');
						cancelCb();
					},
				}
			});
		}

		function reportPossibleRevertToUser(searcherResult, useCb, keepLookingCb, cancelCb) {
			const fullRevision = searcherResult.fullRevision;
			const revid = fullRevision.revid;
			const comment = fullRevision.parsedcomment;
			reportSearcherResultToUser(searcherResult, "Possible revert!", useCb, keepLookingCb, cancelCb, () => {
				return $('<div>').append(
					"The ",
					$('<a>').prop({
						href: '/w/index.php?diff=prev&oldid=' + revid,
						target: '_blank'
					}).text(`found revision (index=${searcherResult.index})`),
					" may be a revert: ",
					comment
				);
			});
		}

		function reportNormalSearcherResultToUser(searcherResult, useCb, keepLookingCb, cancelCb) {
			const fullRevision = searcherResult.fullRevision;
			const revid = fullRevision.revid;
			const comment = fullRevision.parsedcomment;
			reportSearcherResultToUser(searcherResult, "Do you want to use this?", useCb, keepLookingCb, cancelCb, () => {
				return $('<div>').append(
					"Found a revision: ",
					$('<a>').prop({
						href: '/w/index.php?diff=prev&oldid=' + revid,
						target: '_blank'
					}).text(`[[Special:Diff/${revid}]] (index=${searcherResult.index})`),
					".",
					$('<br/>'),
					"Comment: ",
					comment
				);
			});
		}

		function searchFromIndex(index) {
			searcher.findRevisionWhenTextAdded(txt, index).then(searcherResult => {
				if (!mainDialog.dialog('isOpen')) {
					// user clicked [cancel]
					return;
				}
				info('Searcher found:', searcherResult);
				const useCallback = () => { /* use */
					applySearcherResult(searcherResult);
				};
				const keepLookingCallback = () => { /* keep looking */
					// recursive call from a differfent index: `+1` is very important here
					searchFromIndex(searcherResult.index + 1);
				};
				const cancelCallback = () => { /* cancel */
					mainDialog.dialog('close');
				};
				if (isRevisionARevert(searcherResult.fullRevision)) {
					reportPossibleRevertToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback);
					return;
				}
				reportNormalSearcherResultToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback);
			}, rejection => {
				error(`Searcher cannot find requested index=${index}. Got error:`, rejection);
				if (!mainDialog.dialog('isOpen')) {
					// user clicked [cancel]
					return;
				}
				mainDialog.html(formatErrorSpan(`${rejection}`));
			});
		}

		searchFromIndex(0);
	}

	window.unsignedHelperAddUnsignedTemplate = function(event) {
		mw.loader.using(['mediawiki.util', 'jquery.ui'], doAddUnsignedTemplate);
		event.preventDefault();
		event.stopPropagation();
		return false;
	}

	if (!window.charinsertCustom) {
		window.charinsertCustom = {};
	}
	if (!window.charinsertCustom.Insert) {
		window.charinsertCustom.Insert = '';
	}
	window.charinsertCustom.Insert += ' {{unsigned}}\x10unsignedHelperAddUnsignedTemplate';
	if (!window.charinsertCustom['Wiki markup']) {
		window.charinsertCustom['Wiki markup'] = '';
	}
	window.charinsertCustom['Wiki markup'] += ' {{unsigned}}\x10unsignedHelperAddUnsignedTemplate';
	if (window.updateEditTools) {
		window.updateEditTools();
	}
})();