User:Bosco/Unsigned helper.js: Difference between revisions

From Test Wiki
Jump to navigation Jump to search
Content deleted Content added
fix + tweak
mNo edit summary
 
(42 intermediate revisions by 2 users not shown)
Line 1: Line 1:
/*
var UnsignedHelper={
* This is a fork of https://en.wikipedia.org/w/index.php?title=User:Anomie/unsignedhelper.js&oldid=1219219971
months:['January','February','March','April','May','June','July','August','September','October','November','December'],
*/
(function () {
const DEBUG = false;
const LOG_PREFIX = `[簽名工具]:`;


function error(...toLog) {
makeUnsignedTemplate:function(user,ts,template){
console.error(LOG_PREFIX, ...toLog);
ts=new Date(ts);
}
var h=ts.getUTCHours(); if(h<10) h='0'+h;
var m=ts.getUTCMinutes(); if(m<10) m='0'+m;
ts=h+':'+m+', '+ts.getUTCDate()+' '+UnsignedHelper.months[ts.getUTCMonth()]+' '+ts.getUTCFullYear()+' (UTC)';
return '\x7b\x7bsubst:'+template+'|'+user+'|'+ts+'\x7d\x7d';
},
appendToEditSummary:function(newSummary) {
const editSummaryField = $("#wpSummary:first");
if(editSummaryField.length==0) {
console.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);
},


function warn(...toLog) {
addUnsignedTemplate:function(evt){
console.warn(LOG_PREFIX, ...toLog);
mw.loader.using(['mediawiki.util','jquery.ui'], function(){
}
var f=document.getElementById('editform');
var e=f.elements.wpTextbox1;
var pos=$(e).textSelection('getCaretPosition', {startAndEnd:true});
var txt;
if(pos[0]!=pos[1]){
txt=e.value.substring(pos[0],pos[1]);
pos=pos[1];
} else {
pos=pos[1];
if(pos<=0) pos=e.value.length;
txt=e.value.substr(0,pos);
txt=txt.replace(new RegExp('[\\s\\S]*\\d\\d:\\d\\d, \\d+ ('+UnsignedHelper.months.join('|')+') \\d\\d\\d\\d \\(UTC\\)'), '');
txt=txt.replace(/[\s\S]*\n=+.*=+\s*\n/, '');
}
txt=txt.replace(/^\s+|\s+$/g, '');


function info(...toLog) {
var rvct=1;
console.info(LOG_PREFIX, ...toLog);
var dialog=$('<div>Examining revision 1...</div>').dialog({
}
buttons:{ Cancel:function(){ dialog.dialog('close'); } },
modal:true,
title:'Adding \x7b\x7bunsigned\x7d\x7d'
});


function debug(...toLog) {
var revid, user, ts, comment, template;
console.debug(LOG_PREFIX, ...toLog);
var q={
}
url:mw.util.wikiScript('api'),
dataType:'json',
type:'POST',
data:{
format:'json',
action:'query',
titles:mw.config.get('wgPageName'),
prop:'revisions',
rvprop:'ids|timestamp|user|parsedcomment|content',
rvlimit:1,
rawcontinue:1
},
success:function(r,sts,xhr){
if(!dialog.dialog('isOpen')) return;


const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];
if(!r.query || !r.query.pages){
dialog.html('<span style="color:red"><b>Error:</b> Bad response from API</span>');
if(window.console && typeof(window.console.error)=='function')
window.console.error("Bad response", r);
return;
}


const CONFIG = {
for(var k in r.query.pages){
undated: 'Undated', // [[Template:Undated]]
var rr=r.query.pages[k].revisions[0];
unsignedLoggedIn: '未签名', // [[Template:Unsigned]]
var cont = function () {
unsignedIp: '未签名', // [[Template:Unsigned]]
if(r['query-continue'] && r['query-continue'].revisions){
};
dialog.html('Evaluating revision '+(++rvct)+'...');
q.data.rvcontinue=r['query-continue'].revisions.rvcontinue;
$.ajax(q);
} else {
var t=UnsignedHelper.makeUnsignedTemplate(user,ts,template);
var tt=e.value.substr(0,pos).replace(/\s*$/,' ')+t;
e.value=tt+e.value.substr(pos);
$(e).textSelection('setSelection', { start:tt.length });
UnsignedHelper.appendToEditSummary(`unsigned [[Special:Diff/${revid}]]`);
dialog.dialog('close');
}
};


if (mw.config.get('wgAction') !== 'edit' && mw.config.get('wgAction') !== 'submit' && document.getElementById("editform") == null) {
if(typeof(rr['*'])!='undefined' && rr['*'].indexOf(txt)<0){
info('未編輯頁面。暫停中。');
if(!user){
return;
dialog.html('<span style="color:red"><b>Error:</b> Text was not found in the starting revision! Did you edit it?</span>');
}
return;
}
var cb = function () {
var t=UnsignedHelper.makeUnsignedTemplate(user,ts,template);
var tt=e.value.substr(0,pos).replace(/\s*$/,' ')+t;
e.value=tt+e.value.substr(pos);
$(e).textSelection('setSelection', { start:tt.length });
UnsignedHelper.appendToEditSummary(`unsigned [[Special:Diff/${revid}]]`);
dialog.dialog('close');
};
if(/reverted|undid/i.test(comment)){
var dialog2 = $( '<div>' )
.append(
'The ',
$( '<a>' ).prop( { href: '/w/index.php?diff=prev&oldid=' + revid, target: '_blank' } ).text( 'found revision' ),
' may be a revert: ',
comment
)
.dialog( {
title: "Possible revert!",
modal: true,
buttons: {
"Use that revision": function () { dialog2.dialog( 'close' ); cb(); },
"Keep looking": function () { dialog2.dialog( 'close' ); cont(); },
"Cancel": function () { dialog2.dialog( 'close' ); dialog.dialog( 'close' ); },
}
} );
} else {
cb();
}
} else {
revid=rr.revid;
user=rr.user;
ts=rr.timestamp;
comment=rr.parsedcomment;
if ( typeof(rr.anon) !== 'undefined' ) {
template = 'Unsigned IP';
} else if ( typeof(rr.temp) !== 'undefined' ) {
// Seems unlikely "temporary" users will have a user page, so this seems the better template for them for now.
template = 'Unsigned IP';
} else {
template = 'Unsigned';
}
cont();
}
return;
}
dialog.html('<span style="color:red"><b>Error:</b> No revisions found in the page!</span>');
},
error:function(xhr,textStatus,errorThrown){
if(!dialog.dialog('isOpen')) return;
dialog.html('<span style="color:red"><b>Error:</b> '+textStatus+' '+errorThrown+'</span>');
}
};
if(f.elements.baseRevId) q.data.rvstartid=f.elements.baseRevId.value;
$.ajax(q);
});
evt.preventDefault();
evt.stopPropagation();
return false;
}
};


