MediaWiki:Gadget-abuseLogRC-core.js

This is an old revision of this page, as edited by Alex (talk | contribs) at 17:14, 17 October 2024. The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.

After saving, you may need to bypass your browser's cache to see the changes. For further information, see Wikipedia:Bypass your cache.

  • In most Windows and Linux browsers: Hold down Ctrl and press F5.
  • In Safari: Hold down ⇧ Shift and click the Reload button.
  • In Chrome and Firefox for Mac: Hold down both ⌘ Cmd+⇧ Shift and press R.
/*  ======================
	      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));