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

From Test Wiki
Content deleted Content added
searchFromIndex: fix wrong reference to non-existent variable `text`. doAddUnsignedTemplate: Rename variable `txt` → `selection`
mNo edit summary
 
(13 intermediate revisions by 2 users not shown)
Line 3: Line 3:
*/
*/
(function () {
(function () {
const LOG_PREFIX = `[Unsigned Helper]:`;
const DEBUG = false;
const LOG_PREFIX = `[簽名工具]:`;


function error(...toLog) {
function error(...toLog) {
Line 21: Line 22:
}
}


const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
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: 'Unsigned', // [[Template:Unsigned]]
unsignedLoggedIn: '未签名', // [[Template:Unsigned]]
unsignedIp: 'Unsigned IP', // [[Template:Unsigned IP]]
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('Not editing a page. Aborting.');
info('未編輯頁面。暫停中。');
return;
return;
}
}


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


function formatErrorSpan(errorMessage) {
function formatErrorSpan(errorMessage) {
return `<span style="color:maroon;"><b>Error:</b> ${errorMessage}</span>`;
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, user, revid, tags, edit summary, etc) for a page.
* 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(`Wrong arguments for exponentialSearch (${lower}, ${upper}, ${candidateIndex}).`);
throw new Error(`exponentialSearch 的錯誤參數(${lower}, ${upper}, ${candidateIndex}).`);
}
}
if (lower === upper && lower === candidateIndex) {
if (lower === upper && lower === candidateIndex) {
throw new Error("Cannot find it");
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:
}
}


async #findMaxIndex() {
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("Cannot find the latest revision. Does this page exist?");
reject("不能找到最新版本。此頁面存在嗎?");
} else {
} else {
reject(`Cannot find the start revision (index=${startIndex}).`);
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("Cannot find text in the latest revision. Did you edit it?");
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('testFunc: ' + e);
reject('測試內容: ' + e);
}
}
});
});
if (foundIndex === undefined) {
if (foundIndex === undefined) {
reject("Cannot find this text.");
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
*/
*/
let selection = $(wikitextEditor).textSelection('getSelection');
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}'`);
debug(`doAddUnsignedTemplate: getSelection: '${selection}'`);
selection = selection.replace(new RegExp('[\\s\\S]*\\d\\d:\\d\\d, \\d+ (' + months.join('|') + ') \\d\\d\\d\\d \\(UTC\\)'), '');
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]*\n=+.*=+\s*\n/, '');
selection = selection.replace(/^\s+|\s+$/g, '');
selection = selection.replace(/^\s+|\s+$/g, '');
Line 516: 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>Examining...</div>').dialog({
const mainDialog = $('<div>檢查中...</div>').dialog({
buttons: {
buttons: {
Cancel: function () {
Cancel: function () {
Line 557: Line 580:
.dialog({
.dialog({
title: dialogTitle,
title: dialogTitle,
minWidth: document.body.clientWidth / 5,
modal: true,
modal: true,
buttons: {
buttons: {
Line 583: Line 607:
"The ",
"The ",
$('<a>').prop({
$('<a>').prop({
href: '/w/index.php?diff=prev&oldid=' + revid,
href: '/index.php?diff=prev&oldid=' + revid,
target: '_blank'
target: '_blank'
}).text(`found revision (index=${searcherResult.index})`),
}).text(`已尋找版本 (版本=${searcherResult.index})`),
" may be a revert: ",
" may be a revert: ",
comment
comment
Line 592: 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: '/w/index.php?diff=prev&oldid=' + revid,
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: ",
comment
target: '_blank'
}).text(`User:${user}`),
$('<br/>'), "• ", formatSizeChange(afterSize, beforeSize),
$('<br/>'), "• edit summary: ", $('<i>').html(comment)
);
);
});
});
Line 613: Line 678:
function searchFromIndex(index) {
function searchFromIndex(index) {
if (selection == undefined || selection == '') {
if (selection == undefined || selection == '') {
mainDialog.html(formatErrorSpan("Please select an unsigned message.") +
mainDialog.html(formatErrorSpan("請選擇一個未簽名的訊息") +
" Selected: <code>" + selection + "</code>");
" Selected: <code>" + originalSelection + "</code>");
return;
return;
}
}
Line 639: Line 704:
reportNormalSearcherResultToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback);
reportNormalSearcherResultToUser(searcherResult, useCallback, keepLookingCallback, cancelCallback);
}, rejection => {
}, rejection => {
error(`Searcher cannot find requested index=${index}. Got error:`, rejection);
error(`搜尋器不能尋找已要求的索引=${index}. Got error:`, rejection);
if (!mainDialog.dialog('isOpen')) {
if (!mainDialog.dialog('isOpen')) {
// user clicked [cancel]
// user clicked [cancel]