info('載入中...');
if(!window.charinsertCustom) window.charinsertCustom={};

if(!window.charinsertCustom['Insert']) window.charinsertCustom['Insert']='';
function formatErrorSpan(errorMessage) {
window.charinsertCustom['Insert']+=' \x7b\x7bunsigned\x7d\x7d\x10UnsignedHelper.addUnsignedTemplate';
return `<span style="color:maroon;"><b>錯誤:</b> ${errorMessage}</span>`;
if(!window.charinsertCustom['Wiki markup']) window.charinsertCustom['Wiki markup']='';
}
window.charinsertCustom['Wiki markup']+=' \x7b\x7bunsigned*\x7d\x7d\x10UnsignedHelper.addUnsignedTemplate';

if(window.updateEditTools) window.updateEditTools();
/**
* Batch size for {@link LazyRevisionIdsLoader}.
*/
const LAZY_REVISION_LOADING_INTERVAL = 50;

/**
* Lazily loads revision IDs for a page. The loading is done linearly,
* in batches of the size of {@link LAZY_REVISION_LOADING_INTERVAL}.
* This is relatively fast because we are not loading the heavy contents
* of the page, only the metadata.
* 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) {
debug(`#getLastLoadedInterval(${upToIndex}): `, this.#historyIntervalPromises.length);
let i = 0;
while (this.#historyIntervalPromises[i] != undefined && i <= upToIndex) {
i++;
}
debug(`#getLastLoadedInterval(${upToIndex}) = ${i}`);
return [i, this.#historyIntervalPromises[i - 1]];
}

#createIntervalFromResponse(response) {
if ('missing' in response.query.pages[0]) {
return undefined;
}
const interval = {
rvcontinue: response.continue?.rvcontinue,
revisions: response.query.pages[0].revisions,
};
if (response.batchcomplete) {
// remember that MediaWiki has no more revisions to return
interval.batchcomplete = true;
} else {
interval.batchcomplete = false;
}
return interval;
}

async #loadIntervalsRecursive(startIndex, targetIndex, rvcontinue) {
const logMsgPrefix = `#loadIntervalsRecursive(${startIndex}, ${targetIndex}, '${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(`${logMsgPrefix} Q =`, intervalQuery);
this.#api.get(intervalQuery).then(async (response) => {
try {
if (DEBUG) {
debug(`${logMsgPrefix} R =`, response);
}
const interval = this.#createIntervalFromResponse(response);
this.#historyIntervalPromises[startIndex] = Promise.resolve(interval);
if (startIndex == targetIndex) {
// we've hit the limit of what we want to load so far
resolve(interval);
return;
}
if (interval.batchcomplete) {
// reached the end of batch loading => cannot ask for one more
// for convenience, fill the rest of the array with undefined
for (let i = startIndex + 1; i <= targetIndex; i++) {
this.#historyIntervalPromises[i] = Promise.resolve(undefined);
}
info(`${logMsgPrefix}: This is the last batch returned by MediaWiki`);
if (targetIndex <= startIndex) {
error(`${logMsgPrefix}: something went very wrong`);
}
resolve(undefined);
return;
}
// .batchcomplete has not been reached, call for one more interval (recursive)
const ignored = await this.#loadIntervalsRecursive(startIndex + 1, targetIndex, interval.rvcontinue);
if (this.#historyIntervalPromises[targetIndex] == undefined) {
resolve(undefined);
return;
}
this.#historyIntervalPromises[targetIndex].then(
result => resolve(result),
rejection => reject(rejection)
);
} catch (e) {
reject('loadIntervalsRecursive: ' + e);
}
}, rejection => {
reject('loadIntervalsRecursive via api: ' + rejection);
});
});
}

async #loadInterval(intervalIndex) {
const [firstNotLoadedIntervalIndex, latestLoadedIntervalPromise] = this.#getLastLoadedInterval(intervalIndex);
if (firstNotLoadedIntervalIndex > intervalIndex) {
return this.#historyIntervalPromises[intervalIndex];
}
if (await latestLoadedIntervalPromise?.then(interval => interval.batchcomplete)) {
// latest request returned the last batch in the batch loading of revisions
return Promise.resolve(undefined);
}
const rvcontinue = await latestLoadedIntervalPromise?.then(interval => interval.rvcontinue);
debug(`#loadInterval(${intervalIndex}): ${firstNotLoadedIntervalIndex}, ${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;
}

#revisionsToString(revisions) {
if (!revisions) {
return "<undefined revisions>";
}
return Array.from(revisions).map((revision, index) => {
return `[${index}]={revid=${revision.revid} by User:${revision.user}}`
}).join(", ");
}

/**
* @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);
debug(`loadRevision: loading from interval #${intervalIndex}...`);
try {
const interval = await this.#loadInterval(intervalIndex);
if (DEBUG) {
debug(`loadRevision: loaded the interval#${intervalIndex} with revisions: (length=${interval?.revisions?.length}) ${this.#revisionsToString(interval?.revisions)}`);
}
if (interval == undefined) {
resolve(undefined);
return;
}
const indexInInterval = this.#indexToIndexInInterval(index);
if (DEBUG) {
debug(`loadRevision: from the above interval, looking at [${indexInInterval}]`);
}
const theRevision = interval.revisions[indexInInterval];
debug('loadRevision: loaded revision', index, theRevision);
resolve(theRevision);
} catch (e) {
reject('loadRevision: ' + e);
}
});
this.#indexedRevisionPromises[index] = promise;
return promise;
}
}

/**
* Lazily loads full revisions (full wikitext and metadata) for a page.
* Gives zero-indexed access to the revisions. Zeroth revision is the newest revision.
* Loaded revisions are cached to speed up consecutive requests about the
* same page.
*/
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(`exponentialSearch 的錯誤參數(${lower}, ${upper}, ${candidateIndex}).`);
}
if (lower === upper && lower === candidateIndex) {
throw new Error("不能找到");
}
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;
}

