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

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


function makeUnsignedTemplate(user, timestamp, template) {
function error(...toLog) {
console.error(LOG_PREFIX, ...toLog);
const ts = new Date(timestamp);
}
let h = ts.getUTCHours();

if (h < 10)
function warn(...toLog) {
h = '0' + h;
console.warn(LOG_PREFIX, ...toLog);
let m = ts.getUTCMinutes();
}
if (m < 10)

m = '0' + m;
function info(...toLog) {
const formattedTimestamp = `${h}:${m}, ${ts.getUTCDate()} ${months[ts.getUTCMonth()]} ${ts.getUTCFullYear()} (UTC)`;
console.info(LOG_PREFIX, ...toLog);
return '\x7b\x7bsubst:' + template + '|' + user + '|' + formattedTimestamp + '\x7d\x7d';
}

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]])";
}
}


Line 17: Line 479:
const editSummaryField = $("#wpSummary:first");
const editSummaryField = $("#wpSummary:first");
if (editSummaryField.length == 0) {
if (editSummaryField.length == 0) {
console.warn('Cannot find edit summary text field.');
warn('Cannot find edit summary text field.');
return;
return;
}
}
const oldText = editSummaryField.val().trimEnd();
// get text without trailing whitespace
// get text without trailing whitespace
let oldText = editSummaryField.val().trimEnd();
const ad = constructAd();
if (oldText.includes(ad)) {
oldText = oldText.replace(ad, '');
}
let newText = "";
let newText = "";
if (oldText.match(/[*]\/$/)) {
if (oldText.match(/[*]\/$/)) {
Line 31: Line 497:
newText = newSummary;
newText = newSummary;
}
}
editSummaryField.val(newText);
editSummaryField.val(newText + ad);
}
}


