MediaWiki:Gadget-abuseLogRC-core.js: Difference between revisions

From RuneRealm Wiki
Jump to navigation Jump to search
Content added Content deleted
No edit summary
Tag: Reverted
No edit summary
Tag: Manual revert
Line 1: Line 1:
/* ======================
/* ====================== AbuseLogRC ====================== Shows certain Special:AbuseLog entries at the top of Special:RecentChanges for better vandalism detection. Use of this gadget requires the user right to view private filters ("abusefilter-log-private"). Keep this in sync with [[rsw:MediaWiki:Gadget-abuseLogRC-core.js]]. Inspired by Suppa chuppa's original script at [[User:Suppa chuppa/abuselog.js]] @author Iiii_I_I_I*/;(function ($, mw) { let gadgetLoaded = false; let entryDays = new Set(); let lastUpdate; let intervalID; let filters = '2|3|5|6|7|12|14|19|21|global-4'; // default config if (getConfig('autoRefresh') === null) setConfig('autoRefresh', false); if (getConfig('interval') === null) setConfig('interval', 30); if (getConfig('entries') === null) setConfig('entries', 5); function getConfig(key) { return JSON.parse(localStorage.getItem('gadget-abuseLogRC-' + key)); } function setConfig(key, value) { localStorage.setItem('gadget-abuseLogRC-' + key, value); } function refreshData() { $('.gadget-abuselog-list').addClass('loading'); // class is cleared when new list replaces old entryDays.clear(); getData(); } function toggleAutoRefresh(isToggledOn, refreshButton) { if (isToggledOn) { intervalID = setInterval(refreshData, getConfig('interval') * 1000); } else { clearInterval(intervalID); } // hide manual refresh button when autoRefresh is on, show when it's off refreshButton.toggle(!isToggledOn); // update cookie setConfig('autoRefresh', isToggledOn); } function buildGadget([entries, users, pages]) { lastUpdate = new Date(); // refreshed if (gadgetLoaded) { $('.gadget-abuselog-list').replaceWith(buildList(entries, users, pages)); } // initial load else { let $container = $('<div class="gadget-abuselog"></div>'); $container.append(buildHeader(), buildList(entries, users, pages)); $('.mw-changeslist').before($container); gadgetLoaded = true; // change user tool link text if readableRC is on mw.hook('ext.gadget.readableRC').add(function () { $('.gadget-abuselog').addClass('match-gadget-rc'); }); } mw.hook('ext.gadget.abuseLogRC').fire(); } function buildHeader() { let $header = $('<div class="gadget-abuselog-header"></div>'); let $left = $('<span class="gadget-abuselog-header-left"></span>'); let $right = buildSettings(); let $title = $('<h4>Abuse log</h4>'); let link = ' (' + buildLink('Special:AbuseLog', {exists: true, text: 'all'}) + ')'; $left.append($title, link); $header.append($left, $right); return $header; } function buildSettings() { let $settings = $('<span class="gadget-abuselog-header-right"></span>'); let refreshButton = new OO.ui.ButtonWidget({ framed: false, icon: 'reload', flags: ['progressive'], label: 'Refresh log', invisibleLabel: true, title: 'Refresh abuse log entries', classes: ['gadget-abuselog-manual-refresh'] }); // hide when autoRefresh is on, show when it's off refreshButton.toggle(!getConfig('autoRefresh')); refreshButton.on('click', function (e) { refreshButton.setDisabled(true); // disabled state cleared by hook below refreshData(); }); // use RecentChanges' "View new changes" button as another way to refresh $('.mw-rcfilters-ui-filterWrapperWidget-showNewChanges a').on('click', function (e) { refreshData(); }); // POPUP TOP HALF: number of log entries to show let entriesSelectWidget = new OO.ui.ButtonSelectWidget({ items: [ new OO.ui.ButtonOptionWidget({ data: '3', label: '3' }), new OO.ui.ButtonOptionWidget({ data: '5', label: '5' }), new OO.ui.ButtonOptionWidget({ data: '10', label: '10' }), new OO.ui.ButtonOptionWidget({ data: '20', label: '20' }), new OO.ui.ButtonOptionWidget({ data: '50', label: '50' }) ] }); let entriesFieldset = new OO.ui.FieldsetLayout({ label: 'Entries to show', classes: ['gadget-abuselog-settings-entries'], items: [entriesSelectWidget] }); entriesSelectWidget.selectItemByData(getConfig('entries').toString()) entriesSelectWidget.on('choose', function (button, selected) { setConfig('entries', button.data); refreshData(); }); // if user changes # entries to a custom value in localStorage, insert new button at the start if (entriesSelectWidget.findSelectedItem() === null) { let value = getConfig('entries').toString(); let customButton = new OO.ui.ButtonOptionWidget({ data: value, label: value }); entriesSelectWidget.addItems(customButton, 0); entriesSelectWidget.selectItem(customButton); } // POPUP BOTTOM HALF: refresh settings let lastUpdatedLabel = new OO.ui.LabelWidget({ classes: ['gadget-abuselog-settings-last-updated'] }); let autoRefreshCheckbox = new OO.ui.CheckboxInputWidget({ selected: getConfig('autoRefresh') }); let refreshFieldset = new OO.ui.FieldsetLayout({ label: 'Refresh', classes: ['gadget-abuselog-settings-refresh'], items: [ lastUpdatedLabel, new OO.ui.FieldLayout(autoRefreshCheckbox, { classes: ['gadget-abuselog-settings-auto-refresh'], label: 'Auto-refresh log entries every ' + getConfig('interval') + ' seconds' }) ] }); // initial load toggleAutoRefresh(getConfig('autoRefresh'), refreshButton); // when clicked autoRefreshCheckbox.on('change', function (isSelected, indeterminate) { toggleAutoRefresh(isSelected, refreshButton); }); // POPUP MENU let menuButton = new OO.ui.PopupButtonWidget({ icon: 'menu', framed: false, label: 'Abuse log settings', invisibleLabel: true, classes: ['gadget-abuselog-settings'], popup: { head: false, anchor: false, padded: true, autoFlip: false, align: 'backwards', $content: $('<div>').append( entriesFieldset.$element, refreshFieldset.$element ) } }); // to do when settings popup is opened menuButton.on('click', function () { let time = new Intl.DateTimeFormat('en-GB', {hour: 'numeric', minute: 'numeric', timeZone: 'UTC'}).format(lastUpdate); let day = new Intl.DateTimeFormat('en-GB', {day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC'}).format(lastUpdate); let diff = new Date() - lastUpdate; let h = Math.floor(diff / 1000 / 60 / 60); let m = Math.floor(diff / 1000 / 60) % 60; let s = Math.floor(diff / 1000) % 60; // if over one minute since last update, hide seconds unit; // if under one hour since last update, hide hour unit let hh = (h > 0) ? h + 'h ' : ''; let mm = m + 'm'; let ss = s + 's'; let ago = (h > 0 || m > 0) ? hh + mm : ss; lastUpdatedLabel.setLabel( new OO.ui.HtmlSnippet(`Last update: <strong class="last-update" title="${time}, ${day}">${ago} ago</strong>.`) ); }); // to do on each gadget refresh mw.hook('ext.gadget.abuseLogRC').add(function () { refreshButton.setDisabled(false); }); $settings.append( refreshButton.$element, menuButton.$element ); return $settings; } function buildList(entries, users, pages) { let $list = $('<div class="gadget-abuselog-list"></div>'); let pageArr = Object.values(pages); for (let entry of entries) { let user = users.find(user => user.name === entry.user); let page = pageArr.find(page => page.title === entry.title); let userPage = pageArr.find(page => page.title === `User:${user.name}`); let talkPage = pageArr.find(page => page.title === `User talk:${user.name}`); // prevent error; User:FeedbackBot redirects to RuneScape:Article feedback if (user.name === 'FeedbackBot') { userPage = pageArr.find(page => page.title === 'RuneScape:Article feedback'); } let opts = { isRegistered: Object.hasOwn(user, 'userid'), isRedirect: page === undefined, // API response separates redirects from pages object pageExists: page === undefined || Object.hasOwn(page, 'pageid'), userPageExists: Object.hasOwn(userPage, 'pageid'), talkPageExists: Object.hasOwn(talkPage, 'pageid') }; $list.append(buildRow(entry, opts)); } return $list; } function buildRow(entry, opts) { // FIRST COLUMN: date and time let entryDate = new Date(entry.timestamp); let entryDay = new Intl.DateTimeFormat('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'}).format(entryDate); let entryTime = new Intl.DateTimeFormat('en-GB', {hour: 'numeric', minute: 'numeric', timeZone: 'UTC'}).format(entryDate); let showHideDay = (entryDays.has(entryDay)) ? 'hide' : ''; let firstColumn = '<span class="gadget-abuselog-col gadget-abuselog-col-1">' + `<span class="${showHideDay}">${entryDay}</span> <span>${entryTime}</span>` + '</span>'; entryDays.add(entryDay); // SECOND COLUMN: page edited let secondColumn = '<span class="gadget-abuselog-col gadget-abuselog-col-2">' + buildLink(entry.title, {exists: opts.pageExists, text: entry.title, isRedirect: opts.isRedirect}) + '</span>'; // THIRD COLUMN: diff, details, and action taken let diffLink = (entry.revid) ? buildLink(`Special:Diff/${entry.revid}`, {exists: true, text: 'diff'}) : 'diff'; let logLink = buildLink(`Special:AbuseLog/${entry.id}`, {exists: true, text: 'log'}); let results = []; // some filters perform multiple actions on a single edit, eg. <https://oldschool.runescape.wiki/w/Special:AbuseLog/22296> entry.result.split(',').forEach(result => { results.push( `<span class="gadget-abuselog-result gadget-abuselog-result-${result}">` + `${mw.msg('abusefilter-action-' + result)}</span>` ); }); let thirdColumn = '<span class="gadget-abuselog-col gadget-abuselog-col-3">' + `(${diffLink} | ${logLink}) <span class="gadget-abuselog-action">(${results.join(', ')})</span>` + '</span>'; // FOURTH COLUMN: user details and filter triggered let filterURL = `Special:AbuseFilter/${entry.filter_id}`; if (entry.filter_id.includes('global')) { filterURL = `meta:Special:AbuseFilter/${entry.filter_id.replace('global-', '')}`; } let filterLink = buildLink(filterURL, {exists: true, text: `Filter ${entry.filter_id}`}); let fourthColumn = '<span class="gadget-abuselog-col gadget-abuselog-col-4">' + buildUserLinks(entry.user, opts) + `<span class="gadget-abuselog-filter gadget-abuselog-filter-${entry.filter_id}">` + `(${filterLink}: ${entry.filter})` + '</span> ' + '</span>'; return '<div class="gadget-abuselog-row">' + firstColumn + secondColumn + thirdColumn + fourthColumn + '</div>'; } function buildUserLinks(username, opts) { let userLink; let toolLinks; // link text is added with CSS so it can be replaced when readableRC is loaded let talkLink = `<span>${buildLink(`User talk:${username}`, { exists: opts.talkPageExists, text: '', classes: 'mw-usertoollinks-talk' })}</span>`; let contribsLink = `<span>${buildLink(`Special:Contributions/${username}`, { exists: true, text: '', classes: 'mw-usertoollinks-contribs' })}</span>`; let logLink = `<span>${buildLink(`Special:AbuseLog`, { exists: true, text: '', classes: 'mw-usertoollinks-abuselog', param: 'wpSearchUser', value: username })}</span>`; let blockLink = `<span>${buildLink(`Special:Block/${username}`, { exists: true, text: '', classes: 'mw-usertoollinks-block' })}</span>`; // user links vs. anon links if (opts.isRegistered) { userLink = `<span>${buildLink(`User:${username}`, { exists: opts.userPageExists, text: username, classes: 'mw-userlink' })}</span>`; toolLinks = '<span class="mw-usertoollinks">(' + talkLink + contribsLink + logLink + blockLink + ')</span>'; } else { userLink = `<span>${buildLink(`Special:Contributions/${username}`, { exists: true, text: username, classes: 'mw-userlink mw-anonuserlink' })}</span>`; toolLinks = '<span class="mw-usertoollinks">(' + talkLink + logLink + blockLink + ')</span>'; } return userLink + ' ' + toolLinks + ' '; } function buildLink(pagename, opts) { let url = mw.util.getUrl(pagename); if (opts.param) url = mw.util.getUrl(pagename, {[opts.param]: opts.value}); if (!opts.exists) url = mw.util.getUrl(pagename, {action: 'edit'}); if (opts.isRedirect) url = mw.util.getUrl(pagename, {redirect: 'no'}); let title = (opts.exists) ? pagename : pagename + ' (page does not exist)'; let redlink = (opts.exists) ? '' : 'new'; let classes = opts.classes || ''; let link = `<a href="${url}" title="${title}" class="${redlink} ${classes}">${opts.text}</a>`; return link; } function getUsernames(abuselog) { let usernames = new Set(); for (let entry of abuselog) { usernames.add(entry.user); } return [...usernames]; } function getPageTitles(abuselog) { let pages = new Set(); let usernames = getUsernames(abuselog); for (let entry of abuselog) { pages.add(entry.title); } for (let username of usernames) { pages.add('User:' + username); pages.add('User talk:' + username); } return [...pages]; } function setMessages(messages) { for (let message of messages) { mw.messages.set(message['name'], message['*']); } } function buildError(result) { let warning = new OO.ui.MessageWidget({ type: 'notice', classes: ['gadget-abuselog-error'], label: new OO.ui.HtmlSnippet('<strong>AbuseLogRC encountered an API error:</strong><br>' + result.error.info) }); $('.mw-changeslist').before(warning.$element); } function getData() { let api = new mw.Api(); // this api call gets: // - abuselog entries for commonly triggered vandalism filters // - mw messages for abusefilter results (tag, warn, disallow, etc.) api.get({ list: 'abuselog', afllimit: getConfig('entries'), aflprop: 'ids|user|title|action|result|timestamp|revid|filter', aflfilter: filters, meta: 'allmessages', amprefix: 'abusefilter-action-' }) .then(function (results) { let abuselogResult = results.query.abuselog; let messagesResult = results.query.allmessages; if (!gadgetLoaded) setMessages(messagesResult); // this api call gets: // - page info for target pages in abuselog // - user info for users listed in abuselog // - page info for those users' userpages and talk pages return api.get({ list: 'users', ususers: getUsernames(abuselogResult), titles: getPageTitles(abuselogResult), redirects: true }) .then( // success function (results) { let usersResult = results.query.users; let pagesResult = results.query.pages; return [abuselogResult, usersResult, pagesResult]; }, // fail function (code, result) { buildError(result); } ); }) .then( // success function (results) { buildGadget(results); }, // fail function (code, result) { buildError(result); } ); } getData();}(jQuery, mediaWiki));
AbuseLogRC
======================

Shows certain Special:AbuseLog entries at the top of Special:RecentChanges
for better vandalism detection. Use of this gadget requires the user right
to view private filters ("abusefilter-log-private").

Keep this in sync with [[rsw:MediaWiki:Gadget-abuseLogRC-core.js]].

Inspired by Suppa chuppa's original script at [[User:Suppa chuppa/abuselog.js]]

@author Iiii_I_I_I
*/

;(function ($, mw) {
let gadgetLoaded = false;
let entryDays = new Set();
let lastUpdate;
let intervalID;
let filters = '2|3|5|6|7|12|14|19|21|global-4';

// default config
if (getConfig('autoRefresh') === null) setConfig('autoRefresh', false);
if (getConfig('interval') === null) setConfig('interval', 30);
if (getConfig('entries') === null) setConfig('entries', 5);

function getConfig(key) {
return JSON.parse(localStorage.getItem('gadget-abuseLogRC-' + key));
}

function setConfig(key, value) {
localStorage.setItem('gadget-abuseLogRC-' + key, value);
}

function refreshData() {
$('.gadget-abuselog-list').addClass('loading'); // class is cleared when new list replaces old
entryDays.clear();
getData();
}

function toggleAutoRefresh(isToggledOn, refreshButton) {
if (isToggledOn) {
intervalID = setInterval(refreshData, getConfig('interval') * 1000);
} else {
clearInterval(intervalID);
}

// hide manual refresh button when autoRefresh is on, show when it's off
refreshButton.toggle(!isToggledOn);

// update cookie
setConfig('autoRefresh', isToggledOn);
}

function buildGadget([entries, users, pages]) {
lastUpdate = new Date();

// refreshed
if (gadgetLoaded) {
$('.gadget-abuselog-list').replaceWith(buildList(entries, users, pages));
}
// initial load
else {
let $container = $('<div class="gadget-abuselog"></div>');

$container.append(buildHeader(), buildList(entries, users, pages));
$('.mw-changeslist').before($container);
gadgetLoaded = true;

// change user tool link text if readableRC is on
mw.hook('ext.gadget.readableRC').add(function () {
$('.gadget-abuselog').addClass('match-gadget-rc');
});
}

mw.hook('ext.gadget.abuseLogRC').fire();
}

function buildHeader() {
let $header = $('<div class="gadget-abuselog-header"></div>');
let $left = $('<span class="gadget-abuselog-header-left"></span>');
let $right = buildSettings();
let $title = $('<h4>Abuse log</h4>');
let link = ' (' + buildLink('Special:AbuseLog', {exists: true, text: 'all'}) + ')';

$left.append($title, link);
$header.append($left, $right);

return $header;
}

function buildSettings() {
let $settings = $('<span class="gadget-abuselog-header-right"></span>');
let refreshButton = new OO.ui.ButtonWidget({
framed: false,
icon: 'reload',
flags: ['progressive'],
label: 'Refresh log',
invisibleLabel: true,
title: 'Refresh abuse log entries',
classes: ['gadget-abuselog-manual-refresh']
});

// hide when autoRefresh is on, show when it's off
refreshButton.toggle(!getConfig('autoRefresh'));
refreshButton.on('click', function (e) {
refreshButton.setDisabled(true); // disabled state cleared by hook below
refreshData();
});

// use RecentChanges' "View new changes" button as another way to refresh
$('.mw-rcfilters-ui-filterWrapperWidget-showNewChanges a').on('click', function (e) {
refreshData();
});

// POPUP TOP HALF: number of log entries to show
let entriesSelectWidget = new OO.ui.ButtonSelectWidget({
items: [
new OO.ui.ButtonOptionWidget({ data: '3', label: '3' }),
new OO.ui.ButtonOptionWidget({ data: '5', label: '5' }),
new OO.ui.ButtonOptionWidget({ data: '10', label: '10' }),
new OO.ui.ButtonOptionWidget({ data: '20', label: '20' }),
new OO.ui.ButtonOptionWidget({ data: '50', label: '50' })
]
});
let entriesFieldset = new OO.ui.FieldsetLayout({
label: 'Entries to show',
classes: ['gadget-abuselog-settings-entries'],
items: [entriesSelectWidget]
});

entriesSelectWidget.selectItemByData(getConfig('entries').toString())
entriesSelectWidget.on('choose', function (button, selected) {
setConfig('entries', button.data);
refreshData();
});

// if user changes # entries to a custom value in localStorage, insert new button at the start
if (entriesSelectWidget.findSelectedItem() === null) {
let value = getConfig('entries').toString();
let customButton = new OO.ui.ButtonOptionWidget({ data: value, label: value });

entriesSelectWidget.addItems(customButton, 0);
entriesSelectWidget.selectItem(customButton);
}

// POPUP BOTTOM HALF: refresh settings
let lastUpdatedLabel = new OO.ui.LabelWidget({
classes: ['gadget-abuselog-settings-last-updated']
});
let autoRefreshCheckbox = new OO.ui.CheckboxInputWidget({
selected: getConfig('autoRefresh')
});
let refreshFieldset = new OO.ui.FieldsetLayout({
label: 'Refresh',
classes: ['gadget-abuselog-settings-refresh'],
items: [
lastUpdatedLabel,
new OO.ui.FieldLayout(autoRefreshCheckbox, {
classes: ['gadget-abuselog-settings-auto-refresh'],
label: 'Auto-refresh log entries every ' + getConfig('interval') + ' seconds'
})
]
});

// initial load
toggleAutoRefresh(getConfig('autoRefresh'), refreshButton);

// when clicked
autoRefreshCheckbox.on('change', function (isSelected, indeterminate) {
toggleAutoRefresh(isSelected, refreshButton);
});

// POPUP MENU
let menuButton = new OO.ui.PopupButtonWidget({
icon: 'menu',
framed: false,
label: 'Abuse log settings',
invisibleLabel: true,
classes: ['gadget-abuselog-settings'],
popup: {
head: false, anchor: false, padded: true, autoFlip: false, align: 'backwards',
$content: $('<div>').append(
entriesFieldset.$element,
refreshFieldset.$element
)
}
});

// to do when settings popup is opened
menuButton.on('click', function () {
let time = new Intl.DateTimeFormat('en-GB', {hour: 'numeric', minute: 'numeric', timeZone: 'UTC'}).format(lastUpdate);
let day = new Intl.DateTimeFormat('en-GB', {day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC'}).format(lastUpdate);

let diff = new Date() - lastUpdate;
let h = Math.floor(diff / 1000 / 60 / 60);
let m = Math.floor(diff / 1000 / 60) % 60;
let s = Math.floor(diff / 1000) % 60;

// if over one minute since last update, hide seconds unit;
// if under one hour since last update, hide hour unit
let hh = (h > 0) ? h + 'h ' : '';
let mm = m + 'm';
let ss = s + 's';
let ago = (h > 0 || m > 0) ? hh + mm : ss;

lastUpdatedLabel.setLabel(
new OO.ui.HtmlSnippet(`Last update: <strong class="last-update" title="${time}, ${day}">${ago} ago</strong>.`)
);
});

// to do on each gadget refresh
mw.hook('ext.gadget.abuseLogRC').add(function () {
refreshButton.setDisabled(false);
});

$settings.append(
refreshButton.$element,
menuButton.$element
);

return $settings;
}

function buildList(entries, users, pages) {
let $list = $('<div class="gadget-abuselog-list"></div>');
let pageArr = Object.values(pages);

for (let entry of entries) {
let user = users.find(user => user.name === entry.user);
let page = pageArr.find(page => page.title === entry.title);
let userPage = pageArr.find(page => page.title === `User:${user.name}`);
let talkPage = pageArr.find(page => page.title === `User talk:${user.name}`);

// prevent error; User:FeedbackBot redirects to RuneScape:Article feedback
if (user.name === 'FeedbackBot') {
userPage = pageArr.find(page => page.title === 'RuneScape:Article feedback');
}

let opts = {
isRegistered: Object.hasOwn(user, 'userid'),
isRedirect: page === undefined, // API response separates redirects from pages object
pageExists: page === undefined || Object.hasOwn(page, 'pageid'),
userPageExists: Object.hasOwn(userPage, 'pageid'),
talkPageExists: Object.hasOwn(talkPage, 'pageid')
};

$list.append(buildRow(entry, opts));
}

return $list;
}

function buildRow(entry, opts) {
// FIRST COLUMN: date and time
let entryDate = new Date(entry.timestamp);
let entryDay = new Intl.DateTimeFormat('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'}).format(entryDate);
let entryTime = new Intl.DateTimeFormat('en-GB', {hour: 'numeric', minute: 'numeric', timeZone: 'UTC'}).format(entryDate);
let showHideDay = (entryDays.has(entryDay)) ? 'hide' : '';
let firstColumn =
'<span class="gadget-abuselog-col gadget-abuselog-col-1">' +
`<span class="${showHideDay}">${entryDay}</span> <span>${entryTime}</span>` +
'</span>';

entryDays.add(entryDay);

// SECOND COLUMN: page edited
let secondColumn =
'<span class="gadget-abuselog-col gadget-abuselog-col-2">' +
buildLink(entry.title, {exists: opts.pageExists, text: entry.title, isRedirect: opts.isRedirect}) +
'</span>';

// THIRD COLUMN: diff, details, and action taken
let diffLink = (entry.revid) ? buildLink(`Special:Diff/${entry.revid}`, {exists: true, text: 'diff'}) : 'diff';
let logLink = buildLink(`Special:AbuseLog/${entry.id}`, {exists: true, text: 'log'});
let results = [];

// some filters perform multiple actions on a single edit, eg. <https://oldschool.runescape.wiki/w/Special:AbuseLog/22296>
entry.result.split(',').forEach(result => {
results.push(
`<span class="gadget-abuselog-result gadget-abuselog-result-${result}">` +
`${mw.msg('abusefilter-action-' + result)}</span>`
);
});

let thirdColumn =
'<span class="gadget-abuselog-col gadget-abuselog-col-3">' +
`(${diffLink} | ${logLink}) <span class="gadget-abuselog-action">(${results.join(', ')})</span>` +
'</span>';

// FOURTH COLUMN: user details and filter triggered
let filterURL = `Special:AbuseFilter/${entry.filter_id}`;

if (entry.filter_id.includes('global')) {
filterURL = `meta:Special:AbuseFilter/${entry.filter_id.replace('global-', '')}`;
}

let filterLink = buildLink(filterURL, {exists: true, text: `Filter ${entry.filter_id}`});

let fourthColumn =
'<span class="gadget-abuselog-col gadget-abuselog-col-4">' +
buildUserLinks(entry.user, opts) +
`<span class="gadget-abuselog-filter gadget-abuselog-filter-${entry.filter_id}">` +
`(${filterLink}: ${entry.filter})` +
'</span> ' +
'</span>';

return '<div class="gadget-abuselog-row">' + firstColumn + secondColumn + thirdColumn + fourthColumn + '</div>';
}

function buildUserLinks(username, opts) {
let userLink;
let toolLinks;

// link text is added with CSS so it can be replaced when readableRC is loaded
let talkLink = `<span>${buildLink(`User talk:${username}`, {
exists: opts.talkPageExists,
text: '',
classes: 'mw-usertoollinks-talk'
})}</span>`;
let contribsLink = `<span>${buildLink(`Special:Contributions/${username}`, {
exists: true,
text: '',
classes: 'mw-usertoollinks-contribs'
})}</span>`;
let logLink = `<span>${buildLink(`Special:AbuseLog`, {
exists: true,
text: '',
classes: 'mw-usertoollinks-abuselog',
param: 'wpSearchUser',
value: username
})}</span>`;
let blockLink = `<span>${buildLink(`Special:Block/${username}`, {
exists: true,
text: '',
classes: 'mw-usertoollinks-block'
})}</span>`;

// user links vs. anon links
if (opts.isRegistered) {
userLink = `<span>${buildLink(`User:${username}`, {
exists: opts.userPageExists,
text: username,
classes: 'mw-userlink'
})}</span>`;
toolLinks = '<span class="mw-usertoollinks">(' + talkLink + contribsLink + logLink + blockLink + ')</span>';
} else {
userLink = `<span>${buildLink(`Special:Contributions/${username}`, {
exists: true,
text: username,
classes: 'mw-userlink mw-anonuserlink'
})}</span>`;
toolLinks = '<span class="mw-usertoollinks">(' + talkLink + logLink + blockLink + ')</span>';
}

return userLink + ' ' + toolLinks + ' ';
}

function buildLink(pagename, opts) {
let url = mw.util.getUrl(pagename);

if (opts.param) url = mw.util.getUrl(pagename, {[opts.param]: opts.value});
if (!opts.exists) url = mw.util.getUrl(pagename, {action: 'edit'});
if (opts.isRedirect) url = mw.util.getUrl(pagename, {redirect: 'no'});

let title = (opts.exists) ? pagename : pagename + ' (page does not exist)';
let redlink = (opts.exists) ? '' : 'new';
let classes = opts.classes || '';
let link = `<a href="${url}" title="${title}" class="${redlink} ${classes}">${opts.text}</a>`;

return link;
}

function getUsernames(abuselog) {
let usernames = new Set();

for (let entry of abuselog) {
usernames.add(entry.user);
}

return [...usernames];
}

function getPageTitles(abuselog) {
let pages = new Set();
let usernames = getUsernames(abuselog);

for (let entry of abuselog) {
pages.add(entry.title);
}

for (let username of usernames) {
pages.add('User:' + username);
pages.add('User talk:' + username);
}

return [...pages];
}

function setMessages(messages) {
for (let message of messages) {
mw.messages.set(message['name'], message['*']);
}
}

function buildError(result) {
let warning = new OO.ui.MessageWidget({
type: 'notice',
classes: ['gadget-abuselog-error'],
label: new OO.ui.HtmlSnippet('<strong>AbuseLogRC encountered an API error:</strong><br>' + result.error.info)
});

$('.mw-changeslist').before(warning.$element);
}

function getData() {
let api = new mw.Api();

// this api call gets:
// - abuselog entries for commonly triggered vandalism filters
// - mw messages for abusefilter results (tag, warn, disallow, etc.)
api.get({
list: 'abuselog',
afllimit: getConfig('entries'),
aflprop: 'ids|user|title|action|result|timestamp|revid|filter',
aflfilter: filters,
meta: 'allmessages',
amprefix: 'abusefilter-action-'
})
.then(function (results) {
let abuselogResult = results.query.abuselog;
let messagesResult = results.query.allmessages;

if (!gadgetLoaded) setMessages(messagesResult);

// this api call gets:
// - page info for target pages in abuselog
// - user info for users listed in abuselog
// - page info for those users' userpages and talk pages
return api.get({
list: 'users',
ususers: getUsernames(abuselogResult),
titles: getPageTitles(abuselogResult),
redirects: true
})
.then(
// success
function (results) {
let usersResult = results.query.users;
let pagesResult = results.query.pages;

return [abuselogResult, usersResult, pagesResult];
},
// fail
function (code, result) {
buildError(result);
}
);
})
.then(
// success
function (results) {
buildGadget(results);
},
// fail
function (code, result) {
buildError(result);
}
);
}

getData();
}(jQuery, mediaWiki));

Revision as of 17:14, 17 October 2024

/*  ======================
	      AbuseLogRC
	======================

	Shows certain Special:AbuseLog entries at the top of Special:RecentChanges
	for better vandalism detection. Use of this gadget requires the user right
	to view private filters ("abusefilter-log-private").

	Keep this in sync with [[rsw:MediaWiki:Gadget-abuseLogRC-core.js]].

	Inspired by Suppa chuppa's original script at [[User:Suppa chuppa/abuselog.js]]

	@author Iiii_I_I_I
*/

;(function ($, mw) {
	let gadgetLoaded = false;
	let entryDays = new Set();
	let lastUpdate;
	let intervalID;
	let filters = '2|3|5|6|7|12|14|19|21|global-4';

	// default config
	if (getConfig('autoRefresh') === null) setConfig('autoRefresh', false);
	if (getConfig('interval') === null) setConfig('interval', 30);
	if (getConfig('entries') === null) setConfig('entries', 5);

	function getConfig(key) {
		return JSON.parse(localStorage.getItem('gadget-abuseLogRC-' + key));
	}

	function setConfig(key, value) {
		localStorage.setItem('gadget-abuseLogRC-' + key, value);
	}

	function refreshData() {
		$('.gadget-abuselog-list').addClass('loading'); // class is cleared when new list replaces old
		entryDays.clear();
		getData();
	}

	function toggleAutoRefresh(isToggledOn, refreshButton) {
		if (isToggledOn) {
			intervalID = setInterval(refreshData, getConfig('interval') * 1000);
		} else {
			clearInterval(intervalID);
		}

		// hide manual refresh button when autoRefresh is on, show when it's off
		refreshButton.toggle(!isToggledOn);

		// update cookie
		setConfig('autoRefresh', isToggledOn);
	}

	function buildGadget([entries, users, pages]) {
		lastUpdate = new Date();

		// refreshed
		if (gadgetLoaded) {
			$('.gadget-abuselog-list').replaceWith(buildList(entries, users, pages));
		}
		// initial load
		else {
			let $container = $('<div class="gadget-abuselog"></div>');

			$container.append(buildHeader(), buildList(entries, users, pages));
			$('.mw-changeslist').before($container);
			gadgetLoaded = true;

			// change user tool link text if readableRC is on
			mw.hook('ext.gadget.readableRC').add(function () {
				$('.gadget-abuselog').addClass('match-gadget-rc');
			});
		}

		mw.hook('ext.gadget.abuseLogRC').fire();
	}

	function buildHeader() {
		let $header = $('<div class="gadget-abuselog-header"></div>');
		let $left = $('<span class="gadget-abuselog-header-left"></span>');
		let $right = buildSettings();
		let $title = $('<h4>Abuse log</h4>');
		let link = ' (' + buildLink('Special:AbuseLog', {exists: true, text: 'all'}) + ')';

		$left.append($title, link);
		$header.append($left, $right);

		return $header;
	}

	function buildSettings() {
		let $settings = $('<span class="gadget-abuselog-header-right"></span>');
		let refreshButton = new OO.ui.ButtonWidget({
			framed: false,
			icon: 'reload',
			flags: ['progressive'],
			label: 'Refresh log',
			invisibleLabel: true,
			title: 'Refresh abuse log entries',
			classes: ['gadget-abuselog-manual-refresh']
		});

		// hide when autoRefresh is on, show when it's off
		refreshButton.toggle(!getConfig('autoRefresh'));
		refreshButton.on('click', function (e) {
			refreshButton.setDisabled(true); // disabled state cleared by hook below
			refreshData();
		});

		// use RecentChanges' "View new changes" button as another way to refresh
		$('.mw-rcfilters-ui-filterWrapperWidget-showNewChanges a').on('click', function (e) {
			refreshData();
		});

		// POPUP TOP HALF: number of log entries to show
		let entriesSelectWidget = new OO.ui.ButtonSelectWidget({
			items: [
				new OO.ui.ButtonOptionWidget({ data: '3', label: '3' }),
				new OO.ui.ButtonOptionWidget({ data: '5', label: '5' }),
				new OO.ui.ButtonOptionWidget({ data: '10', label: '10' }),
				new OO.ui.ButtonOptionWidget({ data: '20', label: '20' }),
				new OO.ui.ButtonOptionWidget({ data: '50', label: '50' })
			]
		});
		let entriesFieldset = new OO.ui.FieldsetLayout({
			label: 'Entries to show',
			classes: ['gadget-abuselog-settings-entries'],
			items: [entriesSelectWidget]
		});

		entriesSelectWidget.selectItemByData(getConfig('entries').toString())
		entriesSelectWidget.on('choose', function (button, selected) {
			setConfig('entries', button.data);
			refreshData();
		});

		// if user changes # entries to a custom value in localStorage, insert new button at the start
		if (entriesSelectWidget.findSelectedItem() === null) {
			let value = getConfig('entries').toString();
			let customButton = new OO.ui.ButtonOptionWidget({ data: value, label: value });

			entriesSelectWidget.addItems(customButton, 0);
			entriesSelectWidget.selectItem(customButton);
		}

		// POPUP BOTTOM HALF: refresh settings
		let lastUpdatedLabel = new OO.ui.LabelWidget({
			classes: ['gadget-abuselog-settings-last-updated']
		});
		let autoRefreshCheckbox = new OO.ui.CheckboxInputWidget({
			selected: getConfig('autoRefresh')
		});
		let refreshFieldset = new OO.ui.FieldsetLayout({
			label: 'Refresh',
			classes: ['gadget-abuselog-settings-refresh'],
			items: [
				lastUpdatedLabel,
				new OO.ui.FieldLayout(autoRefreshCheckbox, {
					classes: ['gadget-abuselog-settings-auto-refresh'],
					label: 'Auto-refresh log entries every ' + getConfig('interval') + ' seconds'
				})
			]
		});

		// initial load
		toggleAutoRefresh(getConfig('autoRefresh'), refreshButton);

		// when clicked
		autoRefreshCheckbox.on('change', function (isSelected, indeterminate) {
			toggleAutoRefresh(isSelected, refreshButton);
		});

		// POPUP MENU
		let menuButton = new OO.ui.PopupButtonWidget({
			icon: 'menu',
			framed: false,
			label: 'Abuse log settings',
			invisibleLabel: true,
			classes: ['gadget-abuselog-settings'],
			popup: {
				head: false, anchor: false, padded: true, autoFlip: false, align: 'backwards',
				$content: $('<div>').append(
					entriesFieldset.$element,
					refreshFieldset.$element
				)
			}
		});

		// to do when settings popup is opened
		menuButton.on('click', function () {
			let time = new Intl.DateTimeFormat('en-GB', {hour: 'numeric', minute: 'numeric', timeZone: 'UTC'}).format(lastUpdate);
			let day = new Intl.DateTimeFormat('en-GB', {day: 'numeric', month: 'long', year: 'numeric', timeZone: 'UTC'}).format(lastUpdate);

			let diff = new Date() - lastUpdate;
			let h = Math.floor(diff / 1000 / 60 / 60);
			let m = Math.floor(diff / 1000 / 60) % 60;
			let s = Math.floor(diff / 1000) % 60;

			// if over one minute since last update, hide seconds unit;
			// if under one hour since last update, hide hour unit
			let hh = (h > 0) ? h + 'h ' : '';
			let mm = m + 'm';
			let ss = s + 's';
			let ago = (h > 0 || m > 0) ? hh + mm : ss;

			lastUpdatedLabel.setLabel(
				new OO.ui.HtmlSnippet(`Last update: <strong class="last-update" title="${time}, ${day}">${ago} ago</strong>.`)
			);
		});

		// to do on each gadget refresh
		mw.hook('ext.gadget.abuseLogRC').add(function () {
			refreshButton.setDisabled(false);
		});

		$settings.append(
			refreshButton.$element,
			menuButton.$element
		);

		return $settings;
	}

	function buildList(entries, users, pages) {
		let $list = $('<div class="gadget-abuselog-list"></div>');
		let pageArr = Object.values(pages);

		for (let entry of entries) {
			let user = users.find(user => user.name === entry.user);
			let page = pageArr.find(page => page.title === entry.title);
			let userPage = pageArr.find(page => page.title === `User:${user.name}`);
			let talkPage = pageArr.find(page => page.title === `User talk:${user.name}`);

			// prevent error; User:FeedbackBot redirects to RuneScape:Article feedback
			if (user.name === 'FeedbackBot') {
				userPage = pageArr.find(page => page.title === 'RuneScape:Article feedback');
			}

			let opts = {
				isRegistered: Object.hasOwn(user, 'userid'),
				isRedirect: page === undefined, // API response separates redirects from pages object
				pageExists: page === undefined || Object.hasOwn(page, 'pageid'),
				userPageExists: Object.hasOwn(userPage, 'pageid'),
				talkPageExists: Object.hasOwn(talkPage, 'pageid')
			};

			$list.append(buildRow(entry, opts));
		}

		return $list;
	}

	function buildRow(entry, opts) {
		// FIRST COLUMN: date and time
		let entryDate = new Date(entry.timestamp);
		let entryDay = new Intl.DateTimeFormat('en-GB', {day: '2-digit', month: 'short', timeZone: 'UTC'}).format(entryDate);
		let entryTime = new Intl.DateTimeFormat('en-GB', {hour: 'numeric', minute: 'numeric', timeZone: 'UTC'}).format(entryDate);
		let showHideDay = (entryDays.has(entryDay)) ? 'hide' : '';
		let firstColumn =
			'<span class="gadget-abuselog-col gadget-abuselog-col-1">' +
				`<span class="${showHideDay}">${entryDay}</span> <span>${entryTime}</span>` +
			'</span>';

		entryDays.add(entryDay);

		// SECOND COLUMN: page edited
		let secondColumn =
			'<span class="gadget-abuselog-col gadget-abuselog-col-2">' +
				buildLink(entry.title, {exists: opts.pageExists, text: entry.title, isRedirect: opts.isRedirect}) +
			'</span>';

		// THIRD COLUMN: diff, details, and action taken
		let diffLink = (entry.revid) ? buildLink(`Special:Diff/${entry.revid}`, {exists: true, text: 'diff'}) : 'diff';
		let logLink = buildLink(`Special:AbuseLog/${entry.id}`, {exists: true, text: 'log'});
		let results = [];

		// some filters perform multiple actions on a single edit, eg. <https://oldschool.runescape.wiki/w/Special:AbuseLog/22296>
		entry.result.split(',').forEach(result => {
			results.push(
				`<span class="gadget-abuselog-result gadget-abuselog-result-${result}">` +
				`${mw.msg('abusefilter-action-' + result)}</span>`
			);
		});

		let thirdColumn =
			'<span class="gadget-abuselog-col gadget-abuselog-col-3">' +
				`(${diffLink} | ${logLink}) <span class="gadget-abuselog-action">(${results.join(', ')})</span>` +
			'</span>';

		// FOURTH COLUMN: user details and filter triggered
		let filterURL = `Special:AbuseFilter/${entry.filter_id}`;

		if (entry.filter_id.includes('global')) {
			filterURL = `meta:Special:AbuseFilter/${entry.filter_id.replace('global-', '')}`;
		}

		let filterLink = buildLink(filterURL, {exists: true, text: `Filter ${entry.filter_id}`});

		let fourthColumn =
			'<span class="gadget-abuselog-col gadget-abuselog-col-4">' +
				buildUserLinks(entry.user, opts) +
				`<span class="gadget-abuselog-filter gadget-abuselog-filter-${entry.filter_id}">` +
					`(${filterLink}: ${entry.filter})` +
				'</span> ' +
			'</span>';

		return '<div class="gadget-abuselog-row">' + firstColumn + secondColumn + thirdColumn + fourthColumn + '</div>';
	}

	function buildUserLinks(username, opts) {
		let userLink;
		let toolLinks;

		// link text is added with CSS so it can be replaced when readableRC is loaded
		let talkLink = `<span>${buildLink(`User talk:${username}`, {
			exists: opts.talkPageExists,
			text: '',
			classes: 'mw-usertoollinks-talk'
		})}</span>`;
		let contribsLink = `<span>${buildLink(`Special:Contributions/${username}`, {
			exists: true,
			text: '',
			classes: 'mw-usertoollinks-contribs'
		})}</span>`;
		let logLink = `<span>${buildLink(`Special:AbuseLog`, {
			exists: true,
			text: '',
			classes: 'mw-usertoollinks-abuselog',
			param: 'wpSearchUser',
			value: username
		})}</span>`;
		let blockLink = `<span>${buildLink(`Special:Block/${username}`, {
			exists: true,
			text: '',
			classes: 'mw-usertoollinks-block'
		})}</span>`;

		// user links vs. anon links
		if (opts.isRegistered) {
			userLink = `<span>${buildLink(`User:${username}`, {
				exists: opts.userPageExists,
				text: username,
				classes: 'mw-userlink'
			})}</span>`;
			toolLinks = '<span class="mw-usertoollinks">(' + talkLink + contribsLink + logLink + blockLink + ')</span>';
		} else {
			userLink = `<span>${buildLink(`Special:Contributions/${username}`, {
				exists: true,
				text: username,
				classes: 'mw-userlink mw-anonuserlink'
			})}</span>`;
			toolLinks = '<span class="mw-usertoollinks">(' + talkLink + logLink + blockLink + ')</span>';
		}

		return userLink + ' ' + toolLinks + ' ';
	}

	function buildLink(pagename, opts) {
		let url = mw.util.getUrl(pagename);

		if (opts.param) url = mw.util.getUrl(pagename, {[opts.param]: opts.value});
		if (!opts.exists) url = mw.util.getUrl(pagename, {action: 'edit'});
		if (opts.isRedirect) url = mw.util.getUrl(pagename, {redirect: 'no'});

		let title = (opts.exists) ? pagename : pagename + ' (page does not exist)';
		let redlink = (opts.exists) ? '' : 'new';
		let classes = opts.classes || '';
		let link = `<a href="${url}" title="${title}" class="${redlink} ${classes}">${opts.text}</a>`;

		return link;
	}

	function getUsernames(abuselog) {
		let usernames = new Set();

		for (let entry of abuselog) {
			usernames.add(entry.user);
		}

		return [...usernames];
	}

	function getPageTitles(abuselog) {
		let pages = new Set();
		let usernames = getUsernames(abuselog);

		for (let entry of abuselog) {
			pages.add(entry.title);
		}

		for (let username of usernames) {
			pages.add('User:' + username);
			pages.add('User talk:' + username);
		}

		return [...pages];
	}

	function setMessages(messages) {
		for (let message of messages) {
			mw.messages.set(message['name'], message['*']);
		}
	}

	function buildError(result) {
		let warning = new OO.ui.MessageWidget({
			type: 'notice',
			classes: ['gadget-abuselog-error'],
			label: new OO.ui.HtmlSnippet('<strong>AbuseLogRC encountered an API error:</strong><br>' + result.error.info)
		});

		$('.mw-changeslist').before(warning.$element);
	}

	function getData() {
		let api = new mw.Api();

		// this api call gets:
		// - abuselog entries for commonly triggered vandalism filters
		// - mw messages for abusefilter results (tag, warn, disallow, etc.)
		api.get({
			list: 'abuselog',
			afllimit: getConfig('entries'),
			aflprop: 'ids|user|title|action|result|timestamp|revid|filter',
			aflfilter: filters,
			meta: 'allmessages',
			amprefix: 'abusefilter-action-'
		})
		.then(function (results) {
			let abuselogResult = results.query.abuselog;
			let messagesResult = results.query.allmessages;

			if (!gadgetLoaded) setMessages(messagesResult);

			// this api call gets:
			// - page info for target pages in abuselog
			// - user info for users listed in abuselog
			// - page info for those users' userpages and talk pages
			return api.get({
				list: 'users',
				ususers: getUsernames(abuselogResult),
				titles: getPageTitles(abuselogResult),
				redirects: true
			})
			.then(
				// success
				function (results) {
					let usersResult = results.query.users;
					let pagesResult = results.query.pages;

					return [abuselogResult, usersResult, pagesResult];
				},
				// fail
				function (code, result) {
					buildError(result);
				}
			);
		})
		.then(
			// success
			function (results) {
				buildGadget(results);
			},
			// fail
			function (code, result) {
				buildError(result);
			}
		);
	}

	getData();
}(jQuery, mediaWiki));