getContentLoader() {
return this.#contentLoader;
}

/**
* Uses an exponential initial search followed by a binary search to find
* a snippet of text, which was added to the page.
*/
async findRevisionWhenTextAdded(text, startIndex) {
info(`findRevisionWhenTextAdded(startIndex=${startIndex}): searching for '${text}'`);
return new Promise(async (resolve, reject) => {
try {
const startRevision = await this.#contentLoader.loadRevisionId(startIndex);
if (startRevision == undefined) {
if (startIndex === 0) {
reject("不能找到最新版本。此頁面存在嗎?");
} else {
reject(`不能尋找開始版本 (版本=${startIndex}).`);
}
return;
}
if (startIndex === 0) {
const latestFullRevision = await this.#contentLoader.loadContent(startIndex);
if (!latestFullRevision.slots.main.content.includes(text)) {
reject("不能尋找最新版本的內容。你編輯了嗎?");
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?.slots?.main?.content == undefined) {
/*
* TODO can we distinguish between
* - `candidateIndex` is out of bounds of the history
* vs
* - `candidateIndex` is obscured from current user ([[WP:REVDEL]] or [[WP:SUPPRESS]])
* ?
*/
warn('測試功能:不能載入candidateIndex的內容 = ' + candidateIndex);
return undefined;
}
// debug('testFunc: checking text of revision:', candidateFullRevision, candidateFullRevision?.slots, candidateFullRevision?.slots?.main);
return candidateFullRevision.slots.main.content.includes(text);
} catch (e) {
reject('測試內容: ' + e);
}
});
if (foundIndex === undefined) {
reject("不能尋找這個內容");
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;
/*
* https://doc.wikimedia.org/mediawiki-core/master/js/module-jquery.textSelection.html
* We cannot use wikitextEditor.value here, because this textarea is hidden and
* is not updated with CodeMirror. Therefore, the selection in CodeMirror becomes
* desynced from the text in wikitextEditor.
* However, CodeMirror does respond to textSelection "commands" sent to wikitextEditor.
* The responses correspond with up-to-date wikitext in CodeMirror.
* For reference, see https://en.wikipedia.org/wiki/MediaWiki:Gadget-charinsert-core.js#L-251--L-258
*/
const $editor = $(wikitextEditor);
while ($editor.textSelection('getSelection').endsWith('\n')) {
const [selectionStart, selectionEnd] = $editor.textSelection('getCaretPosition', {startAndEnd:true});
$editor.textSelection('setSelection', {start: selectionStart, end:(selectionEnd - 1)});
}
const originalSelection = $(wikitextEditor).textSelection('getSelection');
let selection = originalSelection;
debug(`doAddUnsignedTemplate: getSelection: '${selection}'`);
selection = selection.replace(new RegExp('[\\s\\S]*\\d\\d:\\d\\d, \\d+ (' + months.join('|') + ') \\d\\d\\d\\d \\(UTC\\)([<]/small[>])?'), '');
selection = selection.replace(/[\s\S]*\n=+.*=+\s*\n/, '');
selection = selection.replace(/^\s+|\s+$/g, '');
debug(`doAddUnsignedTemplate: getSelection filtered: '${selection}'`);

// TODO maybe migrate to https://www.mediawiki.org/wiki/OOUI/Windows/Message_Dialogs
const mainDialog = $('<div>檢查中...</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(selection, 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:${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,
minWidth: document.body.clientWidth / 5,
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: '/index.php?diff=prev&oldid=' + revid,
target: '_blank'
}).text(`已尋找版本 (版本=${searcherResult.index})`),
" may be a revert: ",
comment
);
});
}