// kept outside of doAddUnsignedTemplate() to keep all the caches
window.unsignedHelperAddUnsignedTemplate = function(evt) {
let searcher;
mw.loader.using(['mediawiki.util', 'jquery.ui'], function () {
function getSearcher() {
var f = document.getElementById('editform');
if (searcher) {
var e = f.elements.wpTextbox1;
return searcher;
var pos = $(e).textSelection('getCaretPosition', { startAndEnd: true });
}
var txt;
const pagename = mw.config.get('wgPageName');
if (pos[0] != pos[1]) {
searcher = new PageHistoryContentSearcher(pagename, progressInfo => {
txt = e.value.substring(pos[0], pos[1]);
info('Default progress callback', progressInfo);
pos = pos[1];
} else {
});
pos = pos[1];
return searcher;
}
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+ (' + months.join('|') + ') \\d\\d\\d\\d \\(UTC\\)'), '');
txt = txt.replace(/[\s\S]*\n=+.*=+\s*\n/, '');
}
txt = txt.replace(/^\s+|\s+$/g, '');


async function doAddUnsignedTemplate() {
var rvct = 1;
const form = document.getElementById('editform');
var dialog = $('<div>Examining revision 1...</div>').dialog({
const wikitextEditor = form.elements.wpTextbox1;
buttons: {
/*
Cancel: function () {
* https://doc.wikimedia.org/mediawiki-core/master/js/module-jquery.textSelection.html
dialog.dialog('close');
* 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,
modal: true,
buttons: {
title: 'Adding \x7b\x7bunsigned\x7d\x7d'
"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) {
var revid, user, ts, comment, template;
const fullRevision = searcherResult.fullRevision;
var q = {
const revid = fullRevision.revid;
url: mw.util.wikiScript('api'),
const comment = fullRevision.parsedcomment;
dataType: 'json',
reportSearcherResultToUser(searcherResult, "Possible revert!", useCb, keepLookingCb, cancelCb, () => {
type: 'POST',
return $('<div>').append(
data: {
format: 'json',
"The ",
action: 'query',
$('<a>').prop({
href: '/index.php?diff=prev&oldid=' + revid,
titles: mw.config.get('wgPageName'),
prop: 'revisions',
target: '_blank'
}).text(`已尋找版本 (版本=${searcherResult.index})`),
rvprop: 'ids|timestamp|user|parsedcomment|content',
rvlimit: 1,
" may be a revert: ",
rawcontinue: 1
comment
},
);
});
success: function (r, sts, xhr) {
}
if (!dialog.dialog('isOpen'))
return;


function formatTimestamp(timestamp) {
if (!r.query || !r.query.pages) {
// return new Date(timestamp).toLocaleString();
dialog.html('<span style="color:red"><b>Error:</b> Bad response from API</span>');
return timestamp;
if (window.console && typeof (window.console.error) == 'function')
}
window.console.error("Bad response", r);
return;
}


function revisionByteSize(fullRevision) {
for (var k in r.query.pages) {
if (fullRevision == null) {
var rr = r.query.pages[k].revisions[0];
return null;
var cont = function () {
}
if (r['query-continue'] && r['query-continue'].revisions) {
return new Blob([fullRevision.slots.main.content]).size;
dialog.html('Evaluating revision ' + (++rvct) + '...');
}
q.data.rvcontinue = r['query-continue'].revisions.rvcontinue;
$.ajax(q);
} else {
var t = 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 });
appendToEditSummary(`mark unsigned [[Special:Diff/${revid}]]`);
dialog.dialog('close');
}
};


function formatSizeChange(afterSize, beforeSize) {
if (typeof (rr['*']) != 'undefined' && rr['*'].indexOf(txt) < 0) {
const change = afterSize - beforeSize;
if (!user) {
const title = `編輯後共${afterSize}位元組`;
dialog.html('<span style="color:red"><b>Error:</b> Text was not found in the starting revision! Did you edit it?</span>');
const titleAttribute = `title="${title}"`;
return;
let style = '';
}
var cb = function () {
let changeText = "" + change;
if (change > 0) {
var t = makeUnsignedTemplate(user, ts, template);
changeText = "+" + change;
var tt = e.value.substr(0, pos).replace(/\s*$/, ' ') + t;
style = 'color: var(--color-content-added,#006400);';
e.value = tt + e.value.substr(pos);
}
$(e).textSelection('setSelection', {
if (change < 0) {
start: tt.length });
// use proper minus sign ([[Plus and minus signs#Minus sign]])
appendToEditSummary(`unsigned [[Special:Diff/${revid}]]`);
changeText = "−" + Math.abs(change);
dialog.dialog('close');
style = 'color: var(--color-content-removed,#8b0000);';
};
}
if (/reverted|undid/i.test(comment)) {
if (Math.abs(change) > 500) {
var dialog2 = $('<div>')
// [[Help:Watchlist#How to read a watchlist (or recent changes)]]
.append(
style = style + "font-weight:bold;";
'The ',
}
$('<a>').prop({
changeText = `(${changeText})`;
href: '/w/index.php?diff=prev&oldid=' + revid, target: '_blank' }).text('found revision'),
return $('<span>').text(changeText).attr('style', style).attr('title', title);
' may be a revert: ',
}
comment

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

ts = rr.timestamp;
function searchFromIndex(index) {
comment = rr.parsedcomment;
if (typeof (rr.anon) !== 'undefined') {
if (selection == undefined || selection == '') {
mainDialog.html(formatErrorSpan("請選擇一個未簽名的訊息") +
template = 'Unsigned IP';
" Selected: <code>" + originalSelection + "</code>");
} else if (typeof (rr.temp) !== 'undefined') {
return;
// Seems unlikely "temporary" users will have a user page, so this seems the better template for them for now.
}
template = 'Unsigned IP';
searcher.findRevisionWhenTextAdded(selection, index).then(searcherResult => {
} else {
if (!mainDialog.dialog('isOpen')) {
template = 'Unsigned';
// user clicked [cancel]
}
cont();
return;
}
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>');
}
}
info('Searcher found:', searcherResult);
};
const useCallback = () => { /* use */
if (f.elements.baseRevId)
applySearcherResult(searcherResult);
q.data.rvstartid = f.elements.baseRevId.value;
$.ajax(q);
};
const keepLookingCallback = () => { /* keep looking */
});
// recursive call from a differfent index: `+1` is very important here
evt.preventDefault();
searchFromIndex(searcherResult.index + 1);
evt.stopPropagation();
};
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;
return false;
}
}

if (!window.charinsertCustom)
if (!window.charinsertCustom) {
window.charinsertCustom = {};
window.charinsertCustom = {};
}
if (!window.charinsertCustom['Insert'])
window.charinsertCustom['Insert'] = '';
if (!window.charinsertCustom.Insert) {
window.charinsertCustom['Insert'] += ' \x7b\x7bunsigned\x7d\x7d\x10unsignedHelperAddUnsignedTemplate';
window.charinsertCustom.Insert = '';
}
if (!window.charinsertCustom['Wiki markup'])
window.charinsertCustom.Insert += ' {{unsigned}}\x10unsignedHelperAddUnsignedTemplate';
if (!window.charinsertCustom['Wiki markup']) {
window.charinsertCustom['Wiki markup'] = '';
window.charinsertCustom['Wiki markup'] = '';
}
window.charinsertCustom['Wiki markup'] += ' \x7b\x7bunsigned\x7d\x7d\x10unsignedHelperAddUnsignedTemplate';
window.charinsertCustom['Wiki markup'] += ' {{unsigned}}\x10unsignedHelperAddUnsignedTemplate';
if (window.updateEditTools)
if (window.updateEditTools) {
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();
	}
})();