MediaWiki:Gadget-abuseLogRC-core.js: Difference between revisions
Jump to navigation
Jump to search
Content added Content deleted
No edit summary Tag: Reverted |
No edit summary Tag: Reverted |
||
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, mw.pages]) { |
|||
lastUpdate = new Date(); |
|||
// refreshed |
|||
if (gadgetLoaded) { |
|||
$('.gadget-abuselog-list').replaceWith(buildList(entries, users, mw.pages)); |
|||
} |
|||
// initial load |
|||
else { |
|||
let $container = $('<div class="gadget-abuselog"></div>'); |
|||
$container.append(buildHeader(), buildList(entries, users, mw.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, mw.pages) { |
|||
let $list = $('<div class="gadget-abuselog-list"></div>'); |
|||
let pageArr = Object.values(mw.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 mw.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.mw.util.getUrl(pagename); |
|||
if (opts.param) url = mw.mw.util.getUrl(pagename, {[opts.param]: opts.value}); |
|||
if (!opts.exists) url = mw.mw.util.getUrl(pagename, {action: 'edit'}); |
|||
if (opts.isRedirect) url = mw.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 mw.pages = new Set(); |
|||
let usernames = getUsernames(abuselog); |
|||
for (let entry of abuselog) { |
|||
mw.pages.add(entry.title); |
|||
} |
|||
for (let username of usernames) { |
|||
mw.pages.add('User:' + username); |
|||
mw.pages.add('User talk:' + username); |
|||
} |
|||
return [...mw.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 mw.pages in abuselog |
|||
// - user info for users listed in abuselog |
|||
// - page info for those users' userpages and talk mw.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.mw.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:12, 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));