Jump to content

User:Andrybak/Scripts/Unsigned helper.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 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]]
	};

	if (mw.config.get('wgAction') !== 'edit' && mw.config.get('wgAction') !== 'submit' && document.getElementById("editform") == null) {
		info('Not editing a page. Aborting.');
		return;
	}

	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?.slots?.main?.content == 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
			);
			// https://doc.wikimedia.org/mediawiki-core/master/js/module-jquery.textSelection.html
			$(wikitextEditor).textSelection(
				'encapsulateSelection', {
					post: " " + templateWikitext
				}
			);
			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();
	}
})();