User:Bosco/Unsigned helper.js: Difference between revisions
From Test Wiki
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); |
||