function formatTimestamp(timestamp) {
// return new Date(timestamp).toLocaleString();
return timestamp;
}

function revisionByteSize(fullRevision) {
if (fullRevision == null) {
return null;
}
return new Blob([fullRevision.slots.main.content]).size;
}

function formatSizeChange(afterSize, beforeSize) {
const change = afterSize - beforeSize;
const title = `編輯後共${afterSize}位元組`;
const titleAttribute = `title="${title}"`;
let style = '';
let changeText = "" + change;
if (change > 0) {
changeText = "+" + change;
style = 'color: var(--color-content-added,#006400);';
}
if (change < 0) {
// use proper minus sign ([[Plus and minus signs#Minus sign]])
changeText = "−" + Math.abs(change);
style = 'color: var(--color-content-removed,#8b0000);';
}
if (Math.abs(change) > 500) {
// [[Help:Watchlist#How to read a watchlist (or recent changes)]]
style = style + "font-weight:bold;";
}
changeText = `(${changeText})`;
return $('<span>').text(changeText).attr('style', style).attr('title', title);
}

async function reportNormalSearcherResultToUser(searcherResult, useCb, keepLookingCb, cancelCb) {
const fullRevision = searcherResult.fullRevision;
const user = fullRevision.user;
const revid = fullRevision.revid;
const comment = fullRevision.parsedcomment;
const afterSize = revisionByteSize(fullRevision);
const beforeSize = revisionByteSize(await searcher.getContentLoader().loadContent(searcherResult.index + 1));
reportSearcherResultToUser(searcherResult, "Do you want to use this?", useCb, keepLookingCb, cancelCb, () => {
return $('<div>').append(
"Found a revision: ",
$('<a>').prop({
href: '/index.php?diff=prev&oldid=' + revid,
target: '_blank'
}).text(`[[Special:Diff/${revid}]] (index=${searcherResult.index})`),
$('<br/>'), '• ', formatTimestamp(fullRevision.timestamp),
$('<br/>'), "• by ", $('<a>').prop({
href: '/wiki/Special:Contributions/' + user.replaceAll(' ', '_'),
target: '_blank'
}).text(`User:${user}`),
$('<br/>'), "• ", formatSizeChange(afterSize, beforeSize),
$('<br/>'), "• edit summary: ", $('<i>').html(comment)
);
});
}

