Jump to content

User:Nardog/MoveHistory-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', 'mediawiki.Title', 'oojs-ui-windows',
	'oojs-ui-widgets', 'mediawiki.widgets', 'mediawiki.widgets.DateInputWidget',
	'oojs-ui.styles.icons-interactions', 'mediawiki.interface.helpers.styles'
], function moveHistoryCore() {
	mw.loader.addStyleTag('.movehistory .oo-ui-window-body{padding:1em;display:flex;justify-content:center} .movehistory form{flex-grow:1} .movehistory .wikitable{width:100%;margin-top:0;white-space:nowrap} .movehistory-date, .movehistory-title{text-align:center} .movehistory-title, .movehistory-comment{white-space:normal;word-break:break-word} .movehistory-comment{vertical-align:top;max-width:16em}');
	let dialog;
	let nowiki = s => s.replace(
		/["&'<=>\[\]{|}]|:(?=\/\/)|_(?=_)|~(?=~~)/g,
		m => '&#' + m.codePointAt(0) + ';'
	);
	let articlePath = mw.config.get('wgArticlePath').replace(/\$1.*/, '');
	let getPath = (pathname, search, hash) => {
		let s = '';
		if (pathname && pathname.startsWith(articlePath)) {
			s = decodeURIComponent(pathname.slice(articlePath.length));
		} else if (search) {
			let title = mw.util.getParamValue('title', search);
			if (title) {
				s = title;
			}
		}
		if (hash) {
			s += mw.util.percentDecodeFragment(hash);
		}
		return s.replace(/_/g, ' ');
	};
	let api = new mw.Api({
		ajax: { headers: { 'Api-User-Agent': 'MoveHistory (https://en.wikipedia.org/wiki/User:Nardog/MoveHistory)' } }
	});
	let arrow = document.dir === 'rtl' ? ' ← ' : ' → ';
	class MoveHistorySearch {
		constructor(page, dir, since, until) {
			this.$status = $('<div>').appendTo(dialog.$results.empty());
			this.page = page;
			this.ascending = dir === 'newer';
			let sinceTs = (since || '2005-06-25') + 'T00:00:00Z';
			let untilTs;
			if (until) {
				untilTs = until + 'T23:59:59Z';
			}
			this.params = {
				action: 'query',
				titles: page,
				prop: 'revisions',
				rvstart: this.ascending ? sinceTs : untilTs,
				rvend: this.ascending ? untilTs : sinceTs,
				rvdir: dir,
				rvprop: 'sha1|timestamp|user|comment',
				rvlimit: 'max',
				formatversion: 2
			};
			this.revCount = 0;
			this.candidates = [];
			this.titles = {};
			this.noRedirLinks = new WeakSet();
			this.moves = [];
			this.start();
		}
		start() {
			this.setBusy(true);
			dialog.actions.setMode('searching');
			this.i = 0;
			this.paused = false;
			this.doNext();
		}
		doNext() {
			if (!this.paused && this.candidates.length) {
				this.loadMoves();
			} else if (!this.paused && !this.complete && this.i < 4) {
				this.loadRevs();
			} else {
				this.finish();
			}
		}
		loadRevs() {
			this.i++;
			this.setStatus(`Loading history${
				this.revCount
					? this.ascending
						? ' after ' + this.lastDate
						: ' before ' + this.firstDate
					: ''
			}...`);
			api.get(this.params).always((response, error) => {
				let errorMsg = ((error || {}).error || {}).info;
				if (!response || typeof response === 'string' || errorMsg) {
					this.finish('Error loading revisions' + (errorMsg ? ': ' + errorMsg : ''));
					return;
				}
				let revs = ((((response || {}).query || {}).pages || [])[0] || {}).revisions;
				if (revs) {
					this.processRevs(revs);
				}
				this.params.rvcontinue = ((response || {}).continue || {}).rvcontinue;
				if (!this.params.rvcontinue) {
					this.complete = response.batchcomplete;
				}
				this.doNext();
			});
		}
		processRevs(revs) {
			this.revCount += revs.length;
			if (!this.ascending) {
				revs.reverse();
			}
			revs.forEach(rev => {
				let comp = this.lastRev;
				this.lastRev = rev;
				if (!rev.comment || !rev.user || !rev.sha1 ||
					!comp || comp.sha1 !== rev.sha1
				) {
					return;
				}
				let matches = rev.comment.match(/\[\[:?([^\]]+)\]\].+?\[\[:?([^\]]+)\]\]/);
				if (matches) {
					rev.matches = matches.slice(1);
				}
				this.candidates.push(rev);
			});
			if (!this.ascending || !this.firstDate) {
				this.firstDate = revs[0].timestamp;
			}
			if (this.ascending || !this.lastDate) {
				this.lastDate = this.lastRev.timestamp;
			}
		}
		loadMoves() {
			let rev = this.candidates[this.ascending ? 'shift' : 'pop']();
			this.setStatus(`Seeing if there was a move at ${rev.timestamp}...`);
			let date = Date.parse(rev.timestamp) / 1000;
			api.get({
				action: 'query',
				list: 'logevents',
				letype: 'move',
				lestart: date + 60,
				leend: date,
				leprop: 'details|title|user|parsedcomment',
				lelimit: 'max',
				formatversion: 2
			}).always((response, error) => {
				let errorMsg = ((error || {}).error || {}).info;
				if (!response || typeof response === 'string' || errorMsg) {
					this.finish('Error loading moves' + (errorMsg ? ': ' + errorMsg : ''));
					return;
				}
				(((response || {}).query || {}).logevents || []).reverse().some(le => {
					if (le.user !== rev.user || !rev.comment.includes(le.title)) return;
					let target = ((le || {}).params || {}).target_title;
					if (!target || !rev.comment.includes(target) ||
						rev.matches &&
						[le.title, target].some(s => !rev.matches.includes(s))
					) {
						return;
					}
					this.addMove({
						date: rev.timestamp,
						offset: new Date(Date.parse(rev.timestamp) + 1000)
							.toISOString().slice(0, -5).replace(/\D/g, ''),
						from: le.title,
						to: target,
						user: le.user,
						comment: $.parseHTML(le.parsedcomment)
					});
					return true;
				});
				this.doNext();
			});
		}
		addMove(move) {
			if (!this.moves.length) {
				this.lastName = this.ascending ? move.from : move.to;
				this.$trail = $('<p>').append(this.makeLink(this.lastName));
				this.$tbody = $('<tbody>');
				this.$table = $('<table>').addClass('wikitable').append(
					$('<thead>').append(
						$('<tr>').append(
							$('<th>').attr('rowspan', 2).text('Date'),
							$('<th>').text(this.ascending ? 'From' : 'To'),
							$('<th>').attr('rowspan', 2).text('Performer'),
							$('<th>').attr('rowspan', 2).text('Comment')
						),
						$('<tr>').append(
							$('<th>').text(this.ascending ? 'To' : 'From')
						)
					),
					this.$tbody
				);
				this.$status.after('<hr>', this.$trail, this.$table);
			}
			if (this.ascending) {
				if (this.lastName !== move.from) {
					this.$trail.append(arrow + '?' + arrow, this.makeLink(move.from));
				}
				this.$trail.append(arrow, this.makeLink(move.to));
				this.lastName = move.to;
			} else {
				if (this.lastName !== move.to) {
					this.$trail.prepend(this.makeLink(move.to), arrow + '?' + arrow);
				}
				this.$trail.prepend(this.makeLink(move.from), arrow);
				this.lastName = move.from;
			}
			this.$tbody.append(
				$('<tr>').append(
					$('<td>').attr({ class: 'movehistory-date', rowspan: 2 }).append(
						$('<a>').attr({
							href: mw.util.getUrl(this.page, {
								action: 'history',
								offset: move.offset
							}),
							title: 'See history up to this move',
							target: '_blank'
						}).append(move.date.slice(0, 10), '<br>', move.date.slice(10))
					),
					$('<td>').addClass('movehistory-title').append(
						this.makeLink(this.ascending ? move.from : move.to)
					),
					$('<td>').attr({ class: 'movehistory-user', rowspan: 2 }).append(
						this.makeLink('User:' + move.user, move.user, true),
						'<br>',
						$('<span>').addClass('mw-changeslist-links').append(
							$('<span>').append(this.makeLink('User talk:' + move.user, 'talk', true)),
							$('<span>').append(this.makeLink('Special:Contributions/' + move.user, 'contribs', true))
						)
					),
					$('<td>').attr({ class: 'movehistory-comment', rowspan: 2 }).append(
						$(move.comment).clone().attr('target', '_blank')
					)
				),
				$('<tr>').append(
					$('<td>').addClass('movehistory-title').append(
						this.makeLink(this.ascending ? move.to : move.from)
					)
				)
			);
			dialog.setSize('large');
			this.moves.push(move);
		}
		finish(error) {
			let count = this.moves.length;
			let complete = this.complete && !this.candidates.length;
			this.setStatus(error || `Found ${count} move${count === 1 ? '' : 's'} in ${
				this.revCount.toLocaleString('en')
			} revisions${
				this.revCount ? ` from ${this.firstDate} to ${this.lastDate}` : ''
			}.${
				complete ? '' : ' Click Continue to inspect more revisions.'
			}`);
			this.setBusy();
			this.mode = complete
				? count ? 'found' : 'notFound'
				: count ? 'pausedFound' : 'paused';
			dialog.actions.setMode(this.mode);
			if (!count) return;
			this.queryTitles(
				Object.entries(this.titles)
					.filter(([k, v]) => !v.processed).map(([k]) => k)
			);
		}
		setBusy(busy) {
			MoveHistoryDialog.static.escapable = !busy;
			dialog.$navigation.toggleClass('oo-ui-pendingElement-pending', !!busy);
		}
		setStatus(text) {
			this.$status.text(text);
			dialog.updateSize();
			console.log(text);
		}
		makeLink(title, text, allowRedirect) {
			let obj;
			if (this.titles.hasOwnProperty(title)) {
				obj = this.titles[title];
			} else {
				obj = { links: [] };
				this.titles[title] = obj;
				if (title === this.page) {
					obj.classes = ['mw-selflink', 'selflink'];
					obj.processed = true;
				}
			}
			let params = obj.red && { action: 'edit', redlink: 1 } ||
				!allowRedirect && obj.redirect && { redirect: 'no' };
			let $link = $('<a>').attr({
				href: mw.util.getUrl(obj.canonical || title, params),
				title: obj.canonical || title,
				target: '_blank'
			}).addClass(obj.classes).text(text || title);
			if (!allowRedirect && !obj.processed) {
				this.noRedirLinks.add($link[0]);
			}
			if (!obj.processed) {
				obj.links.push($link[0]);
			}
			return $link;
		}
		queryTitles(titles) {
			if (!titles.length) 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: this.page,
				formatversion: 2
			}, {
				headers: { 'Promise-Non-Write-API-Action': 1 }
			}).always(response => {
				let query = response && response.query;
				if (!query) 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', i => mw.util.getUrl(
						obj.canonical || title,
						obj.red && { action: 'edit', redlink: 1 } ||
						obj.redirect && this.noRedirLinks.has($links[i]) &&
						{ redirect: 'no' }
					));
					if (obj.canonical) {
						$links.attr('title', obj.canonical);
					}
					delete obj.links;
				});
				this.queryTitles(titles.slice(50));
			});
		}
		copyResults() {
			let text = this.$trail.contents().get().map(n => (
				n.tagName === 'A' ? `[[:${n.textContent}]]` : n.textContent
			)).join('') + `
{| class="wikitable plainlinks" style="white-space: nowrap;"
! rowspan="2" | Date
! ${this.ascending ? 'From' : 'To'}
! rowspan="2" | Performer
! rowspan="2" | Comment
|-
! ${this.ascending ? 'To' : 'From'}
${this.moves.map(move => `|-
| rowspan="2" style="text-align: center;" | [{{fullurl:${this.page}|action=history&offset=${move.offset}}} ${move.date.slice(0, 10)}<br>${move.date.slice(10)}]
| style="text-align: center;" | ${
	this.titles[this.ascending ? move.from : move.to] && this.titles[this.ascending ? move.from : move.to].redirect
		? `[{{fullurl:${this.ascending ? move.from : move.to}|redirect=no}} ${this.ascending ? move.from : move.to}]`
		: `[[:${this.ascending ? move.from : move.to}]]`
}
| rowspan="2" | [[User:${move.user}|${move.user}]]<br>([[User talk:${move.user}|talk]] &#124; [[Special:Contributions/${move.user}|contribs]])
| rowspan="2" ${
	move.comment.length ? 'style="vertical-align: top; white-space: normal;" | ' + move.comment.map(n => (
		n.tagName === 'A' ? `[[:${
			n.classList.contains('extiw')
				? n.title
				: getPath(n.pathname, n.search, n.hash)
		}|${nowiki(n.textContent)}]]` : nowiki(n.textContent)
	)).join('') : '|'
}
|-
| style="text-align: center;" | ${
	this.titles[this.ascending ? move.to : move.from] && this.titles[this.ascending ? move.to : move.from].redirect
		? `[{{fullurl:${this.ascending ? move.to : move.from}|redirect=no}} ${this.ascending ? move.to : move.from}]`
		: `[[:${this.ascending ? move.to : move.from}]]`
}
`).join('')}|}`;
			let $textarea = $('<textarea>').attr({
				readonly: '',
				style: 'position:fixed;top:-100%'
			}).val(text).appendTo(document.body);
			$textarea[0].select();
			let copied;
			try {
				copied = document.execCommand('copy');
			} catch (e) {}
			$textarea.remove();
			if (copied) {
				mw.notify('Copied');
			} else {
				mw.notify('Copy failed', { type: 'error' });
			}
		}
	}
	function MoveHistoryDialog(config) {
		MoveHistoryDialog.parent.call(this, config);
		this.$element.addClass('movehistory');
	}
	OO.inheritClass(MoveHistoryDialog, OO.ui.ProcessDialog);
	MoveHistoryDialog.static.name = 'moveHistoryDialog';
	MoveHistoryDialog.static.title = 'Move history';
	MoveHistoryDialog.static.size = 'small';
	MoveHistoryDialog.static.actions = [
		{
			modes: 'config',
			flags: ['safe', 'close']
		},
		{
			action: 'search',
			label: 'Search',
			modes: 'config',
			flags: ['primary', 'progressive'],
			disabled: true
		},
		{
			action: 'goBack',
			modes: ['paused', 'pausedFound', 'found', 'notFound'],
			flags: ['safe', 'back']
		},
		{
			action: 'continue',
			label: 'Continue',
			modes: ['paused', 'pausedFound'],
			flags: ['primary', 'progressive']
		},
		{
			action: 'pause',
			label: 'Pause',
			modes: 'searching',
			flags: ['primary', 'destructive']
		},
		{
			action: 'copy',
			modes: ['pausedFound', 'found'],
			label: 'Copy results as wikitext'
		}
	];
	MoveHistoryDialog.prototype.initialize = function () {
		MoveHistoryDialog.parent.prototype.initialize.apply(this, arguments);
		let rt = mw.Title.newFromText(mw.config.get('wgRelevantPageName'));
		this.pageInput = new mw.widgets.TitleInputWidget({
			$overlay: this.$overlay,
			api: api,
			excludeDynamicNamespaces: true,
			required: true,
			showMissing: false,
			value: rt && rt.namespace >= 0 ? rt.toText() : ''
		}).connect(this, { change: 'updateButton' });
		this.directionInput = new OO.ui.RadioSelectInputWidget({
			options: [
				{ data: 'newer', label: 'Oldest first' },
				{ data: 'older', label: 'Newest first' }
			]
		});
		this.sinceInput = new mw.widgets.DateInputWidget({
			$overlay: this.$overlay,
			displayFormat: 'YYYY-MM-DD'
		}).connect(this, { flag: 'updateButton' });
		this.untilInput = new mw.widgets.DateInputWidget({
			$overlay: this.$overlay,
			displayFormat: 'YYYY-MM-DD',
			mustBeAfter: '2005-06-24'
		}).on('change', () => {
			let m = this.untilInput.getMoment();
			this.sinceInput.mustBeBefore = m.isValid() ? m.add(1, 'days') : undefined;
			this.sinceInput.setValidityFlag();
		}).connect(this, { flag: 'updateButton' });
		this.form = new OO.ui.FormLayout({
			items: [
				new OO.ui.FieldLayout(this.pageInput, {
					label: 'Page:',
					align: 'top'
				}),
				new OO.ui.FieldLayout(this.directionInput, {
					label: 'Direction:',
					align: 'top'
				}),
				new OO.ui.FieldLayout(this.sinceInput, {
					label: 'Since:',
					align: 'top'
				}),
				new OO.ui.FieldLayout(this.untilInput, {
					label: 'Until:',
					align: 'top'
				})
			]
		}).connect(this, { submit: ['executeAction', 'search'] });
		this.$results = $('<div>');
		this.form.$element
			.append($('<input>').attr({ type: 'submit', hidden: '' }))
			.appendTo(this.$body);
	};
	MoveHistoryDialog.prototype.updateButton = function () {
		this.actions.setAbilities({ search: this.canSearch() });
	};
	MoveHistoryDialog.prototype.canSearch = function () {
		return !!this.pageInput.getValue() &&
			['sinceInput', 'untilInput'].every(n => !this[n].hasFlag('invalid'));
	};
	MoveHistoryDialog.prototype.getSetupProcess = function (data) {
		return MoveHistoryDialog.super.prototype.getSetupProcess.call(this, data).next(function () {
			this.updateButton();
			this.actions.setMode('config');
		}, this);
	};
	MoveHistoryDialog.prototype.getReadyProcess = function (data) {
		return MoveHistoryDialog.super.prototype.getReadyProcess.call(this, data).next(function () {
			this.pageInput.focus();
		}, this);
	};
	MoveHistoryDialog.prototype.getActionProcess = function (action) {
		if (action === 'search') {
			if (!this.canSearch()) return;
			let config = [
				this.pageInput.getMWTitle().toText(),
				this.directionInput.getValue(),
				this.sinceInput.getValue(),
				this.untilInput.getValue()
			];
			if (!this.config || config.some((v, i) => v !== this.config[i])) {
				this.config = config;
				this.search = new MoveHistorySearch(...config);
			} else {
				this.actions.setMode(this.search.mode);
			}
			this.form.toggle(false).$element.after(this.$results);
			this.setSize(this.search.moves.length ? 'large' : 'medium');
		} else if (action === 'continue') {
			this.search.start();
		} else if (action === 'pause') {
			this.search.paused = true;
		} else if (action === 'copy') {
			this.search.copyResults();
		} else {
			this.actions.setMode('config');
			this.$results.detach();
			this.form.toggle(true);
			this.setSize('small');
		}
		return MoveHistoryDialog.super.prototype.getActionProcess.call(this, action);
	};
	dialog = new MoveHistoryDialog();
	window.moveHistoryDialog = dialog;
	let winMan = new OO.ui.WindowManager();
	winMan.addWindows([dialog]);
	winMan.$element.appendTo(OO.ui.getTeleportTarget());
	dialog.open();
});