User:WhitePhosphorus/js/AutoUndoGlobal.js
< User:WhitePhosphorus | js
Note: After publishing, you may have to bypass your browser's cache to see the changes.
- Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
- Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
- Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
- Opera: Press Ctrl-F5.
/*
* @author [[User:WhitePhosphorus]]
* Some English translations came from [[User:Camouflaged Mirage]].
* See also [[User:WhitePhosphorus/js/AutoUndo.js]] (only work on the wiki you activate the script)
*
* If your wiki does not use { {delete}} as speedy template:
* Copy the following snippet to your global.js, then replace '<wikidbname>' and '<speedytemplatename>'
* Check https://noc.wikimedia.org/conf/highlight.php?file=dblists/all.dblist for the <wikidbname> list. eg: English Wikipedia is enwiki, Meta Wiki is metawiki
p4js_auto_undo_csd_template = {
// default: 'delete'
'commonswiki': 'speedy', // example, no braces are needed in template name
'<wikidbname>': '<speedytemplatename>', // edit this line!
};
*/
mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:WhitePhosphorus/js/eventsource-polyfill.js&action=raw&ctype=text/javascript');
mw.loader.load('//meta.wikimedia.org/w/index.php?title=User:WhitePhosphorus/js/ipaddr.js&action=raw&ctype=text/javascript');
$(function () {
mw.loader.using(['mediawiki.util', 'mediawiki.ForeignApi'], function() {
const translations = {
portletlink: {
en: 'Global auto undo',
zh: '全域回退编辑'
},
introduction: {
en: 'IP ranges are fully supported. You can also use /regex/ (two backslashes are needed to escape characters). Only the new edits will be reverted after you click [start], please manually revert previous edits if needed.<br>Please avoid using the script on vandals who may undo-war with you.',
zh: '用户支持 IP 段,支持 /正则表达式/(需要两个反斜杠来转义)。確定之後將會跟蹤此用戶的全域編輯,並將其全部回退。(之前的編輯請手動回退)<br>請不要對會跟你打撤銷戰的破壞者用這個腳本。'
},
username: {
en: 'Username: ',
zh: '用户名:'
},
showname: {
en: 'Show username in rollback summary',
zh: '在回退摘要中显示用户名'
},
shownamedesp: {
en: ' (Used for grossly inappropriate username)',
zh: '(适用于极度不恰当的用户名)'
},
undolimit: {
en: 'Maximum times to undo per page: ',
zh: '每个页面的最多回退次数:'
},
unlimited: {
en: 'Unlimited',
zh: '无限制'
},
limitreached: {
en: 'Reached the undo limit on this page. Please manually undo if needed',
zh: '已达到本页面的撤销上限。如有需要请手动回退'
},
newpage: {
en: 'Actions on new page creation: ',
zh: '新建页面的处理:'
},
prependspeedydelete: {
en: 'Request speedy deletion (Vandalism)',
zh: '请求快速删除(破坏)'
},
prependspeedydeletespam: {
en: 'Request speedy deletion (Spam)',
zh: '请求快速删除(广告)'
},
speedydelete: {
en: 'Empty the page and request speedy deletion (Vandalism)',
zh: '清空页面并请求快速删除(破坏)'
},
empty: {
en: 'Empty the page',
zh: '清空页面'
},
nothing: {
en: 'Do nothing',
zh: '不处理'
},
start: {
en: 'Start [Enter]',
zh: '开始 [Enter]'
},
undid: {
en: 'Successfully undid on page ',
zh: '已撤銷頁面 '
},
cannotundo: {
en: 'Cannot undo on page ',
zh: '無法撤銷頁面 '
},
cannotedit: {
en: 'Cannot edit: ',
zh: '无法完成编辑:'
},
revision: {
en: ', revision id ',
zh: ',修訂版本 '
},
newedit: {
en: 'New edit: ',
zh: '新編輯:'
},
page: {
en: ', on page ',
zh: ',页面 '
},
stopped: {
en: 'Stopped.',
zh: '已停止。'
},
monitoring: {
en: 'Stalking ',
zh: '正在監視 '
},
glocked: {
en: 'is global (b)locked, stop stalking.',
zh: '已被全域锁定/封禁,停止监视。'
},
unknownerror: {
en: 'Unknown error, please see the browser console.',
zh: '未知错误,请看浏览器控制台。'
},
tokenerror: {
en: 'Cannot obtain the csrf token.',
zh: '无法获取编辑令牌。'
},
networktokenerror: {
en: 'Loss of connectivity, cannot obtain the csrf token.',
zh: '网络异常,无法获取编辑令牌。'
},
pagecreationskipped: {
en: 'Page creation skipped.',
zh: '已忽略页面建立。'
},
colon: {
en: ': ',
zh: ':'
},
};
const i18n = function(key) {
var userlang = mw.config.get('wgUserLanguage');
var lang = userlang;
// we have zh, zh-hans, zh-cn, zh-tw, etc.
if (userlang.startsWith('zh')) {
lang = 'zh';
}
// fallback to en
return translations[key][lang] || translations[key]['en'];
};
if (typeof(p4js_auto_undo_csd_template) !== 'object') {
p4js_auto_undo_csd_template = {
// default: 'delete'
'commonswiki': 'speedy',
};
}
const SUMMARY = 'revert edits by $1';
const CONTRIB = '[[Special:Contributions/$1|$1]]';
const HIDDEN = '<username hidden>';
let summary = null;
let uid = null;
let hidename = false;
let handle_creation = 'csd';
let started = false;
let apis = {};
let urls = {};
let tokens = {};
let undolimit = 0; // undo limit per page
let undidpages = {};
let eventSource = null;
const pageLink = function(wikiurl, page, title) {
return `<a target="_blank" href="${wikiurl}?title=${page}">${(title || page)}</a>`;
};
const print = function(html) {
$('#content').append($('<p>').html(html));
};
const printSuccess = function(url, change) {
print(`<span style="color:green">${i18n('undid')}${pageLink(url, change.title)}@${change.wiki}${i18n('revision')}${pageLink(url, 'Special:diff/'+change.revision.new, change.revision.new)}`);
};
const printFail = function(url, change, reason) {
print(`<span style="color:red">${i18n('cannotundo')}${pageLink(url, change.title)}@${change.wiki}${i18n('revision')}${pageLink(url, 'Special:diff/'+change.revision.new, change.revision.new)}${i18n('colon')}${reason}`);
};
const isMatch = function(user, pattern) {
let slashIndex = pattern.indexOf('/', 1);
if (pattern.startsWith('/') && slashIndex !== -1) {
// check regex
try {
let body = pattern.slice(1, slashIndex);
let flags = pattern.slice(slashIndex+1);
let regex = new RegExp(body, flags);
return regex.test(user);
} catch (e) {}
}
if (ipaddr.isValid(user)) {
try {
// check accurately ip match
// this is intended for IPv6 addresses as they may contain upper & lower cases
// will throw an exception for ip ranges
return ipaddr.parse(user).toNormalizedString() === ipaddr.parse(pattern).toNormalizedString();
} catch (e) {}
try {
// check ip range
let range = ipaddr.parseCIDR(pattern);
return ipaddr.parse(user).match(range);
} catch (e) {}
}
return user === pattern;
};
const isGloballyBlocked = function(change, user) {
if (change.wiki !== 'metawiki') return false;
let range = null;
try {
range = ipaddr.parseCIDR(user);
} catch (e) {}
if (ipaddr.isValid(user) || range !== null) {
// ip or ip range => check gblock
if (change.title !== `User:${user}`) return false;
return change.log_type === 'gblblock' && change.log_action === 'gblock2';
} else {
// registered => check glock
if (change.title !== `User:${user}@global`) return false;
return change.log_type === 'globalauth' && change.log_action === 'setstatus' &&
change.log_params && change.log_params.added && change.log_params.added.includes('locked');
}
};
const start = function() {
$('#content').html(i18n('monitoring') + uid);
// load site matrix
$.ajax({
url: mw.util.wikiScript('api'),
data: {
format: 'json',
action: 'sitematrix',
},
dataType: 'json',
type: 'POST',
}).then(function(data) {
if (!data.sitematrix) return;
// meta, commons, etc.
for (let wiki of data.sitematrix.specials) {
urls[wiki.dbname] = wiki.url;
}
for (let language of Object.values(data.sitematrix)) {
if (!language.site) continue;
for (let wiki of language.site) {
urls[wiki.dbname] = wiki.url;
}
}
});
started = true;
eventSource = new window.EventSource('https://stream.wikimedia.org/v2/stream/recentchange');
eventSource.onopen = function(event) {
console.log('--- Opened connection.');
};
eventSource.onerror = function(event) {
console.error('--- Encountered error', event);
};
eventSource.onmessage = function(event) {
if (!started) return;
let change = JSON.parse(event.data);
if (isGloballyBlocked(change, uid)) {
print(`${uid} ${i18n('glocked')}`);
this.close();
return;
}
if (!isMatch(change.user, uid)) return;
if (!urls[change.wiki]) {
console.log(`Unknown wiki ${change.wiki}`);
return;
}
let apiurl = urls[change.wiki] + mw.util.wikiScript('api');
let indexurl = urls[change.wiki] + mw.config.get('wgScript');
print(`${i18n('newedit')}${change.user}${i18n('revision')}${change.revision.new}${i18n('page')}${pageLink(indexurl, change.title)}@${change.wiki}`);
let fulltitle = change.title+'@'+change.wiki;
if (undolimit > 0 && undidpages[fulltitle] >= undolimit) {
printFail(indexurl, change, i18n('limitreached'));
return;
} else {
undidpages[fulltitle] = undidpages[fulltitle] ? undidpages[fulltitle]+1 : 1;
}
if (!apis[change.wiki]) {
apis[change.wiki] = (change.wiki === mw.config.get('wgDBname') ? new mw.Api() : new mw.ForeignApi(apiurl));
}
let api = apis[change.wiki];
// will be called after we get a token
const undo = function(token, target) {
api.post({
action: 'edit',
token: token,
title: change.title,
summary: SUMMARY.replace(/\$1/g, hidename ? HIDDEN : CONTRIB.replace(/\$1/g, target)),
undo: change.revision.new,
minor: true,
bot: true
}).done(function(data) {
if (data && data.edit && data.edit.result && data.edit.result === 'Success') {
printSuccess(indexurl, change);
} else {
if (data.error) {
if (data.error && data.error.code === 'nosuchrevid') {
printFail(indexurl, change, data.error.code);
console.log(data.error.info);
return;
}
}
printFail(indexurl, change, i18n('unknownerror'));
console.log(data);
}
}).fail(function(errcode) {
if (errcode === 'nosuchrevid') {
let newtext = '';
if (handle_creation !== 'nothing') {
if (handle_creation === 'rcsd' || handle_creation === 'csd') {
newtext = `{{${p4js_auto_undo_csd_template[change.wiki] || 'delete'}|Vandalism}}\n`;
} else if (handle_creation === 'csdspam') {
newtext = `{{${p4js_auto_undo_csd_template[change.wiki] || 'delete'}|Spam}}\n`;
}
let body = {
action: 'edit',
token: token,
title: change.title,
summary: SUMMARY.replace(/\$1/g, hidename ? HIDDEN : CONTRIB.replace(/\$1/g, target)),
minor: true,
bot: true
};
if (handle_creation === 'csd' || handle_creation === 'csdspam') {
body.prependtext = newtext;
} else {
body.text = newtext;
}
api.post(body).done(function(data2) {
if (data2 && data2.edit && data2.edit.result && data2.edit.result === 'Success') {
// TODO: log that it's a creation
printSuccess(indexurl, change);
} else {
if (data2.error) {
printFail(indexurl, change, data2.error.code);
console.log(data2.error.info);
return;
}
printFail(indexurl, change, i18n('unknownerror'));
console.log(data2);
}
});
} else {
printFail(indexurl, change, i18n('pagecreationskipped'));
}
} else {
printFail(indexurl, change, i18n('cannotedit') + errcode);
}
});
}
// try to query the token on that foreign wiki
if (!tokens[change.wiki]) {
api.get({
action: 'query',
meta: 'tokens'
}).done(function(data) {
if (!(data && data.query && data.query.tokens && data.query.tokens.csrftoken)) {
console.log(`Failed to query edit token on wiki ${change.wiki}: see following output`);
console.log(data);
printFail(indexurl, change, i18n('tokenerror'));
return;
}
tokens[change.wiki] = data.query.tokens.csrftoken;
undo(tokens[change.wiki], change.user);
}).fail(function() {
printFail(indexurl, change, i18n('networktokenerror'));
});
} else {
undo(tokens[change.wiki], change.user);
}
};
};
$(mw.util.addPortletLink('p-cactions', '#', i18n('portletlink'))).click(function (e) {
$('#content').html(`<div id="p4js-globalundo-settings"><p>${i18n('introduction')}</p>
<label>${i18n('username')}<input type="text" id="p4js-globalundo-username"></label><br>
<label><input type="checkbox" checked id="p4js-globalundo-showusername">${i18n('showname')}</label>${i18n('shownamedesp')}<br>
<label>${i18n('undolimit')}<input type="text" id="p4js-globalundo-undolimit" value="3"></label>
<label><input type="checkbox" id="p4js-globalundo-unlimited">${i18n('unlimited')}</label><br>
<label>${i18n('newpage')}<select id="p4js-globalundo-creation">
<option value="csd">${i18n('prependspeedydelete')}</option>
<option value="csdspam">${i18n('prependspeedydeletespam')}</option>
<option value="rcsd">${i18n('speedydelete')}</option>
<option value="empty">${i18n('empty')}</option>
<option value="nothing">${i18n('nothing')}</option></select></label><br>
<a id="p4js-globalundo-start">${i18n('start')}</a></div>`);
$('#p4js-globalundo-undolimit')[0].disabled = $('#p4js-globalundo-unlimited')[0].checked;
$('#p4js-globalundo-unlimited').click(function(e) {
$('#p4js-globalundo-undolimit')[0].disabled = this.checked;
});
$('#p4js-globalundo-start').click(function(e) {
uid = $('#p4js-globalundo-username')[0].value.trim();
if (uid) {
// normalize the username: replace underscore with space
uid = uid.replace(/_/g, ' ');
// make the first letter uppercase
uid = uid.charAt(0).toUpperCase() + uid.slice(1);
hidename = !$('#p4js-globalundo-showusername')[0].checked;
handle_creation = $('#p4js-globalundo-creation')[0].value;
if ($('#p4js-globalundo-unlimited')[0].checked) {
undolimit = 0;
} else {
undolimit = $('#p4js-globalundo-undolimit')[0].value;
if (undolimit < 1 || isNaN(undolimit)) {
undolimit = 3;
}
}
$('#p4js-globalundo-settings').hide();
start();
}
});
$('#p4js-globalundo-username').focus();
$(window).on('keypress', function(e) {
// press enter to start
if (e.which == 13) {
$('#p4js-globalundo-start').click();
}
});
});
});
});