User:Bosco/Unsigned helper.js: Difference between revisions
From Test Wiki
Content deleted Content added
doAddUnsignedTemplate: improve filtering of the selection to filter out output of Template:Unsigned and Template:Unsigned IP – when the output ends in a closing tag </small> |
mNo edit summary |
||
| (11 intermediate revisions by 2 users not shown) | |||
| Line 3: | Line 3: | ||
*/ |
*/ |
||
(function () { |
(function () { |
||
const |
const DEBUG = false; |
||
const LOG_PREFIX = `[簽名工具]:`; |
|||
function error(...toLog) { |
function error(...toLog) { |
||
| Line 21: | Line 22: | ||
} |
} |
||
const months = [' |
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']; |
||
const CONFIG = { |
const CONFIG = { |
||
undated: 'Undated', // [[Template:Undated]] |
undated: 'Undated', // [[Template:Undated]] |
||
unsignedLoggedIn: ' |
unsignedLoggedIn: '未签名', // [[Template:Unsigned]] |
||
unsignedIp: ' |
unsignedIp: '未签名', // [[Template:Unsigned]] |
||
}; |
}; |
||
if (mw.config.get('wgAction') !== 'edit' && mw.config.get('wgAction') !== 'submit' && document.getElementById("editform") == null) { |
if (mw.config.get('wgAction') !== 'edit' && mw.config.get('wgAction') !== 'submit' && document.getElementById("editform") == null) { |
||
info(' |
info('未編輯頁面。暫停中。'); |
||
return; |
return; |
||
} |
} |
||
info(' |
info('載入中...'); |
||
function formatErrorSpan(errorMessage) { |
function formatErrorSpan(errorMessage) { |
||
return `<span style="color:maroon;"><b> |
return `<span style="color:maroon;"><b>錯誤:</b> ${errorMessage}</span>`; |
||
} |
} |
||
/** |
|||
* Batch size for {@link LazyRevisionIdsLoader}. |
|||
*/ |
|||
const LAZY_REVISION_LOADING_INTERVAL = 50; |
const LAZY_REVISION_LOADING_INTERVAL = 50; |
||
/** |
/** |
||
* Lazily loads revision IDs for a page. |
* 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. |
* Gives zero-indexed access to the revisions. Zeroth revision is the newest revision. |
||
*/ |
*/ |
||
| Line 107: | Line 114: | ||
this.#api.get(intervalQuery).then(async (response) => { |
this.#api.get(intervalQuery).then(async (response) => { |
||
try { |
try { |
||
if (DEBUG) { |
|||
// debug(`${logMsgPrefix} R =`, response); |
|||
debug(`${logMsgPrefix} R =`, response); |
|||
} |
|||
const interval = this.#createIntervalFromResponse(response); |
const interval = this.#createIntervalFromResponse(response); |
||
this.#historyIntervalPromises[startIndex] = Promise.resolve(interval); |
this.#historyIntervalPromises[startIndex] = Promise.resolve(interval); |
||
| Line 190: | Line 199: | ||
try { |
try { |
||
const interval = await this.#loadInterval(intervalIndex); |
const interval = await this.#loadInterval(intervalIndex); |
||
if (DEBUG) { |
|||
debug(`loadRevision: loaded the interval#${intervalIndex} with revisions: (length=${interval?.revisions?.length}) ${this.#revisionsToString(interval?.revisions)}`); |
|||
debug(`loadRevision: loaded the interval#${intervalIndex} with revisions: (length=${interval?.revisions?.length}) ${this.#revisionsToString(interval?.revisions)}`); |
|||
} |
|||
if (interval == undefined) { |
if (interval == undefined) { |
||
resolve(undefined); |
resolve(undefined); |
||
| Line 196: | Line 207: | ||
} |
} |
||
const indexInInterval = this.#indexToIndexInInterval(index); |
const indexInInterval = this.#indexToIndexInInterval(index); |
||
if (DEBUG) { |
|||
debug(`loadRevision: from the above interval, looking at [${indexInInterval}]`); |
|||
debug(`loadRevision: from the above interval, looking at [${indexInInterval}]`); |
|||
} |
|||
const theRevision = interval.revisions[indexInInterval]; |
const theRevision = interval.revisions[indexInInterval]; |
||
debug('loadRevision: loaded revision', index, theRevision); |
debug('loadRevision: loaded revision', index, theRevision); |
||
| Line 210: | Line 223: | ||
/** |
/** |
||
* Lazily loads full revisions (wikitext |
* Lazily loads full revisions (full wikitext and metadata) for a page. |
||
* Gives zero-indexed access to the revisions. Zeroth revision is the newest revision. |
* 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 { |
class LazyFullRevisionsLoader { |
||
| Line 288: | Line 303: | ||
async function exponentialSearch(lower, upper, candidateIndex, testFunc) { |
async function exponentialSearch(lower, upper, candidateIndex, testFunc) { |
||
if (upper === null && lower === candidateIndex) { |
if (upper === null && lower === candidateIndex) { |
||
throw new Error(` |
throw new Error(`exponentialSearch 的錯誤參數(${lower}, ${upper}, ${candidateIndex}).`); |
||
} |
} |
||
if (lower === upper && lower === candidateIndex) { |
if (lower === upper && lower === candidateIndex) { |
||
throw new Error(" |
throw new Error("不能找到"); |
||
} |
} |
||
const progressMessage = `Examining [${lower}, ${upper ? upper : '...'}]. Current candidate: ${candidateIndex}`; |
const progressMessage = `Examining [${lower}, ${upper ? upper : '...'}]. Current candidate: ${candidateIndex}`; |
||
| Line 327: | Line 342: | ||
} |
} |
||
getContentLoader() { |
|||
return this.#contentLoader; |
|||
info('#findMaxIndex: this.#progressCallback = ', this.#progressCallback); |
|||
return exponentialSearch(0, null, 1, async (candidateIndex, progressInfo) => { |
|||
this.#progressCallback(progressInfo + ' (max search)'); |
|||
const candidateRevision = await this.#contentLoader.loadRevisionId(candidateIndex); |
|||
if (candidateRevision == undefined) { |
|||
return false; |
|||
} |
|||
return true; |
|||
}); |
|||
} |
} |
||
/** |
|||
* 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) { |
async findRevisionWhenTextAdded(text, startIndex) { |
||
info(`findRevisionWhenTextAdded(startIndex=${startIndex}): searching for '${text}'`); |
info(`findRevisionWhenTextAdded(startIndex=${startIndex}): searching for '${text}'`); |
||
| Line 346: | Line 357: | ||
if (startRevision == undefined) { |
if (startRevision == undefined) { |
||
if (startIndex === 0) { |
if (startIndex === 0) { |
||
reject(" |
reject("不能找到最新版本。此頁面存在嗎?"); |
||
} else { |
} else { |
||
reject(` |
reject(`不能尋找開始版本 (版本=${startIndex}).`); |
||
} |
} |
||
return; |
return; |
||
| Line 355: | Line 366: | ||
const latestFullRevision = await this.#contentLoader.loadContent(startIndex); |
const latestFullRevision = await this.#contentLoader.loadContent(startIndex); |
||
if (!latestFullRevision.slots.main.content.includes(text)) { |
if (!latestFullRevision.slots.main.content.includes(text)) { |
||
reject(" |
reject("不能尋找最新版本的內容。你編輯了嗎?"); |
||
return; |
return; |
||
} |
} |
||
} |
} |
||
const maxIndex = (startIndex === 0) ? null : (await this.#findMaxIndex()); |
|||
const foundIndex = await exponentialSearch(startIndex, null, startIndex + 10, async (candidateIndex, progressInfo) => { |
|||
// const maxIndex = (await this.#findMaxIndex()); |
|||
info('findRevisionWhenTextAdded: maxIndex =', maxIndex); |
|||
const foundIndex = await exponentialSearch(startIndex, maxIndex, startIndex + 10, async (candidateIndex, progressInfo) => { |
|||
try { |
try { |
||
this.#progressCallback(progressInfo); |
this.#progressCallback(progressInfo); |
||
const candidateFullRevision = await this.#contentLoader.loadContent(candidateIndex); |
const candidateFullRevision = await this.#contentLoader.loadContent(candidateIndex); |
||
if (candidateFullRevision?.slots?.main?.content == undefined) { |
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; |
return undefined; |
||
} |
} |
||
| Line 372: | Line 389: | ||
return candidateFullRevision.slots.main.content.includes(text); |
return candidateFullRevision.slots.main.content.includes(text); |
||
} catch (e) { |
} catch (e) { |
||
reject(' |
reject('測試內容: ' + e); |
||
} |
} |
||
}); |
}); |
||
if (foundIndex === undefined) { |
if (foundIndex === undefined) { |
||
reject(" |
reject("不能尋找這個內容"); |
||
return; |
return; |
||
} |
} |
||
| Line 508: | Line 525: | ||
* For reference, see https://en.wikipedia.org/wiki/MediaWiki:Gadget-charinsert-core.js#L-251--L-258 |
* 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'); |
const originalSelection = $(wikitextEditor).textSelection('getSelection'); |
||
let selection = originalSelection; |
let selection = originalSelection; |
||
| Line 517: | Line 539: | ||
// TODO maybe migrate to https://www.mediawiki.org/wiki/OOUI/Windows/Message_Dialogs |
// TODO maybe migrate to https://www.mediawiki.org/wiki/OOUI/Windows/Message_Dialogs |
||
const mainDialog = $('<div> |
const mainDialog = $('<div>檢查中...</div>').dialog({ |
||
buttons: { |
buttons: { |
||
Cancel: function () { |
Cancel: function () { |
||
| Line 558: | Line 580: | ||
.dialog({ |
.dialog({ |
||
title: dialogTitle, |
title: dialogTitle, |
||
minWidth: document.body.clientWidth / 5, |
|||
modal: true, |
modal: true, |
||
buttons: { |
buttons: { |
||
| Line 584: | Line 607: | ||
"The ", |
"The ", |
||
$('<a>').prop({ |
$('<a>').prop({ |
||
href: ' |
href: '/index.php?diff=prev&oldid=' + revid, |
||
target: '_blank' |
target: '_blank' |
||
}).text(` |
}).text(`已尋找版本 (版本=${searcherResult.index})`), |
||
" may be a revert: ", |
" may be a revert: ", |
||
comment |
comment |
||
| Line 593: | Line 616: | ||
} |
} |
||
function formatTimestamp(timestamp) { |
|||
function reportNormalSearcherResultToUser(searcherResult, useCb, keepLookingCb, cancelCb) { |
|||
// 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 fullRevision = searcherResult.fullRevision; |
||
const user = fullRevision.user; |
|||
const revid = fullRevision.revid; |
const revid = fullRevision.revid; |
||
const comment = fullRevision.parsedcomment; |
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, () => { |
reportSearcherResultToUser(searcherResult, "Do you want to use this?", useCb, keepLookingCb, cancelCb, () => { |
||
return $('<div>').append( |
return $('<div>').append( |
||
"Found a revision: ", |
"Found a revision: ", |
||
$('<a>').prop({ |
$('<a>').prop({ |
||
href: ' |
href: '/index.php?diff=prev&oldid=' + revid, |
||
target: '_blank' |
target: '_blank' |
||
}).text(`[[Special:Diff/${revid}]] (index=${searcherResult.index})`), |
}).text(`[[Special:Diff/${revid}]] (index=${searcherResult.index})`), |
||
$('<br/>'), '• ', formatTimestamp(fullRevision.timestamp), |
|||
".", |
|||
$('<br/>'), |
$('<br/>'), "• by ", $('<a>').prop({ |
||
href: '/wiki/Special:Contributions/' + user.replaceAll(' ', '_'), |
|||
"Comment: ", |
|||
target: '_blank' |
|||
}).text(`User:${user}`), |
|||
$('<br/>'), "• ", formatSizeChange(afterSize, beforeSize), |
|||
$('<br/>'), "• edit summary: ", $('<i>').html(comment) |
|||
); |
); |
||
}); |
}); |
||
| Line 614: | Line 678: | ||
function searchFromIndex(index) { |
function searchFromIndex(index) { |
||
if (selection == undefined || selection == '') { |
if (selection == undefined || selection == '') { |
||
mainDialog.html(formatErrorSpan(" |
mainDialog.html(formatErrorSpan("請選擇一個未簽名的訊息") + |
||
" Selected: <code>" + originalSelection + "</code>"); |
" Selected: <code>" + originalSelection + "</code>"); |
||
return; |
return; |
||
| Line 640: | Line 704: | ||
reportNormalSearcherResultToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback); |
reportNormalSearcherResultToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback); |
||
}, rejection => { |
}, rejection => { |
||
error(` |
error(`搜尋器不能尋找已要求的索引=${index}. Got error:`, rejection); |
||
if (!mainDialog.dialog('isOpen')) { |
if (!mainDialog.dialog('isOpen')) { |
||
// user clicked [cancel] |
// user clicked [cancel] |
||