User:Bosco/Unsigned helper.js: Difference between revisions
Jump to navigation
Jump to search
Content deleted Content added
createTimestampWikitext: move timestamp formatting into wikitext = one usage of `const months` fewer |
chooseTemplate & makeTemplate: add support for Template:Undated; searchFromIndex: make it easier to check found diff by showing it to the user; applySearcherResult: mention the used template in edit summary; findRevisionWhenTextAdded: add better protection against infinite recursion; createTimestampWikitext: use Unicode escapes instead of string concatenation for protection; |
||
| Line 22: | Line 22: | ||
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December']; |
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...'); |
info('Loading...'); |
||
| Line 243: | Line 249: | ||
*/ |
*/ |
||
async function exponentialSearch(lower, upper, candidateIndex, testFunc) { |
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}`; |
const progressMessage = `Examining [${lower}, ${upper ? upper : '...'}]. Current candidate: ${candidateIndex}`; |
||
if (await testFunc(candidateIndex, progressMessage)) { |
if (await testFunc(candidateIndex, progressMessage)) { |
||
| Line 275: | Line 287: | ||
setProgressCallback(progressCallback) { |
setProgressCallback(progressCallback) { |
||
this.#progressCallback = 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; |
|||
}); |
|||
} |
} |
||
| Line 281: | Line 304: | ||
return new Promise(async (resolve, reject) => { |
return new Promise(async (resolve, reject) => { |
||
try { |
try { |
||
const startRevision = await this.#contentLoader.loadRevisionId(startIndex); |
|||
if (startIndex === 0) { |
|||
if (startRevision == undefined) { |
|||
const latestFullRevision = await this.#contentLoader.loadContent(startIndex); |
|||
if ( |
if (startIndex === 0) { |
||
reject("Cannot find the latest revision. Does this page exist?"); |
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)) { |
if (!latestFullRevision.slots.main.content.includes(text)) { |
||
reject("Cannot find text in the latest revision. Did you edit it?"); |
reject("Cannot find text in the latest revision. Did you edit it?"); |
||
| Line 292: | Line 320: | ||
} |
} |
||
} |
} |
||
const |
const maxIndex = (startIndex === 0) ? null : (await this.#findMaxIndex()); |
||
const foundIndex = await exponentialSearch(startIndex, maxIndex, startIndex + 10, async (candidateIndex, progressInfo) => { |
|||
try { |
try { |
||
this.#progressCallback(progressInfo); |
this.#progressCallback(progressInfo); |
||
const candidateFullRevision = await this.#contentLoader.loadContent(candidateIndex); |
const candidateFullRevision = await this.#contentLoader.loadContent(candidateIndex); |
||
if (candidateFullRevision == undefined) { |
if (candidateFullRevision == undefined) { |
||
return |
return undefined; |
||
} |
} |
||
// debug('testFunc: checking text of revision:', candidateFullRevision, candidateFullRevision?.slots, candidateFullRevision?.slots?.main); |
// debug('testFunc: checking text of revision:', candidateFullRevision, candidateFullRevision?.slots, candidateFullRevision?.slots?.main); |
||
| Line 305: | Line 334: | ||
} |
} |
||
}); |
}); |
||
if (foundIndex === undefined) { |
|||
reject("Cannot find this text."); |
|||
return; |
|||
} |
|||
const foundFullRevision = await this.#contentLoader.loadContent(foundIndex); |
const foundFullRevision = await this.#contentLoader.loadContent(foundIndex); |
||
resolve({ |
resolve({ |
||
| Line 333: | Line 366: | ||
} |
} |
||
function |
function chooseUnsignedTemplateFromRevision(fullRevision) { |
||
if (typeof (fullRevision.anon) !== 'undefined') { |
if (typeof (fullRevision.anon) !== 'undefined') { |
||
return |
return CONFIG.unsignedIp; |
||
} else if (typeof (fullRevision.temp) !== 'undefined') { |
} 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. |
// Seems unlikely "temporary" users will have a user page, so this seems the better template for them for now. |
||
return |
return CONFIG.unsignedIp; |
||
} else { |
} else { |
||
return |
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) { |
function createTimestampWikitext(timestamp) { |
||
/* |
|||
// https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##time_format_like_in_signatures |
|||
* Format is from https://www.mediawiki.org/wiki/Help:Extension:ParserFunctions##time_format_like_in_signatures |
|||
return '{{' + `subst:#time:H:i, j xg Y "(UTC)"|${timestamp}}}`; |
|||
* |
|||
* 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 |
function makeTemplate(user, timestamp, template) { |
||
// <nowiki> |
|||
const formattedTimestamp = createTimestampWikitext(timestamp); |
const formattedTimestamp = createTimestampWikitext(timestamp); |
||
if (template == CONFIG.undated) { |
|||
return '{{subst:' + template + '|' + formattedTimestamp + '}}'; |
|||
} |
|||
return '{{subst:' + template + '|' + user + '|' + formattedTimestamp + '}}'; |
return '{{subst:' + template + '|' + user + '|' + formattedTimestamp + '}}'; |
||
// </nowiki> |
|||
} |
} |
||
| Line 433: | Line 493: | ||
function applySearcherResult(searcherResult) { |
function applySearcherResult(searcherResult) { |
||
const fullRevision = searcherResult.fullRevision; |
const fullRevision = searcherResult.fullRevision; |
||
const template = |
const template = chooseTemplate(txt, fullRevision); |
||
const templateWikitext = |
const templateWikitext = makeTemplate( |
||
fullRevision.user, |
fullRevision.user, |
||
fullRevision.timestamp, |
fullRevision.timestamp, |
||
| Line 442: | Line 502: | ||
wikitextEditor.value = newWikitextTillSelection + wikitextEditor.value.substr(pos); |
wikitextEditor.value = newWikitextTillSelection + wikitextEditor.value.substr(pos); |
||
$(wikitextEditor).textSelection('setSelection', { start: newWikitextTillSelection.length }); |
$(wikitextEditor).textSelection('setSelection', { start: newWikitextTillSelection.length }); |
||
appendToEditSummary(`mark |
appendToEditSummary(`mark [[Template:${template}|{{${template}}}]] [[Special:Diff/${fullRevision.revid}]]`); |
||
mainDialog.dialog('close'); |
mainDialog.dialog('close'); |
||
} |
} |
||
function |
function reportSearcherResultToUser(searcherResult, dialogTitle, useCb, keepLookingCb, cancelCb, createMainMessageDivFn) { |
||
const fullRevision = searcherResult.fullRevision; |
const fullRevision = searcherResult.fullRevision; |
||
const revid = fullRevision.revid; |
const revid = fullRevision.revid; |
||
const comment = fullRevision.parsedcomment; |
const comment = fullRevision.parsedcomment; |
||
const |
const questionDialog = createMainMessageDivFn() |
||
.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({ |
.dialog({ |
||
title: |
title: dialogTitle, |
||
modal: true, |
modal: true, |
||
buttons: { |
buttons: { |
||
"Use that revision": function () { |
"Use that revision": function () { |
||
questionDialog.dialog('close'); |
|||
useCb(); |
useCb(); |
||
}, |
}, |
||
"Keep looking": function () { |
"Keep looking": function () { |
||
questionDialog.dialog('close'); |
|||
keepLookingCb(); |
keepLookingCb(); |
||
}, |
}, |
||
"Cancel": function () { |
"Cancel": function () { |
||
questionDialog.dialog('close'); |
|||
cancelCb(); |
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 |
|||
); |
|||
}); |
}); |
||
} |
} |
||
| Line 487: | Line 574: | ||
} |
} |
||
info('Searcher found:', searcherResult); |
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)) { |
if (isRevisionARevert(searcherResult.fullRevision)) { |
||
reportPossibleRevertToUser( |
reportPossibleRevertToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback); |
||
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; |
return; |
||
} |
} |
||
reportNormalSearcherResultToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback); |
|||
}, rejection => { |
}, rejection => { |
||
error(`Searcher cannot find requested index=${index}. Got error:`, rejection); |
error(`Searcher cannot find requested index=${index}. Got error:`, rejection); |
||
Revision as of 23:08, 22 July 2024
/*
* 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();
}
})();