function searchFromIndex(index) {
if (selection == undefined || selection == '') {
mainDialog.html(formatErrorSpan("請選擇一個未簽名的訊息") +
" Selected: <code>" + originalSelection + "</code>");
return;
}
searcher.findRevisionWhenTextAdded(selection, 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(`搜尋器不能尋找已要求的索引=${index}. Got error:`, rejection);
if (!mainDialog.dialog('isOpen')) {
// user clicked [cancel]
return;
}
mainDialog.html(formatErrorSpan(`${rejection}`));
});
}

searchFromIndex(0);
}

window.unsignedHelperAddUnsignedTemplate = function(event) {
event.preventDefault();
event.stopPropagation();
mw.loader.using(['mediawiki.util', 'jquery.ui'], doAddUnsignedTemplate);
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();
}
})();

Latest revision as of 09:33, 1 September 2025

/*
 * This is a fork of https://en.wikipedia.org/w/index.php?title=User:Anomie/unsignedhelper.js&oldid=1219219971
 */
(function () {
	const DEBUG = false;
	const LOG_PREFIX = `[簽名工具]:`;

	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 = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月'];

	const CONFIG = {
		undated: 'Undated', // [[Template:Undated]]
		unsignedLoggedIn: '未签名', // [[Template:Unsigned]]
		unsignedIp: '未签名', // [[Template:Unsigned]]
	};

	if (mw.config.get('wgAction') !== 'edit' && mw.config.get('wgAction') !== 'submit' && document.getElementById("editform") == null) {
		info('未編輯頁面。暫停中。');
		return;
	}

	info('載入中...');

	function formatErrorSpan(errorMessage) {
		return `<span style="color:maroon;"><b>錯誤:</b> ${errorMessage}</span>`;
	}

	/**
	 * Batch size for {@link LazyRevisionIdsLoader}.
	 */
	const LAZY_REVISION_LOADING_INTERVAL = 50;

	/**
	 * Lazily loads revision IDs for a page. The loading is done linearly,
	 * in batches of the size of {@link LAZY_REVISION_LOADING_INTERVAL}.
	 * This is relatively fast because we are not loading the heavy contents
	 * of the page, only the metadata.
	 * 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) {
			debug(`#getLastLoadedInterval(${upToIndex}): `, this.#historyIntervalPromises.length);
			let i = 0;
			while (this.#historyIntervalPromises[i] != undefined && i <= upToIndex) {
				i++;
			}
			debug(`#getLastLoadedInterval(${upToIndex}) = ${i}`);
			return [i, this.#historyIntervalPromises[i - 1]];
		}

		#createIntervalFromResponse(response) {
			if ('missing' in response.query.pages[0]) {
				return undefined;
			}
			const interval = {
				rvcontinue: response.continue?.rvcontinue,
				revisions: response.query.pages[0].revisions,
			};
			if (response.batchcomplete) {
				// remember that MediaWiki has no more revisions to return
				interval.batchcomplete = true;
			} else {
				interval.batchcomplete = false;
			}
			return interval;
		}

		async #loadIntervalsRecursive(startIndex, targetIndex, rvcontinue) {
			const logMsgPrefix = `#loadIntervalsRecursive(${startIndex}, ${targetIndex}, '${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(`${logMsgPrefix} Q =`, intervalQuery);
				this.#api.get(intervalQuery).then(async (response) => {
					try {
						if (DEBUG) {
							debug(`${logMsgPrefix} R =`, response);
						}
						const interval = this.#createIntervalFromResponse(response);
						this.#historyIntervalPromises[startIndex] = Promise.resolve(interval);
						if (startIndex == targetIndex) {
							// we've hit the limit of what we want to load so far
							resolve(interval);
							return;
						}
						if (interval.batchcomplete) {
							// reached the end of batch loading => cannot ask for one more
							// for convenience, fill the rest of the array with undefined
							for (let i = startIndex + 1; i <= targetIndex; i++) {
								this.#historyIntervalPromises[i] = Promise.resolve(undefined);
							}
							info(`${logMsgPrefix}: This is the last batch returned by MediaWiki`);
							if (targetIndex <= startIndex) {
								error(`${logMsgPrefix}: something went very wrong`);
							}
							resolve(undefined);
							return;
						}
						// .batchcomplete has not been reached, call for one more interval (recursive)
						const ignored = await this.#loadIntervalsRecursive(startIndex + 1, targetIndex, interval.rvcontinue);
						if (this.#historyIntervalPromises[targetIndex] == undefined) {
							resolve(undefined);
							return;
						}
						this.#historyIntervalPromises[targetIndex].then(
							result => resolve(result),
							rejection => reject(rejection)
						);
					} catch (e) {
						reject('loadIntervalsRecursive: ' + e);
					}
				}, rejection => {
					reject('loadIntervalsRecursive via api: ' + rejection);
				});
			});
		}

		async #loadInterval(intervalIndex) {
			const [firstNotLoadedIntervalIndex, latestLoadedIntervalPromise] = this.#getLastLoadedInterval(intervalIndex);
			if (firstNotLoadedIntervalIndex > intervalIndex) {
				return this.#historyIntervalPromises[intervalIndex];
			}
			if (await latestLoadedIntervalPromise?.then(interval => interval.batchcomplete)) {
				// latest request returned the last batch in the batch loading of revisions
				return Promise.resolve(undefined);
			}
			const rvcontinue = await latestLoadedIntervalPromise?.then(interval => interval.rvcontinue);
			debug(`#loadInterval(${intervalIndex}): ${firstNotLoadedIntervalIndex}, ${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;
		}

		#revisionsToString(revisions) {
			if (!revisions) {
				return "<undefined revisions>";
			}
			return Array.from(revisions).map((revision, index) => {
				return `[${index}]={revid=${revision.revid} by User:${revision.user}}`
			}).join(", ");
		}

		/**
		 * @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);
				debug(`loadRevision: loading from interval #${intervalIndex}...`);
				try {
					const interval = await this.#loadInterval(intervalIndex);
					if (DEBUG) {
						debug(`loadRevision: loaded the interval#${intervalIndex} with revisions: (length=${interval?.revisions?.length}) ${this.#revisionsToString(interval?.revisions)}`);
					}
					if (interval == undefined) {
						resolve(undefined);
						return;
					}
					const indexInInterval = this.#indexToIndexInInterval(index);
					if (DEBUG) {
						debug(`loadRevision: from the above interval, looking at [${indexInInterval}]`);
					}
					const theRevision = interval.revisions[indexInInterval];
					debug('loadRevision: loaded revision', index, theRevision);
					resolve(theRevision);
				} catch (e) {
					reject('loadRevision: ' + e);
				}
			});
			this.#indexedRevisionPromises[index] = promise;
			return promise;
		}
	}

	/**
	 * Lazily loads full revisions (full wikitext and metadata) for a page.
	 * Gives zero-indexed access to the revisions. Zeroth revision is the newest revision.
	 * Loaded revisions are cached to speed up consecutive requests about the
	 * same page.
	 */
	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(`exponentialSearch 的錯誤參數(${lower}, ${upper}, ${candidateIndex}).`);
		}
		if (lower === upper && lower === candidateIndex) {
			throw new Error("不能找到");
		}
		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;
		}

		getContentLoader() {
			return this.#contentLoader;
		}

		/**
		 * Uses an exponential initial search followed by a binary search to find
		 * a snippet of text, which was added to the page.
		 */
		async findRevisionWhenTextAdded(text, startIndex) {
			info(`findRevisionWhenTextAdded(startIndex=${startIndex}): searching for '${text}'`);
			return new Promise(async (resolve, reject) => {
				try {
					const startRevision = await this.#contentLoader.loadRevisionId(startIndex);
					if (startRevision == undefined) {
						if (startIndex === 0) {
							reject("不能找到最新版本。此頁面存在嗎?");
						} else {
							reject(`不能尋找開始版本 (版本=${startIndex}).`);
						}
						return;
					}
					if (startIndex === 0) {
						const latestFullRevision = await this.#contentLoader.loadContent(startIndex);
						if (!latestFullRevision.slots.main.content.includes(text)) {
							reject("不能尋找最新版本的內容。你編輯了嗎?");
							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?.slots?.main?.content == undefined) {
								/*
								 * TODO can we distinguish between
								 * - `candidateIndex` is out of bounds of the history
								 * vs
								 * - `candidateIndex` is obscured from current user ([[WP:REVDEL]] or [[WP:SUPPRESS]])
								 * ?
								 */
								warn('測試功能:不能載入candidateIndex的內容 = ' + candidateIndex);
								return undefined;
							}
							// debug('testFunc: checking text of revision:', candidateFullRevision, candidateFullRevision?.slots, candidateFullRevision?.slots?.main);
							return candidateFullRevision.slots.main.content.includes(text);
						} catch (e) {
							reject('測試內容: ' + e);
						}
					});
					if (foundIndex === undefined) {
						reject("不能尋找這個內容");
						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;
		/*
		 * https://doc.wikimedia.org/mediawiki-core/master/js/module-jquery.textSelection.html
		 * We cannot use wikitextEditor.value here, because this textarea is hidden and
		 * is not updated with CodeMirror. Therefore, the selection in CodeMirror becomes
		 * desynced from the text in wikitextEditor.
		 * However, CodeMirror does respond to textSelection "commands" sent to wikitextEditor.
		 * The responses correspond with up-to-date wikitext in CodeMirror.
		 * For reference, see https://en.wikipedia.org/wiki/MediaWiki:Gadget-charinsert-core.js#L-251--L-258
		 */
		const $editor = $(wikitextEditor);
		while ($editor.textSelection('getSelection').endsWith('\n')) {
			const [selectionStart, selectionEnd] = $editor.textSelection('getCaretPosition', {startAndEnd:true});
			$editor.textSelection('setSelection', {start: selectionStart, end:(selectionEnd - 1)});
		}
		const originalSelection = $(wikitextEditor).textSelection('getSelection');
		let selection = originalSelection;
		debug(`doAddUnsignedTemplate: getSelection: '${selection}'`);
		selection = selection.replace(new RegExp('[\\s\\S]*\\d\\d:\\d\\d, \\d+ (' + months.join('|') + ') \\d\\d\\d\\d \\(UTC\\)([<]/small[>])?'), '');
		selection = selection.replace(/[\s\S]*\n=+.*=+\s*\n/, '');
		selection = selection.replace(/^\s+|\s+$/g, '');
		debug(`doAddUnsignedTemplate: getSelection filtered: '${selection}'`);

		// TODO maybe migrate to https://www.mediawiki.org/wiki/OOUI/Windows/Message_Dialogs
		const mainDialog = $('<div>檢查中...</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(selection, 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:${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,
				minWidth: document.body.clientWidth / 5,
				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: '/index.php?diff=prev&oldid=' + revid,
						target: '_blank'
					}).text(`已尋找版本 (版本=${searcherResult.index})`),
					" may be a revert: ",
					comment
				);
			});
		}

		function formatTimestamp(timestamp) {
			// return new Date(timestamp).toLocaleString();
			return timestamp;
		}

		function revisionByteSize(fullRevision) {
			if (fullRevision == null) {
				return null;
			}
			return new Blob([fullRevision.slots.main.content]).size;
		}

		function formatSizeChange(afterSize, beforeSize) {
			const change = afterSize - beforeSize;
			const title = `編輯後共${afterSize}位元組`;
			const titleAttribute = `title="${title}"`;
			let style = '';
			let changeText = "" + change;
			if (change > 0) {
				changeText = "+" + change;
				style = 'color: var(--color-content-added,#006400);';
			}
			if (change < 0) {
				// use proper minus sign ([[Plus and minus signs#Minus sign]])
				changeText = "−" + Math.abs(change);
				style = 'color: var(--color-content-removed,#8b0000);';
			}
			if (Math.abs(change) > 500) {
				// [[Help:Watchlist#How to read a watchlist (or recent changes)]]
				style = style + "font-weight:bold;";
			}
			changeText = `(${changeText})`;
			return $('<span>').text(changeText).attr('style', style).attr('title', title);
		}

		async function reportNormalSearcherResultToUser(searcherResult, useCb, keepLookingCb, cancelCb) {
			const fullRevision = searcherResult.fullRevision;
			const user = fullRevision.user;
			const revid = fullRevision.revid;
			const comment = fullRevision.parsedcomment;
			const afterSize = revisionByteSize(fullRevision);
			const beforeSize = revisionByteSize(await searcher.getContentLoader().loadContent(searcherResult.index + 1));
			reportSearcherResultToUser(searcherResult, "Do you want to use this?", useCb, keepLookingCb, cancelCb, () => {
				return $('<div>').append(
					"Found a revision: ",
					$('<a>').prop({
						href: '/index.php?diff=prev&oldid=' + revid,
						target: '_blank'
					}).text(`[[Special:Diff/${revid}]] (index=${searcherResult.index})`),
					$('<br/>'), '• ', formatTimestamp(fullRevision.timestamp),
					$('<br/>'), "• by ", $('<a>').prop({
						href: '/wiki/Special:Contributions/' + user.replaceAll(' ', '_'),
						target: '_blank'
					}).text(`User:${user}`),
					$('<br/>'), "• ", formatSizeChange(afterSize, beforeSize),
					$('<br/>'), "• edit summary: ", $('<i>').html(comment)
				);
			});
		}

		function searchFromIndex(index) {
			if (selection == undefined || selection == '') {
				mainDialog.html(formatErrorSpan("請選擇一個未簽名的訊息") +
					" Selected: <code>" + originalSelection + "</code>");
				return;
			}
			searcher.findRevisionWhenTextAdded(selection, 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(`搜尋器不能尋找已要求的索引=${index}. Got error:`, rejection);
				if (!mainDialog.dialog('isOpen')) {
					// user clicked [cancel]
					return;
				}
				mainDialog.html(formatErrorSpan(`${rejection}`));
			});
		}

		searchFromIndex(0);
	}

	window.unsignedHelperAddUnsignedTemplate = function(event) {
		event.preventDefault();
		event.stopPropagation();
		mw.loader.using(['mediawiki.util', 'jquery.ui'], doAddUnsignedTemplate);
		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();
	}
})();