Jump to content

User:Nardog/MoveHistory-core.js

From Wikipedia, the free encyclopedia
This is an old revision of this page, as edited by Nardog (talk | contribs) at 19:03, 12 January 2024 (fix). The present address (URL) is a permanent link to this revision, which may differ significantly from the current revision.
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',
	'jquery.tablesorter'
], function moveHistoryCore() {
	mw.loader.addStyleTag('.movehistory .oo-ui-window-body{padding:0 1em 1em} .movehistory-status{text-align:center} .movehistory .wikitable{margin:0;width:100%;word-break:break-word}');
	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 = $('<p>').addClass('movehistory-status')
				.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.aborted = false;
			if (this.$sortable) {
				this.$sortable.replaceWith(this.$table);
			}
			this.doNext();
		}
		doNext() {
			if (!this.aborted && this.candidates.length) {
				this.loadMoves();
			} else if (!this.aborted && !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 retrieving 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 retrieving 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>').text('Date'),
							$('<th>').text('From'),
							$('<th>').text('To'),
							$('<th>').text('Performer'),
							$('<th>').text('Comment')
						)
					),
					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;
			}
			$('<tr>').append(
				$('<td>').append(
					$('<a>').attr({
						href: mw.util.getUrl(this.page, {
							action: 'history',
							offset: move.offset
						}),
						title: 'See history up to this move',
						target: '_blank'
					}).text(move.date)
				),
				$('<td>').append(this.makeLink(move.from)),
				$('<td>').append(this.makeLink(move.to)),
				$('<td>').append(
					this.makeLink('User:' + move.user, move.user, true),
					' ',
					$('<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>').append($(move.comment).clone().attr('target', '_blank'))
			).appendTo(this.$tbody);
			dialog.setSize('larger');
			this.moves.push(move);
		}
		finish(error) {
			let count = this.moves.length;
			this.setStatus(error || `Found ${count} move${count === 1 ? '' : 's'} in ${
				this.revCount.toLocaleString()
			} revisions${
				this.revCount ? ` from ${this.firstDate} to ${this.lastDate}` : ''
			}.${
				this.complete ? '' : ' Click Continue to inspect more revisions.'
			}`);
			this.setBusy();
			this.mode = this.complete && !this.candidates.length
				? 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) {
				this.makeSortable();
				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
			}).always(response => {
				let query = response && response.query;
				if (!query) {
					this.makeSortable();
					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));
			});
		}
		makeSortable() {
			this.$sortable = this.$table.clone().addClass('sortable').tablesorter();
			this.$table.replaceWith(this.$sortable);
			dialog.updateSize();
		}
		copyResults() {
			let text = this.$trail.contents().get().map(n => (
				n.tagName === 'A' ? `[[:${n.textContent}]]` : n.textContent
			)).join('') + `
{| class="wikitable sortable plainlinks"
! Date
! From
! To
! Performer
! Comment
${this.moves.map(move => `|-
| [{{fullurl:${this.page}|action=history&offset=${move.offset}}} ${move.date}]
| ${this.titles[move.from] && this.titles[move.from].redirect ? `[{{fullurl:${move.from}|redirect=no}} ${move.from}]` : `[[:${move.from}]]`}
| ${this.titles[move.to] && this.titles[move.to].redirect ? `[{{fullurl:${move.to}|redirect=no}} ${move.to}]` : `[[:${move.to}]]`}
| [[User:${move.user}|${move.user}]] ([[User talk:${move.user}|talk]] &#124; [[Special:Contributions/${move.user}|contribs]])
|${move.comment.length ? ' ' + 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('') : ''}
`).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: 'abort',
			label: 'Abort',
			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 updateButton = () => {
			let invalid = ['pageInput', 'sinceInput', 'untilInput']
				.some(n => this[n].hasFlag('invalid'));
			this.actions.get()[1].setDisabled(invalid);
		};
		this.pageInput = new mw.widgets.TitleInputWidget({
			$overlay: this.$overlay,
			api: api,
			excludeDynamicNamespaces: true,
			required: true,
			showMissing: false
		}).on('flag', updateButton);
		let rt = mw.Title.newFromText(mw.config.get('wgRelevantPageName'));
		if (rt && rt.namespace >= 0) {
			this.pageInput.setValue(rt.toText());
		}
		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'
		}).on('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') : null;
			this.sinceInput.emit('change');
		}).on('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'
				})
			],
			content: [$('<input>').attr({ type: 'submit', hidden: '' })]
		}).on('submit', () => {
			if (!this.actions.get()[1].isDisabled()) {
				this.executeAction('search');
			}
		});
		this.$results = $('<div>');
		this.$body.append(this.form.$element);
	};
	MoveHistoryDialog.prototype.getSetupProcess = function (data) {
		return MoveHistoryDialog.super.prototype.getSetupProcess.call(this, data).next(function () {
			this.pageInput.emit('change');
			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') {
			let config = [
				this.pageInput.getValue(),
				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 ? 'larger' : 'medium');
		} else if (action === 'continue') {
			this.search.start();
		} else if (action === 'abort') {
			this.search.aborted = 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();
});