User:Nardog/CatChangesViewer-core.js

From Wikipedia, the free encyclopedia
Note: After saving, you have to bypass your browser's cache to see the changes. Google Chrome, Firefox, Microsoft Edge and Safari: Hold down the ⇧ Shift key and click the Reload toolbar button. For details and instructions about other browsers, see Wikipedia:Bypass your cache.
mw.loader.using([
	'mediawiki.api', 'mediawiki.util', 'oojs-ui-widgets', 'mediawiki.widgets',
	'mediawiki.widgets.UserInputWidget', 'mediawiki.widgets.datetime',
	'oojs-ui.styles.icons-interactions', 'oojs-ui.styles.icons-movement',
	'mediawiki.interface.helpers.styles'
], function catChangesViewer() {
	mw.loader.addStyleTag('.catchangesviewer .oo-ui-numberInputWidget{width:4em} .catchangesviewer .oo-ui-numberInputWidget input{text-align:center} .catchangesviewer .oo-ui-menuSelectWidget, .catchangesviewer .mw-widgets-datetime-dateTimeInputWidget{width:min-content} .catchangesviewer .mw-widget-userInputWidget{width:8em} .catchangesviewer .oo-ui-fieldLayout-align-inline{vertical-align:top} .catchangesviewer-table{white-space:nowrap} .catchangesviewer-addition{background:#e6ffe6} .catchangesviewer-removal{background:#ffe6e6} .catchangesviewer-table td:empty::after{content:"\\a0"}');
	let api = new mw.Api({
		ajax: { headers: { 'Api-User-Agent': 'CatChangesViewer (https://en.wikipedia.org/wiki/User:Nardog/CatChangesViewer)' } }
	});
	let msgKeys = mw.config.get('wgContentLanguage') === 'en' ? [] : [
		'recentchanges-page-added-to-category',
		'recentchanges-page-added-to-category-bundled',
		'recentchanges-page-removed-from-category',
		'recentchanges-page-removed-from-category-bundled'
	];
	let addedKeys = msgKeys.slice(0, 2), removedKeys = msgKeys.slice(2);
	class CatChangesSearch {
		constructor() {
			this.options = getOptions();
			this.params = Object.assign({
				action: 'query',
				list: 'recentchanges',
				rctype: 'categorize',
				rctitle: mw.config.get('wgPageName'),
				rcprop: 'ids|timestamp|comment|user|flags',
				formatversion: 2
			}, this.options);
			this.rcs = [];
			this.latest = {};
			this.curPage = 0;
			this.titles = {};
			this.newRcs = [];
		}
		load(isRefresh) {
			isRefresh = isRefresh && !!this.rcs.length;
			if (isRefresh) {
				this.params.rcdir = 'newer';
				this.params.rclimit = Math.min(limitInput.getNumericValue() + 1, 500);
				this.params.rccontinue = this.rcs[0].timestamp.replace(/\D/g, '') + '|' + this.rcs[0].revid;
			} else {
				delete this.params.rcdir;
				this.params.rclimit = limitInput.getNumericValue();
				this.params.rccontinue = this.rccontinue;
			}
			this.setDisabledAll(true);
			$error.empty();
			api.get(this.params).then(response => {
				if (!isRefresh) {
					this.rccontinue = (response.continue || {}).rccontinue;
					this.complete = !this.rccontinue && response.batchcomplete;
				}
				return api.loadMessagesIfMissing(msgKeys).then(() => {
					this.processChanges(isRefresh, response.query.recentchanges);
				});
			}).catch(err => {
				$error.text(err ? 'Error: ' + err : 'Unknown error');
			}).always(() => {
				this.setDisabledAll(false);
				this.resetNavButtons();
				this.updateButton();
				refreshButton.toggle(true);
			});
		}
		updateButton() {
			button.setLabel(
				this.rcs.length
					? this.complete ? 'No more results' : 'Load more'
					: this.complete ? 'No results' : 'Search'
			).setDisabled(this.complete);
		}
		processChanges(isRefresh, rcs = []) {
			if (isRefresh && (rcs[0] || {}).revid === this.rcs[0].revid) {
				rcs.shift();
			}
			if (!rcs.length) return;
			rcs.forEach(rc => {
				if (!rc.comment) return;
				let page = rc.comment.match(/\[\[:?([^|\]]+)\]\]/)[1];
				if (rc.comment.includes(']] added to category')) {
					rc.action = 'addition';
				} else if (rc.comment.includes(']] removed from category')) {
					rc.action = 'removal';
				} else if (addedKeys.some(key => rc.comment === mw.msg(key, page))) {
					rc.action = 'addition';
				} else if (removedKeys.some(key => rc.comment === mw.msg(key, page))) {
					rc.action = 'removal';
				}
				if (this.latest.hasOwnProperty(page)) {
					if (isRefresh) {
						this.latest[page].duplicate = true;
						this.latest[page] = rc;
					} else {
						rc.duplicate = true;
					}
				} else {
					this.latest[page] = rc;
				}
				this.rcs[isRefresh ? 'unshift' : 'push'](rc);
				this.addRow(rc, page);
			});
			this.initNav();
			this.queryTitles(
				Object.entries(this.titles)
					.filter(([k, v]) => !v.processed).map(([k]) => k)
			);
		}
		initNav() {
			let rcsToShow = hideAdditionsCheckbox.isSelected()
				? this.rcs.filter(rc => rc.action !== 'addition')
				: hideRemovalsCheckbox.isSelected()
					? this.rcs.filter(rc => rc.action !== 'removal')
					: this.rcs;
			if (hideDuplicatesCheckbox.isSelected()) {
				rcsToShow = rcsToShow.filter(rc => !rc.duplicate);
			}
			this.visibleRows = rcsToShow.map(rc => rc.$row[0]);
			this.pageCount = Math.ceil(this.visibleRows.length / perPageNum) || 1;
			let z = this.rcs.length > perPageNum
				? perPageNum * this.pageCount - this.visibleRows.length
				: this.rcs.length - this.visibleRows.length;
			for (let i = 0; i < z; i++) {
				let $row = $('<tr>');
				for (let j = 0; j < 5; j++) {
					$row.append('<td>');
				}
				this.visibleRows.push($row[0]);
			}
			if (!this.$table) {
				this.$tbody = $('<tbody>');
				this.$table = $('<table>').addClass('wikitable catchangesviewer-table').append(
					$('<thead>').append(
						$('<tr>').append(
							$('<th>').text('±'),
							$('<th>').text('Date'),
							$('<th>').text('Page'),
							$('<th>').text('User'),
							$('<th>').text('Bot')
						)
					),
					this.$tbody
				);
			}
			this.setPage();
			navLayout.toggle(true).$element.before(this.$table);
		}
		setPage(increment) {
			if (this.pageCount > 1) {
				if (increment === 'first') {
					this.curPage = 0;
				} else if (increment === 'last') {
					this.curPage = this.pageCount - 1;
				} else if (increment) {
					this.curPage += increment;
					if (this.curPage < 0) {
						this.curPage = this.pageCount - 1;
					}
					if (this.curPage > this.pageCount - 1) {
						this.curPage = 0;
					}
				} else if (this.curPage > this.pageCount - 1) {
					this.curPage = this.pageCount - 1;
				}
			} else {
				this.curPage = 0;
			}
			let start = this.curPage * perPageNum;
			this.$tbody.html(
				this.visibleRows.slice(start, start + perPageNum)
			);
			navLabel.setLabel(this.curPage + 1 + ' / ' + this.pageCount);
			this.resetNavButtons();
		}
		resetNavButtons() {
			firstButton.setDisabled(this.curPage === 0);
			prevButton.setDisabled(this.pageCount < 2);
			nextButton.setDisabled(this.pageCount < 2);
			lastButton.setDisabled(this.curPage === this.pageCount - 1);
		}
		setDisabledAll(disabled) {
			[
				limitInput, filtersButton, userInput, untilInput, button,
				refreshButton, firstButton, prevButton, nextButton, lastButton,
				hideAdditionsCheckbox, hideRemovalsCheckbox, hideDuplicatesCheckbox
			].forEach(widget => {
				widget.setDisabled(disabled);
			});
		}
		addRow(rc, page) {
			let symbol = rc.action === 'addition' ? '+' : rc.action === 'removal' ? '−' : '?';
			rc.$row = $('<tr>').addClass(rc.action && 'catchangesviewer-' + rc.action).append(
				$('<td>').text(symbol),
				$('<td>').append(
					$('<a>').attr('href', mw.util.getUrl(page, {
						oldid: rc.revid
					})).text(rc.timestamp),
					' ',
					$('<span>').addClass('mw-changeslist-links').append(
						$('<span>').append(
							$('<a>').attr('href', mw.util.getUrl(page, {
								diff: rc.revid
							})).text('diff')
						),
						$('<span>').append(
							$('<a>').attr('href', mw.util.getUrl(page, {
								curid: rc.pageid,
								action: 'history'
							})).text('hist')
						)
					)
				),
				$('<td>').append(this.makeLink(page)),
				$('<td>').append(
					this.makeLink((rc.anon ? 'Special:Contributions/' : 'User:') + rc.user, rc.user),
					' ',
					$('<span>').addClass('mw-changeslist-links').append(
						$('<span>').append(
							this.makeLink('User talk:' + rc.user, 'talk')
						),
						!rc.anon && $('<span>').append(
							this.makeLink('Special:Contributions/' + rc.user, 'contribs')
						)
					)
				),
				$('<td>').text(rc.bot ? 'Yes' : 'No')
			);
			this.newRcs.push(rc);
		}
		makeLink(title, text) {
			let obj;
			if (this.titles.hasOwnProperty(title)) {
				obj = this.titles[title];
			} else {
				obj = { links: [] };
				this.titles[title] = obj;
			}
			let params = obj.red && { action: 'edit', redlink: 1 } ||
				obj.redirect && { redirect: 'no' };
			let $link = $('<a>').attr({
				href: mw.util.getUrl(obj.canonical || title, params),
				title: obj.canonical || title
			}).addClass(obj.classes).text(text || title);
			if (!obj.processed) obj.links.push($link[0]);
			return $link;
		}
		queryTitles(titles) {
			if (!titles.length) {
				this.fireHook();
				return;
			}
			let curTitles = titles.slice(0, 50);
			curTitles.forEach(title => {
				this.titles[title].processed = true;
			});
			api.post({
				action: 'query',
				titles: curTitles,
				prop: 'info',
				inprop: 'linkclasses',
				inlinkcontext: mw.config.get('wgPageName'),
				formatversion: 2
			}).always(response => {
				let query = response && response.query;
				if (!query) {
					this.fireHook();
					return;
				}
				(query.normalized || []).forEach(entry => {
					if (!this.titles.hasOwnProperty(entry.from)) return;
					let obj = this.titles[entry.from];
					obj.canonical = entry.to;
					this.titles[entry.to] = obj;
				});
				(query.pages || []).forEach(page => {
					if (!this.titles.hasOwnProperty(page.title)) return;
					let obj = this.titles[page.title];
					let classes = page.linkclasses || [];
					if (page.missing && !page.known) {
						classes.push('new');
						obj.red = true;
					} else if (classes.includes('mw-redirect')) {
						obj.redirect = true;
					}
					if (classes.length) obj.classes = classes;
				});
				curTitles.forEach(title => {
					let obj = this.titles[title];
					let $links = $(obj.links).addClass(obj.classes);
					$links.attr('href', mw.util.getUrl(
						obj.canonical || title,
						obj.red && { action: 'edit', redlink: 1 }
					));
					if (obj.canonical) $links.attr('title', obj.canonical);
					delete obj.links;
				});
				this.queryTitles(titles.slice(50));
			});
		}
		fireHook() {
			if (!this.newRcs.length) return;
			let tempRows = this.newRcs.map(rc => rc.$row.clone()[0]);
			let $tempTable = $('<table>').hide().append(tempRows)
				.insertAfter(this.$table);
			mw.hook('wikipage.content').fire($tempTable);
			this.newRcs.forEach((rc, i) => {
				rc.$row.html(tempRows[i].children);
			});
			$tempTable.remove();
			this.newRcs = [];
		}
		destroy() {
			if (this.$table) this.$table.remove();
			navLayout.toggle(false);
		}
	}
	let curSearch;
	let getOptions = () => {
		let options = {};
		Object.entries(filters).forEach(([k, v]) => {
			if (v.widget.getIcon() === 'check') {
				if (v.input) {
					let value = v.input.getValue();
					if (value) {
						options[k] = value;
					}
				} else {
					options.rcshow = options.rcshow || [];
					options.rcshow.push(k);
				}
			}
		});
		return options;
	};
	let isModified = () => {
		if (!curSearch) return false;
		let options = getOptions();
		return ['rcshow', 'rcuser', 'rcexcludeuser', 'rcstart'].some(k => (
			String(options[k]) !== String(curSearch.options[k])
		));
	};
	let updateButton = () => {
		if (isModified()) {
			button.setLabel('Search').setDisabled(false);
		} else if (curSearch) {
			curSearch.updateButton();
		}
	};
	let perPageNum = window.catchangesviewerChangesPerPage || 20;
	let limitInput = new OO.ui.NumberInputWidget({
		max: 500,
		min: 1,
		required: true,
		showButtons: false,
		title: 'Number of changes to load (1–500)',
		value: window.catchangesviewerDefaultLimit || 50
	}).setIndicator();
	let userInput = new mw.widgets.UserInputWidget({
		placeholder: 'User'
	}).on('change', updateButton).toggle();
	let untilInput = new mw.widgets.datetime.DateTimeInputWidget({
		clearable: false,
		min: new Date(Date.now() - 2592000000)
	}).on('change', updateButton).toggle();
	let filters = {
		'!anon': {
			widget: new OO.ui.MenuOptionWidget({
				data: '!anon',
				label: 'No IPs',
				icon: 'none'
			}),
			incompatibleWith: 'anon'
		},
		anon: {
			widget: new OO.ui.MenuOptionWidget({
				data: 'anon',
				label: 'IPs only',
				icon: 'none'
			}),
			incompatibleWith: '!anon'
		},
		'!bot': {
			widget: new OO.ui.MenuOptionWidget({
				data: '!bot',
				label: 'No bots',
				icon: 'none'
			}),
			incompatibleWith: 'bot'
		},
		bot: {
			widget: new OO.ui.MenuOptionWidget({
				data: 'bot',
				label: 'Bots only',
				icon: 'none'
			}),
			incompatibleWith: '!bot'
		},
		rcuser: {
			widget: new OO.ui.MenuOptionWidget({
				data: 'rcuser',
				label: 'This user:',
				icon: 'none'
			}),
			incompatibleWith: 'rcexcludeuser',
			input: userInput
		},
		rcexcludeuser: {
			widget: new OO.ui.MenuOptionWidget({
				data: 'rcexcludeuser',
				label: 'Not this user:',
				icon: 'none'
			}),
			incompatibleWith: 'rcuser',
			input: userInput
		},
		rcstart: {
			widget: new OO.ui.MenuOptionWidget({
				data: 'rcstart',
				label: 'Until:',
				icon: 'none'
			}),
			input: untilInput
		}
	};
	let filtersButton = new OO.ui.ButtonMenuSelectWidget({
		icon: 'funnel',
		menu: { items: Object.values(filters).map(o => o.widget) },
		invisibleLabel: true,
		label: 'Filters'
	});
	filtersButton.getMenu().on('choose', option => {
		let data = filters[option.getData()];
		if (option.getIcon() === 'none') {
			option.setIcon('check');
			if (data.incompatibleWith) {
				filters[data.incompatibleWith].widget.setIcon('none');
			}
			filtersButton.setIndicator('required');
			if (data.input) {
				data.input.toggle(true);
			}
		} else {
			option.setIcon('none');
			if (!Object.values(filters).some(o => o.widget.getIcon() === 'check')) {
				filtersButton.setIndicator();
			}
			if (data.input) {
				data.input.toggle(false);
			}
		}
		updateButton();
	});
	let button = new OO.ui.ButtonInputWidget({
		flags: ['primary', 'progressive'],
		label: 'Search',
		type: 'submit'
	}).on('click', () => {
		if (curSearch) {
			if (isModified()) {
				curSearch.destroy();
				curSearch = new CatChangesSearch();
			}
		} else {
			curSearch = new CatChangesSearch();
		}
		curSearch.load();
	});
	let refreshButton = new OO.ui.ButtonWidget({
		icon: 'reload',
		invisibleLabel: true,
		label: 'Load new'
	}).toggle().on('click', () => {
		curSearch.load(true);
	});
	let form = new OO.ui.FormLayout({
		classes: ['oo-ui-horizontalLayout'],
		items: [
			limitInput, filtersButton, userInput, untilInput, button, refreshButton
		]
	});
	let navLabel = new OO.ui.LabelWidget();
	let firstButton = new OO.ui.ButtonWidget({
		icon: 'first',
		invisibleLabel: true,
		label: 'Newest ' + perPageNum
	}).on('click', () => {
		curSearch.setPage('first');
	});
	let prevButton = new OO.ui.ButtonWidget({
		icon: 'previous',
		invisibleLabel: true,
		label: 'Newer ' + perPageNum
	}).on('click', () => {
		curSearch.setPage(-1);
	});
	let nextButton = new OO.ui.ButtonWidget({
		icon: 'next',
		invisibleLabel: true,
		label: 'Older ' + perPageNum
	}).on('click', () => {
		curSearch.setPage(1);
	});
	let lastButton = new OO.ui.ButtonWidget({
		icon: 'last',
		invisibleLabel: true,
		label: 'Oldest ' + perPageNum
	}).on('click', () => {
		curSearch.setPage('last');
	});
	let hideAdditionsCheckbox = new OO.ui.CheckboxInputWidget().on('change', selected => {
		if (selected) {
			hideRemovalsCheckbox.setSelected(false, true);
		}
		curSearch.initNav();
	});
	let hideRemovalsCheckbox = new OO.ui.CheckboxInputWidget().on('change', selected => {
		if (selected) {
			hideAdditionsCheckbox.setSelected(false, true);
		}
		curSearch.initNav();
	});
	let hideDuplicatesCheckbox = new OO.ui.CheckboxInputWidget().on('change', () => {
		curSearch.initNav();
	});
	let navLayout = new OO.ui.HorizontalLayout({
		items: [
			navLabel,
			new OO.ui.ButtonGroupWidget({
				items: [firstButton, prevButton, nextButton, lastButton]
			}),
			new OO.ui.HorizontalLayout({
				items: [
					new OO.ui.LabelWidget({ label: 'Hide:' }),
					new OO.ui.FieldLayout(hideAdditionsCheckbox, {
						align: 'inline',
						label: 'Additions'
					}),
					new OO.ui.FieldLayout(hideRemovalsCheckbox, {
						align: 'inline',
						label: 'Removals'
					}),
					new OO.ui.FieldLayout(hideDuplicatesCheckbox, {
						align: 'inline',
						label: 'Duplicates'
					})
				]
			})
		]
	}).toggle();
	let $error = $('<div>');
	let $div = $('<div>').addClass('catchangesviewer').append(
		$('<h2>').text('Recent changes'), navLayout.$element, form.$element, $error
	);
	$(() => {
		$('.mw-category-generated').first().before($div);
	});
});