User:Bosco/Unsigned helper.js: Difference between revisions
Jump to navigation
Jump to search
Content deleted Content added
makeUnsignedTemplate: refactor |
mNo edit summary |
||
| (38 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) { |
|||
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()} ${UnsignedHelper.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]])"; |
|||
} |
|||
function appendToEditSummary(newSummary) { |
|||
const editSummaryField = $("#wpSummary:first"); |
const editSummaryField = $("#wpSummary:first"); |
||
if (editSummaryField.length == 0) { |
if (editSummaryField.length == 0) { |
||
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 |
|||
addUnsignedTemplate: 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]; |
|||
}); |
|||
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+ (' + UnsignedHelper.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: { |
|||
"The ", |
|||
$('<a>').prop({ |
|||
href: '/index.php?diff=prev&oldid=' + revid, |
|||
titles: mw.config.get('wgPageName'), |
|||
target: '_blank' |
|||
}).text(`已尋找版本 (版本=${searcherResult.index})`), |
|||
rvprop: 'ids|timestamp|user|parsedcomment|content', |
|||
" may be a revert: ", |
|||
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 = 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(`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 = ''; |
|||
} |
|||
let changeText = "" + change; |
|||
if (change > 0) { |
|||
var t = UnsignedHelper.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]]) |
|||
UnsignedHelper.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'); |
|||
$('<a>').prop({ |
|||
href: '/index.php?diff=prev&oldid=' + revid, |
|||
}, |
|||
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' |
|||
}).text(`User:${user}`), |
|||
$('<br/>'), "• ", formatSizeChange(afterSize, beforeSize), |
|||
cb(); |
|||
$('<br/>'), "• edit summary: ", $('<i>').html(comment) |
|||
} |
|||
); |
|||
}); |
|||
revid = rr.revid; |
|||
} |
|||
user = rr.user; |
|||
ts = rr.timestamp; |
|||
function searchFromIndex(index) { |
|||
comment = rr.parsedcomment; |
|||
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] |
|||
} |
|||
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; |
|||
}; |
|||
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 = {}; |
|||
} |
|||
if (!window.charinsertCustom['Insert']) |
|||
window.charinsertCustom |
if (!window.charinsertCustom.Insert) { |
||
window.charinsertCustom |
window.charinsertCustom.Insert = ''; |
||
} |
|||
if (!window.charinsertCustom['Wiki markup']) |
|||
window.charinsertCustom |
window.charinsertCustom.Insert += ' {{unsigned}}\x10unsignedHelperAddUnsignedTemplate'; |
||
window.charinsertCustom['Wiki markup'] |
if (!window.charinsertCustom['Wiki markup']) { |
||
window.charinsertCustom['Wiki markup'] = ''; |
|||
if (window.updateEditTools) |
|||
} |
|||
window.updateEditTools(); |
|||
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();
}
})();