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

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 `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) {

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) {

//

const formattedTimestamp = createTimestampWikitext(timestamp);

if (template == CONFIG.undated) {

return '{{subst:' + template + '|' + formattedTimestamp + '}}';

}

return '{{subst:' + template + '|' + user + '|' + formattedTimestamp + '}}';

//

}

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 {{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}}} 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 $('

').append(

"The ",

$('').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 $('

').append(

"Found a revision: ",

$('').prop({

href: '/w/index.php?diff=prev&oldid=' + revid,

target: '_blank'

}).text(`Special:Diff/${revid} (index=${searcherResult.index})`),

".",

$('
'),

"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();

}

})();