User:Andrybak/Scripts/Unsigned helper.js

/* * 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'];

info('Loading...');

function formatErrorSpan(errorMessage) { return ` Error: ${errorMessage} `; }

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) { 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 findRevisionWhenTextAdded(text, startIndex) { info('findRevisionWhenTextAdded: searching for', text); return new Promise(async (resolve, reject) => {				try {					if (startIndex === 0) {						const latestFullRevision = await this.#contentLoader.loadContent(startIndex);						if (latestFullRevision == undefined) {							reject("Cannot find the latest revision. Does this page exist?");							return;						}						if (!latestFullRevision.slots.main.content.includes(text)) {							reject("Cannot find text in the latest revision. Did you edit it?");							return;						}					}					const foundIndex = await exponentialSearch(startIndex, null, startIndex + 10, async (candidateIndex, progressInfo) => { try { this.#progressCallback(progressInfo); const candidateFullRevision = await this.#contentLoader.loadContent(candidateIndex); if (candidateFullRevision == undefined) { return false; }							// debug('testFunc: checking text of revision:', candidateFullRevision, candidateFullRevision?.slots, candidateFullRevision?.slots?.main); return candidateFullRevision.slots.main.content.includes(text); } catch (e) { reject('testFunc: ' + e); }					});					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 chooseTemplateFromRevision(fullRevision) { if (typeof (fullRevision.anon) !== 'undefined') { return 'Unsigned IP'; } 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 'Unsigned IP'; } else { return 'Unsigned'; }	}	function createTimestampWikitext(timestamp) { // https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##time_format_like_in_signatures return '`; }

function makeUnsignedTemplate(user, timestamp, template) { const formattedTimestamp = createTimestampWikitext(timestamp); return  + user + ; }

function constructAd { return " (using 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 = $(' Examining... ').dialog({			buttons: {				Cancel: function {					mainDialog.dialog('close');				}			},			modal: true,			title: 'Adding '		});

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

function applySearcherResult(searcherResult) { const fullRevision = searcherResult.fullRevision; const template = chooseTemplateFromRevision(fullRevision); const templateWikitext = makeUnsignedTemplate(				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 unsigned Special:Diff/${fullRevision.revid}`); mainDialog.dialog('close'); }

function reportPossibleRevertToUser(searcherResult, useCb, keepLookingCb, cancelCb) { const fullRevision = searcherResult.fullRevision; const revid = fullRevision.revid; const comment = fullRevision.parsedcomment; const suspicionDialog = $(' ') .append(				"The ",				$('').prop({ href: '/w/index.php?diff=prev&oldid=' + revid, target: '_blank' }).text(`found revision (index=${searcherResult.index})`),				" may be a revert: ",				comment			) .dialog({				title: "Possible revert!",				modal: true,				buttons: {					"Use that revision": function {						suspicionDialog.dialog('close');						useCb;					},					"Keep looking": function  {						suspicionDialog.dialog('close');						keepLookingCb;					},					"Cancel": function  {						suspicionDialog.dialog('close');						cancelCb;					},				}			}); }

function searchFromIndex(index) { searcher.findRevisionWhenTextAdded(txt, index).then(searcherResult => {				if (!mainDialog.dialog('isOpen')) {					// user clicked [cancel]					return;				}				info('Searcher found:', searcherResult);				if (isRevisionARevert(searcherResult.fullRevision)) {					reportPossibleRevertToUser( searcherResult, => { /* use */ applySearcherResult(searcherResult); },						 => { /* keep looking */ // recursive call from a differfent index: `+1` is very important here searchFromIndex(searcherResult.index + 1); },						 => { /* cancel */ mainDialog.dialog('close'); }					);					return;				}				applySearcherResult(searcherResult);			}, 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 += ' \x10unsignedHelperAddUnsignedTemplate'; if (!window.charinsertCustom['Wiki markup']) { window.charinsertCustom['Wiki markup'] = ''; }	window.charinsertCustom['Wiki markup'] += ' \x10unsignedHelperAddUnsignedTemplate'; if (window.updateEditTools) { window.updateEditTools; } });