User:Andrybak/Scripts/Unsigned helper.js
Appearance
< User:Andrybak | Scripts
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | This user script seems to have a documentation page at User:Andrybak/Scripts/Unsigned helper. |
/*
* 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 `<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) {
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 makeUnsignedTemplate(user, timestamp, template) {
const ts = new Date(timestamp);
let h = ts.getUTCHours();
if (h < 10) {
h = '0' + h;
}
let m = ts.getUTCMinutes();
if (m < 10) {
m = '0' + m;
}
const formattedTimestamp = `${h}:${m}, ${ts.getUTCDate()} ${months[ts.getUTCMonth()]} ${ts.getUTCFullYear()} (UTC)`;
return '{{subst:' + template + '|' + user + '|' + formattedTimestamp + '}}';
}
function appendToEditSummary(newSummary) {
const editSummaryField = $("#wpSummary:first");
if (editSummaryField.length == 0) {
warn('Cannot find edit summary text field.');
return;
}
const oldText = editSummaryField.val().trimEnd();
// get text without trailing whitespace
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);
}
// 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 = 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 = $('<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
)
.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.updateEditTools) {
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';
window.updateEditTools();
info('Added edit tools.');
}
})();