User:Murph9000/usercontribs.js

From Meta, a Wikimedia project coordination wiki

Note: After publishing, you may have to bypass your browser's cache to see the changes.

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * Enhance Special:Contributions with features from API:Usercontribs.
 *
 * Copyright © 2017 User:Murph9000 @ English Wikipedia.  All rights reserved.
 *
 * Released under the Creative Commons Attribution-ShareAlike 3.0 Unported License
 * Wikipedia:Text_of_Creative_Commons_Attribution-ShareAlike_3.0_Unported_License
 *
 * Released under the Creative Commons Attribution-ShareAlike 4.0 International License
 * Wikipedia:Text_of_Creative_Commons_Attribution-ShareAlike_4.0_International_License
 *
 * Released under the GNU Free Documentation License
 * Wikipedia:Text_of_the_GNU_Free_Documentation_License
 *
 * Released under the GNU General Public License, version 2 or later
 * https://www.gnu.org/licenses/gpl-2.0.html
 *
 * THIS SOFTWARE IS PROVIDED BY THE AUTHOR AND CONTRIBUTORS ``AS IS'' AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED.  IN NO EVENT SHALL THE AUTHOR OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
 * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
 * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
 * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 * SUCH DAMAGE.
 */

/*
 * Copyright (c) 1990, 1993
 *	The Regents of the University of California.  All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without
 * modification, are permitted provided that the following conditions
 * are met:
 * 1. Redistributions of source code must retain the above copyright
 *    notice, this list of conditions and the following disclaimer.
 * 2. Redistributions in binary form must reproduce the above copyright
 *    notice, this list of conditions and the following disclaimer in the
 *    documentation and/or other materials provided with the distribution.
 * 3. Neither the name of the University nor the names of its contributors
 *    may be used to endorse or promote products derived from this software
 *    without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
 * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
 * ARE DISCLAIMED.  IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
 * FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
 * DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
 * OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
 * HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
 * LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
 * OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
 * SUCH DAMAGE.
 */

( function ( mw, $ ) {
	'use strict';
	var FILE = 'meta:User:Murph9000/usercontribs.js';
	console.info( FILE, 'startup' );

	var $content, $form, form, $inputContinue, $list, $spinner,
		$usersDl, $usersDt, $usersDd, $usersList,
		Api, cachingApi, oresPromise, specialPageAliasesPromise, tagsPromise,
		usercontribsContinue,
		conf = mw.config.get( [
			'wgFormattedNamespaces',
			'wgNamespaceIds',
			'wgUserName',
		] );
	// This isn't exported to the JS env for some reason
	// 500 is the MW 1.28 default setting
	conf.wgRCChangedSizeThreshold = 500;

	var	NS_USER = conf.wgNamespaceIds.user,
		NS_USER_TALK = conf.wgNamespaceIds.user_talk,
		contributionsTitle = 'Special:Contributions',
		nsUser = conf.wgFormattedNamespaces[ NS_USER ],
		nsUserTalk = conf.wgFormattedNamespaces[ NS_USER_TALK ];

	/**
	 * mediawiki-1.28.2/includes/templates/SpecialContributionsLine.mustache
	 */
	var templateName = 'SpecialContributionsLine.mustache',
		templateBody = '\
{{{ del }}}{{{ timestamp }}}\n\
{{{ diffHistLinks }}}{{{ charDifference }}}{{# flags }}{{{ . }}}{{/ flags }}\n\
{{{ articleLink }}}{{{ userlink }}}\n\
{{{ logText }}}\n\
{{{ topmarktext }}}{{# rev-deleted-user-contribs }} <strong>{{{ . }}}</strong>{{/ rev-deleted-user-contribs }}\n\
{{{ tagSummary }}}\n\
';

	var Html = mw.html,

		mLimitsShown = [ 10, 20, 50, 100, 250, 500 ],
		mDefaultLimit = 50,

		ores = {
			damagingPrefMap: {
				'hard': 'maybebad',
				'soft': 'likelybad',
				'softest': 'verylikelybad',
			},
	
			highlight:		mw.user.options.get( 'oresHighlight' ),
			damagingPref:	mw.user.options.get( 'oresDamagingPref' ),
			thresholds:		mw.config.get( 'oresThresholds' ),
	
			// Order must be highest to lowest severity,
			// to match processing in usercontribsCallback()
			levels:			[ 'verylikelybad', 'likelybad', 'maybebad' ],
		},

		param = {},
		ucParam = {},
		ucprop = [
			'ids',
			'title',
			'timestamp',
			'parsedcomment',
			'size',
			'sizediff',
			'flags',
			'tags',
		],
		users = {},

		separator = ' <span class="mw-changeslist-separator">. .</span> ';

	// @(#)bsearch.c	8.1 (Berkeley) 6/4/93
	// $FreeBSD: releng/10.3/lib/libc/stdlib/bsearch.c 251069 2013-05-28 20:57:40Z emaste $

	/*
	 * Perform a binary search.
	 *
	 * The code below is a bit sneaky.  After a comparison fails, we
	 * divide the work in half by moving either left or right. If lim
	 * is odd, moving left simply involves halving lim: e.g., when lim
	 * is 5 we look at item 2, so we change lim to 2 so that we will
	 * look at items 0 & 1.  If lim is even, the same applies.  If lim
	 * is odd, moving right again involes halving lim, this time moving
	 * the base up one item past p: e.g., when lim is 5 we change base
	 * to item 3 and make lim 2 so that we will look at items 3 and 4.
	 * If lim is even, however, we have to shrink it by one before
	 * halving: e.g., when lim is 4, we still looked at item 2, so we
	 * have to make lim 3, then halve, obtaining 1, so that we will only
	 * look at item 3.
	 */
	function bsearch(key, arr, compar)
	{
		var base = 0;
		var lim;
		var cmp;
		var p;

		for (lim = arr.length; lim !== 0; lim >>= 1) {
			p = base + (lim >> 1);
			cmp = compar(key, arr[p]);
			if (cmp === 0)
				return arr[p];
			if (cmp > 0) {	/* key > p: move right */
				base = p + 1;
				lim--;
			}		/* else move left */
		}
		return null;
	}

	function compare( left, right ) {
		return ((left > right) - (left < right));
	}

	/**
	 * Given a value, escape it so that it can be used as a CSS class and
	 * return it.
	 *
	 * @see http://www.w3.org/TR/CSS21/syndata.html Valid characters/format
	 *
	 * mediawiki-1.28.2/includes/Sanitizer.php:escapeClass()
	 * 
	 * @param string $class
	 * @return string
	 */
	function escapeClass( $class ) {
		// Convert ugly stuff to underscores and kill underscores in ugly places
		return $class
			.replace( /(^[0-9\-])|[\x00-\x20!"#$%&'()*+,.\/:;<=>?@[\]^`{|}~]|\xC2\xA0/g, '_' )
			.replace( /_+/g, '_' )
			.replace( /_$/, '' );
	}

	/**
	 * extend mw.Api
	 */
	function extendApi( Api ) {
		$.extend( Api.prototype, {
			getOres: function () {
				return this.get( {
					action: 'query',
					meta: 'ores'
				} ).then( function ( data ) {
					console.log( FILE, 'getOres data', data );

					if ( !data.batchcomplete ) {
						mw.log.warn( FILE, 'specialpagealiases API query, batchcomplete not true');
					}

					if ( data.query && data.query.ores ) {
						return data.query.ores;
					}
					return $.Deferred().reject().promise();
				} );
			},

			loadOres: function () {
				return this.getOres().then( function ( data ) {
					ores.thresholds = {
						damaging: data.damagingthresholds
					};
				} );
			},

			loadOresIfMissing: function () {
				if ( !ores.highlight || ores.thresholds ) {
					return $.Deferred().resolve();
				}

				return this.loadOres();
			},

			getSpecialPageAliases: function () {
				return this.get( {
					action: 'query',
					meta: 'siteinfo',
					siprop: 'specialpagealiases',
				} ).then( function ( data ) {
					console.log( FILE, 'getSpecialPageAliases data', data );

					if ( !data.batchcomplete ) {
						mw.log.warn( FILE, 'specialpagealiases API query, batchcomplete not true');
					}

					if ( data.query && data.query.specialpagealiases ) {
						return data.query.specialpagealiases;
					}
					return $.Deferred().reject().promise();
				} );
			},

			loadSpecialPageAliases: function () {
				return this.getSpecialPageAliases().then(
					function ( specialpagealiases ) {
						var result = {};
						for ( var page of specialpagealiases ) {
							result[ page.realname ] = page.aliases;
						}
						return result;
					}
				);
			},

			getTags: function ( options ) {
				options = options || {};
				return this.get( $.extend( {
					action: 'query',
					list: 'tags',
					tglimit: 'max',
					tgprop: [ 'name', 'displayname' ],
				}, options ) ).then( function ( data ) {
					var result = {};

					console.log( FILE, 'getTags data', data );
					if ( !data.batchcomplete ) {
						mw.log.warn( FILE, 'tags API query, batchcomplete not true');
					}

					for ( var tag of data.query.tags ) {
						result[ 'tag-' + tag.name ] = tag.displayname;
					}

					return result;
				} );
			},

			loadTags: function ( options ) {
				return this.getTags( options ).then( $.proxy( mw.messages, 'set' ) );
			},

			getUserContribs: function ( options ) {
				console.log( 'getUserContribs', options );
				options = options || { ucuser: conf.wgUserName };
				return this.get( $.extend( {
					action: 'query',
					list: 'usercontribs',
					ucprop: ucprop,
				}, options ) );
			}
		} );
	}

	/**
	 * extend mw.Message
	 */
	function extendMessage( Message ) {
		if ( Message.prototype.isDisabled ) {
			// Function exists, possibly added to MW
			return Message;
		}
		return $.extend( Message.prototype, {
			/**
			 * Check if a message is disabled
			 *
			 * @return {boolean}
			 */
			isDisabled: function () {
				var message = this.map.get( this.key );
				return  message === null ||
						message === false ||
						message === '' ||
						message === '-';
			}
		} );
	}

	/**
	 * extend mw.Title
	 */
	function extendTitle( Title ) {
		return $.extend( Title.exist, {
			batchLimit: 50,
			batchQueue: [],
			batchRunning: [],

			batchQuery: function ( title ) {
				var limit = this.batchLimit,
					queue = this.batchQueue,
					running = this.batchRunning;

				if ( queue.includes( title ) ||
					 running.includes( title ) ) {
					return;
				}

				queue.push( title );

				if ( queue.length >= limit ) {
					this.batchRun();
				}
			},

			batchRun: function () {
				var titles,
					limit = this.batchLimit,
					queue = this.batchQueue,
					running = this.batchRunning;

				if (queue.length) {
					titles = queue.splice(0, limit);
					running = running.concat( titles );
					this.query( titles, this.batchRunCallback.bind( this ) );
				}
			},

			batchRunCallback: function () {
				if ( this.batchQueue.length >= this.batchLimit ) {
					this.batchRun();
				}
			},

			hook: mw.hook( 'mediawiki.Title.exist' ),

			query: function ( titles, callback ) {
				return Api.get( {
					action: 'query',
					prop: 'info',
					titles: titles,
				} ).done( callback, this.queryCallback.bind( this ) );
			},

			queryCallback: function ( data ) {
				console.log('Title.exist.queryCallback', this, data);

				var hook = this.hook,
					pages = this.pages,
					queryPages = data.query.pages,
					running = this.batchRunning;

				for ( var i = 0, len = queryPages.length; i < len; i++ ) {
					var index,
						page = queryPages[i],
						title = page.title,
						state = !page.missing;

					//this.set( title, state );
					pages[ title ] = state;

					hook.fire( title );

					running.indexOf( title );
					if ( index >= 0 ) {
						running.splice( index, 1 );
					}
				}
			},
		} );
	}

	function titleExistsHandler( title ) {
		if ( mw.Title.exists( title ) === false ) {
			$content.find( 'a[title="' + title.replace( /_/g, ' ' ) + '"]' )
				.addClass( 'new' );
		}
	}

	/**
	 * Make user link (or user contributions for unregistered users)
	 * 
	 * mediawiki-1.28.2/includes/Linker.php
	 * 
	 * @param int $userId User id in database.
	 * @param string $userName User name in database.
	 * @param string $altUserName Text to display instead of the user name (optional)
	 * @return string HTML fragment
	 */
	function userLink( userId, userName, altUserName ) {
		var page,
			classes = 'mw-userlink';
		if ( userId === 0 ) {
			page = mw.Title.newFromText( contributionsTitle + '/' + userName );
			// PHP does a $altUserName = IP::prettifyIP( $userName );
			classes += ' mw-anonuserlink'; // Separate link class for anons (bug 43179)
		} else {
			page = mw.Title.makeTitle( NS_USER, userName );
			switch ( page.exists() ) {
				case null: mw.Title.exist.batchQuery( page.toString() ); break;
				case false: classes = 'new ' + classes;
			}
		}

		// Wrap the output with <bdi> tags for directionality isolation
		return Html.element( 'a', {
			href: page.getUrl(),
			class: classes,
			title: page.getPrefixedText()
		}, new Html.Raw(
			'<bdi>' +
			Html.escape( altUserName !== undefined ? altUserName : userName ) +
			'</bdi>'
		) );
	}

	function userToolLinks( userId, userText ) {
		var items, page;

		items = [];
		items.push( userTalkLink( userId, userText ) );
		if ( userId ) {
			page = mw.Title.newFromText( contributionsTitle + '/' + userText );
			items.push( Html.element( 'a', {
					href: page.getUrl(),
					class: 'mw-usertoollinks-contribs',
					title: page.getPrefixedText()
				}, mw.msg( 'contribslink' ) )
			);
		}

		return mw.msg( 'word-separator' )
			+ '<span class="mw-usertoollinks">'
			+ mw.message( 'parentheses',
					items.join( mw.msg( 'pipe-separator' ) )
				).text()
			+ '</span>';
	}

	/**
	 * @param int $userId User id in database.
	 * @param string $userText User name in database.
	 * @return string HTML fragment with user talk link
	 */
	function userTalkLink( userId, userText ) {
		var page = mw.Title.makeTitle( NS_USER_TALK, userText ),
			classes = 'mw-usertoollinks-talk';
		
		switch ( page.exists() ) {
			case null: mw.Title.exist.batchQuery( page.toString() ); break;
			case false: classes = 'new ' + classes;
		}
		
		return Html.element( 'a', {
				href: page.getUrl(),
				class: classes,
				title: page.getPrefixedText()
			}, mw.msg( 'talkpagelinktext' ) );
	}

	/**
	 * Format the character difference of the change.
	 * 
	 * Converted from mediawiki-1.28.2/includes/changes/ChangesList.php
	 * showCharacterDifference()
	 * formatCharacterDifference()
	 * 
	 * @param {Object} item A single change item from the usercontribs API
	 * @return string HTML fragment
	 */
	function formatCharacterDifference( item ) {
		var formattedSize, formattedSizeClass, formattedTotalSize, tag,
			szdiff = item.sizediff;

		formattedSize = mw.language.convertNumber( szdiff );
		formattedSize = mw.msg( 'rc-change-size', formattedSize );

		if ( Math.abs( szdiff ) > conf.wgRCChangedSizeThreshold ) {
			tag = 'strong';
		} else {
			tag = 'span';
		}

		if ( szdiff === 0 ) {
			formattedSizeClass = 'mw-plusminus-null';
		} else if ( szdiff > 0 ) {
			formattedSize = '+' + formattedSize;
			formattedSizeClass = 'mw-plusminus-pos';
		} else {
			formattedSizeClass = 'mw-plusminus-neg';
		}

		formattedTotalSize = mw.msg( 'rc-change-size-new', item.size );

		return Html.element( tag,
			{ class: formattedSizeClass, title: formattedTotalSize },
			mw.msg( 'parentheses', formattedSize ) );
	}

	/**
	 * mediawiki-1.28.2/includes/changetags/ChangeTags.php:formatSummaryRow()
	 */
	function formatTags( tags ) {
		var classes, description, displayTags, markers;

		if ( !tags.length ) {
			return [ '', [] ];
		}

		classes = [];
		displayTags = [];
		for ( var tag of tags ) {
			if ( !tag ) {
				continue;
			}
			description = tagDescription( tag );
			if ( description === false ) {
				continue;
			}
			displayTags.push( Html.element( 'span', {
				class: 'mw-tag-marker ' +
						escapeClass( 'mw-tag-marker-' + tag )
			}, new Html.Raw( description ) ) );
			classes.push( escapeClass( 'mw-tag-' + tag ) );
		}

		if ( !displayTags.length ) {
			return [ '', [] ];
		}

		// This should work, but it looks like mediawiki.jqueryMsg
		// is unable to cope with it
		// TODO: investigate parser settings
		/*markers = mw.message( 'tag-list-wrapper',
					displayTags.length,
					displayTags.join(', ')
				).parse();*/
		markers = mw.message( 'tag-list-wrapper', displayTags.length ).parse();
		markers = markers.replace( /\$2/g,
				displayTags.join( mw.msg( 'comma-separator' ) ) );
		markers = Html.element( 'span', { class: 'mw-tag-markers' },
			new Html.Raw( markers ) );

		return [ markers, classes ];
	}

	/**
	 * Get a short description for a tag.
	 *
	 * Checks if message key "mediawiki:tag-$tag" exists. If it does not,
	 * returns the HTML-escaped tag name. Uses the message if the message
	 * exists, provided it is not disabled. If the message is disabled,
	 * we consider the tag hidden, and return false.
	 *
	 * mediawiki-1.28.2/includes/changetags/ChangeTags.php
	 * 
	 * @param {string} tag Tag
	 * @return {string|boolean} Tag description or false if tag is to be hidden.
	 */
	function tagDescription( tag ) {
		var msg = mw.message( 'tag-' + tag );
		if ( !msg.exists() ) {
			// No such message, so return the HTML-escaped tag name.
			return Html.escape( tag );
		}
		if ( msg.isDisabled() ) {
			// The message exists but is disabled, hide the tag.
			return false;
		}

		// Message exists and isn't disabled, use it.
		return msg.text();
	}

	/**
	 * Generates each row in the contributions list.
	 *
	 * Contributions which are marked "top" are currently on top of the history.
	 * For these contributions, a [rollback] link is shown for users with roll-
	 * back privileges. The rollback link restores the most recent version that
	 * was not written by the target user.
	 *
	 * mediawiki-1.28.2/includes/specials/pagers/ContribsPager.php
	 * 
	 * @param {Object} row
	 * @return {jQuery} HTML list item
	 */
	function formatRow( row ) {
		var chardiff, classes, comment, d, del, diffHistLinks, difflink,
			flags, histlink, link, newClasses, $ret, tagSummary,
			templateParams, topmarktext, userlink;

		classes = [];

		link = Html.element( 'a', {
			href: mw.util.getUrl( row.title ),
			class: 'mw-contributions-title',
			title: row.title
		}, row.title );

		// Mark current revisions
		topmarktext = '';
		if (row.top) {
			topmarktext += '<span class="mw-uctop">' + mw.msg('uctop') + '</span>';
			classes.push( 'mw-contributions-current' );
			// TODO: Add rollback link
		}

		// TODO: Is there a visible previous revision?
		difflink = Html.element( 'a', {
			href: mw.util.getUrl( row.title, {
				diff: 'prev',
				oldid: row.revid
			} ),
			class: 'mw-changeslist-diff',
			title: row.title
		}, mw.msg( 'diff' ) );

		histlink = Html.element( 'a', {
			href: mw.util.getUrl( row.title, { action: 'history' } ),
			class: 'mw-changeslist-history',
			title: row.title
		}, mw.msg( 'hist' ) );

		chardiff = separator;
		chardiff += formatCharacterDifference( row );
		chardiff += separator;

		comment = row.parsedcomment;
		if (comment) {
			comment = '<span class="comment">' +
					mw.msg( 'parentheses', comment ) +
					'</span>';
		}

		d = Html.element( 'a', {
			href: mw.util.getUrl( row.title, { oldid: row.revid } ),
			class: 'mw-changeslist-date',
			title: row.title
		}, row.timestamp );

		// Show user names for /newbies as there may be different users.
		// Note that we already excluded rows with hidden user names.
		if ( param.contribs === 'newbie' || param.contribs === 'userprefix' ) {
			userlink = separator
				+ userLink( row.userid, row.user );
			/*userlink += ' ' + mw.message( 'parentheses',
					userTalkLink( row.userid, row.user )
				).text() + ' ';*/
			userlink += ' ' + userToolLinks( row.userid, row.user ) + ' ';
		} else {
			userlink = '';
		}

		flags = [];
		// TODO: new
		// TODO: minor

		del = ''; // TODO: getRevDeleteLink;

		diffHistLinks = mw.message( 'parentheses',
			difflink + mw.msg( 'pipe-separator' ) + histlink
		).text();

		[ tagSummary, newClasses ] = formatTags( row.tags );
		classes = classes.concat( newClasses );

		templateParams = {
			del: del,
			timestamp: d,
			diffHistLinks: diffHistLinks,
			charDifference: chardiff,
			flags: flags,
			articleLink: link,
			userlink: userlink,
			logText: comment,
			topmarktext: topmarktext,
			tagSummary: tagSummary,
		};

		$ret = mw.template.get( 'mediawiki.special', templateName )
				.render( templateParams );

		if ( classes === [] && ret === '' ) {
			mw.log( 'Dropping Special:Contribution row that could not be formatted' );
			return '<!-- Could not format Special:Contribution row. -->\n';
		}

		// mediawiki.template.mustache renders to a jQuery object
		// Just going with that for now as it creates the DOM nodes we need
		//return Html.element( 'li', { class: classes }, new Html.Raw( ret ) );
		return $( '<li>' ).addClass( classes.join(' ') ).append( $ret );
	}

	function usercontribsCallback( data ) {
		console.log( FILE + ':usercontribsCallback():', data );

		var rev, oresDamaging, oresGoodfaith, oresLevel, key, li, ref,
			usersCount,
			oresDamagingThresholds,
			usercontribs = data.query.usercontribs,
			list = $list[0],
			listNodes = list.childNodes; // Live NodeList

		/* Although the auto-incrementing revid seems like a perfectly
		 * reasonable sort key, it turns out that it has been reset in some
		 * databases, so does not always represent a chronology.
		 * German Wikipedia is one example of this, for unknown reasons.
		 *
		 * mediawiki-1.28.2/includes/specials/pagers/ContribsPager.php
		 * getIndexField() uses 'rev_timestamp' 
		 */
		/*function compareNodes( key, node ) {
			var revid = Number( node.dataset.revid ),
				previousSibling = node.previousSibling,
				left = previousSibling ?
					Number( previousSibling.dataset.revid ) : Infinity;

			return compare( revid, key ) - compare( key, left );
		}*/
		function compareNodes( key, node ) {
			var timestamp = new Date( node.dataset.timestamp ),
				previousSibling = node.previousSibling,
				left = previousSibling ?
					new Date( previousSibling.dataset.timestamp ) : Infinity;

			return compare( timestamp, key ) - compare( key, left );
		}
		
		if ( !data.batchcomplete ) {
			mw.log.warn( FILE, 'usercontribs API query, batchcomplete not true');
		}

		if ( ores.highlight ) {
			oresDamagingThresholds = ores.thresholds.damaging;
		}

		for ( var i = 0, len = usercontribs.length; i < len; i++ ) {
			rev = usercontribs[i];

			li = formatRow( rev )[0];
			li.dataset.revid = rev.revid;
			li.dataset.timestamp = rev.timestamp;
			li.dataset.sizediff = rev.sizediff;
			li.dataset.title = rev.title;
			li.dataset.user = rev.user;

			if ( ores.highlight && rev.oresscores ) {
				oresDamaging  = rev.oresscores.damaging.true;
				oresGoodfaith = rev.oresscores.goodfaith.true;
				li.dataset.oresDamaging  = oresDamaging;
				li.dataset.oresGoodfaith = oresGoodfaith;

				// Intentionally fall off the end of the array here,
				// to get undefined where the score is below the lowest level
				for ( var j = 0, jLen = ores.levels.length; j <= jLen; j++ ) {
					oresLevel = ores.levels[j];
					if ( oresDamaging > oresDamagingThresholds[ oresLevel ] ) {
						break;
					}
				}

				if ( oresLevel ) {
					li.classList.add(
						'ores-highlight', 'ores-damaging-' + oresLevel
					);
				}
			}

			//key = Number( li.dataset.revid );
			key = new Date( li.dataset.timestamp );
			ref = bsearch( key, listNodes, compareNodes );

			list.insertBefore( li, ref );
			
			if ( users[ rev.user ] ) {
				users[ rev.user ].count++;
			} else {
				users[ rev.user ] = { id: rev.userid, count: 1 };
			}
		}
		console.log( FILE, i + ' revisions loaded' );

		$usersList.empty();
		usersCount = 0;
		for ( var user in users ) {
			var rec = users[ user ];
			$usersList.append(
				$( '<li>' ).html(
					userLink( rec.id, user ) +
					userToolLinks( rec.id, user ) +
					rec.count
				)
			);
			usersCount++;
		}
		$usersDt.text( usersCount + ' users found' );

		mw.Title.exist.batchRun();

		$spinner.remove();
		if ( data.continue ) {
			usercontribsContinue = data.continue;
			$inputContinue.prop( 'disabled', false );
		} else {
			$inputContinue.prop( 'disabled', true );
		}
	}

	function continueHandler( event ) {
		console.log( FILE + ':continueHandler():', event );
		$inputContinue.prop( 'disabled', true );
		$spinner = $.createSpinner( { size: 'small', type: 'inline' } );
		$inputContinue.after( $spinner );
		Api.getUserContribs( $.extend( ucParam, usercontribsContinue ) )
			.done( usercontribsCallback );
	}

	function submitHandler( event ) {
		console.log( FILE + ':submitHandler():', event );

		var ucshow, namespaces, ns, options;

		param = {};
		$.each( [ 'contribs', 'limit', 'prefix' ], function ( i, name ) {
			param[ name ] = form.elements[ name ].value;
		} );
		if ( param.contribs === 'userprefix' ) {
			event.preventDefault();
			if ( !param.prefix ) {
				// TODO: communicate with the user
				return;
			}
			$.each( [
				'limit',
				'namespace',
				'tagfilter'
			], function ( i, name ) {
				param[ name ] = form.elements[ name ].value;
			} );
			$.each( [
				'nsInvert', 'associated',
				'topOnly', 'newOnly', 'hideMinor', 'hidenondamaging'
			], function ( i , name ) {
				if ( form.elements[ name ].checked ) {
					param[ name ] = form.elements[ name ].value;
				}
			} );

			ucParam = {};
			ucParam.uclimit = param.limit;
			ucParam.ucuserprefix = param.prefix;

			if ( param.namespace ) {
				if ( param.associated ) {
					ns = Number( param.namespace );
					if ( ns % 2 ) {
						ucParam.ucnamespace = [ String( ns - 1 ), String( ns ) ];
					} else {
						ucParam.ucnamespace = [ String( ns ), String( ns + 1 ) ];
					}
				} else {
					ucParam.ucnamespace = [ param.namespace ];
				}
				if ( param.nsInvert ) {
					namespaces = [];
					options = form.elements.namespace.options;
					for ( var i = 0, len = options.length; i < len; i++ ) {
						ns = options[i].value;
						if ( ns && !ucParam.ucnamespace.includes( ns ) ) {
							namespaces.push( ns );
						}
					}
					ucParam.ucnamespace = namespaces;
				}
			} // TODO: associated & inverted

			ucshow = [];
			if ( param.topOnly ) {
				ucshow.push( 'top' );
			}
			if ( param.newOnly ) {
				ucshow.push( 'new' );
			}
			if ( param.hideMinor ) {
				ucshow.push( '!minor' );
			}
			if ( param.hidenondamaging ) {
				ucshow.push( 'oresreview' );
			}
			if ( ucshow.length ) {
				ucParam.ucshow = ucshow;
			}

			if ( param.tagfilter ) {
				ucParam.uctag = param.tagfilter;
			}

			$usersDl = $usersDt = $usersDd = $usersList = null;
			$content.children().remove( 'form ~ *' );

			$spinner = $.createSpinner( { size: 'large', type: 'block' } );
			$content.append( $spinner );
			
			users = {};
			$usersDl = $( '<dl>' ).append( [
				$usersDt = $( '<dt>' ),
				$usersDd = $( '<dd>' ).append(
					$usersList = $( '<ul>' )
				).makeCollapsible( {
					collapsed: true,
					$customTogglers: $usersDt
				} )
			] );
			$content.append( $usersDl );

			$list = $( '<ul>' ).attr( 'class', 'mw-contributions-list' );
			$content.append( $list );

			mw.Title.exist.hook.add( titleExistsHandler );

			$.when(
				Api.getUserContribs( ucParam ),
				oresPromise,
				specialPageAliasesPromise,
				tagsPromise
			).done( function ( data ) {
				// $.when( … ).done() produces a variation over a simple .done()
				usercontribsCallback.apply( this, data );
			} );
		}
	}

	function loaderCallback() {
		console.log( FILE, 'loader done' );

		extendApi( mw.Api );
		extendMessage( mw.Message );
		extendTitle( mw.Title );

		Api = new mw.Api( {
			parameters: {
				formatversion: 2
			}
		} );

		cachingApi = new mw.Api( {
			parameters: {
				formatversion: 2,
				smaxage: 86400,
				maxage: 86400
			}
		} );

		return $.when(
			/* Messages are needed early, but other API queries are
			 * not needed until after submit.
			 */
			cachingApi.loadMessagesIfMissing( [
				'boteditletter',
				'collapsible-collapse',
				'collapsible-expand',
				'comma-separator',
				'contribslink',
				'contributions-title',
				'diff',
				'hist',
				'log',
				'minoreditletter',
				'newpageletter',
				'ores-damaging-letter',
				'ores-damaging-title',
				'pagetitle',
				'parentheses',
				'pipe-separator',
				'rc-change-size',
				'rc-change-size-new',
				'recentchanges-label-bot',
				'recentchanges-label-minor',
				'recentchanges-label-newpage',
				'sp-contributions-username',
				//'sp-contributions-username-prefix', // Not yet available
				'tag-list-wrapper',
				'talkpagelinktext',
				'uctop',
				'viewpagelogs',
				'word-separator',
			] ),
			$.ready
		);
	}

	function initPage() {
		var $fieldset;

		console.info( FILE, 'ready' );

		if ( !mw.messages.exists( 'sp-contributions-username-prefix' ) ) {
			mw.messages.set( 'sp-contributions-username-prefix',
				mw.messages.get( 'sp-contributions-username' )
					.replace( /:?\s*$/, ' prefix$&' )
			);
		}

		$content = $( '#mw-content-text' );

		// Extend form as early as possible for better user experience.
		$form = $content.find( 'form.mw-contributions-form' );
		form = $form[0];
		$fieldset = $form.find( 'fieldset.mw-contributions-table' );

		// MW adds a hidden limit field, remove it to use our selector
		$form.find( 'input[name="limit"]' ).remove();
		$fieldset.find( 'input[name="target"]' ).after( [
			'<br>',
			Html.element( 'input', {
				name: 'contribs',
				type: 'radio',
				value: 'userprefix',
				id: 'userprefix',
				class: 'mw-input'
			} ),
			'&#160;',
			Html.element( 'label', {
				for: 'userprefix',
				class: 'mw-input'
			}, mw.msg( 'sp-contributions-username-prefix' ) ),
			' ',
			Html.element( 'input', {
				size: '40',
				class: 'mw-input mw-ui-input-inline mw-autocomplete-user',
				name: 'prefix'
			} ),
			' '
		] );
		$fieldset.find( '> div:last' ).before( 
			Html.element( 'div', {}, new Html.Raw(
				Html.element( 'label', { for: 'limit' }, 'Limit:') +
				'&#160;' +
				Html.element( 'select', { id: 'limit', name: 'limit' }, new Html.Raw(
					mLimitsShown.reduce( function ( acc, val ) {
						return acc + Html.element( 'option', {}, val );
					}, '')
				) )
			) )
		).append(
			$inputContinue = $( '<input>' ).attr( {
				class: 'mw-button',
				type: 'button',
				value: 'Continue'
			} ).prop( 'disabled', true )
		);
		$form.on( 'submit', submitHandler );
		$inputContinue.on( 'click', continueHandler );

		console.log( FILE, 'form extended' );
	}

	function pageReady() {
		$.each( [ 'contribs', 'limit', 'prefix' ], function ( i, name ) {
			var value = mw.util.getParamValue ( name );
			param[name] = value;
			if ( value ) {
				form.elements[ name ].value = value;
			}
		} );
		if ( !param.limit ) {
			param.limit = mw.user.options.get( 'rclimit' );
			form.elements.limit.value = param.limit;
		}

		if ( ores.highlight ) {
			ucprop.push( 'oresscores' );
			ores.style = mw.util.addCSS(
				'.ores-damaging-verylikelybad { background-color: #fc3; } ' +
				'.ores-damaging-likelybad { background-color: #ffe79e; } ' +
				'.ores-damaging-maybebad { background-color: #fef6e7; }'
			);
			
			ores.levels.splice( ores.levels.indexOf(
					ores.damagingPrefMap[ ores.damagingPref ]
				) + 1);
		}
		console.log( 'ORES', ores );

		oresPromise = cachingApi.loadOresIfMissing();
		specialPageAliasesPromise = cachingApi.loadSpecialPageAliases()
			.done( function ( specialPageAliases ) {
				var aliases = specialPageAliases.Contributions,
					specialNamespace = conf.wgFormattedNamespaces[
						conf.wgNamespaceIds.special
					];
				if ( aliases && aliases.length ) {
					contributionsTitle = specialNamespace + ':' + aliases[0];
				}
			}
		);
		tagsPromise = cachingApi.loadTags();

		mw.template.add( 'mediawiki.special', templateName, templateBody );

		if ( param.contribs === 'userprefix' && param.prefix ) {
			$form.submit();
		}
		window.ucparam = param;

		console.log( FILE, 'startup complete' );
	}

	if ( mw.config.get( 'wgCanonicalSpecialPageName' ) === 'Contributions' ) {
		mw.loader.using( [
			'mediawiki.Title',
			'mediawiki.api',
			'mediawiki.api.messages',
			'mediawiki.jqueryMsg',
			'mediawiki.language',
			'mediawiki.template',
			'mediawiki.template.mustache',
			'mediawiki.user',
			'mediawiki.util',
			'jquery.makeCollapsible',
			'jquery.spinner'
		] ).then( loaderCallback ).then( initPage ).done( pageReady );
	}
} )( mediaWiki, jQuery );