User:Murph9000/pagetriagestats-topreviewers.js

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.
/**
 * Page triage stats, top reviewers
 * from API action=pagetriagestats
 * 
 * Load from your common.js, for example:
 * 
 * if ( mw.config.get( 'wgCanonicalSpecialPageName' ) === 'Blankpage' ) {
 *	mw.loader.load( '/w/index.php?title=User:Murph9000/pagetriagestats-topreviewers.js&action=raw&ctype=text/javascript' );
 * }
 * 
 * Once added to your common.js, view the dynamically generated special page at:
 * 
 * https://en.wikipedia.org/wiki/Special:BlankPage?action=pagetriagestats&topreviewers=last-day
 * https://en.wikipedia.org/wiki/Special:BlankPage?action=pagetriagestats&topreviewers=last-week
 * https://en.wikipedia.org/wiki/Special:BlankPage?action=pagetriagestats&topreviewers=last-month
 * 
 * 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.
 */

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

	var $content, $spinner, topreviewersParam,
		Api,
		Html = mw.html,
		conf = mw.config.get( [
			'wgAction',
			'wgCanonicalSpecialPageName',
			'wgNamespaceIds',
			'wgPageName',
		] ),
		messages = {
			'pagetriagestats-filteredarticle': 'Filtered articles',
			'pagetriagestats-filteredarticle-count': 'Count: $1', 
			'pagetriagestats-reviewedarticle': 'Reviewed articles',
			'pagetriagestats-reviewedarticle-count': 'Count: $1',
			'pagetriagestats-title': 'Page triage stats',
			'pagetriagestats-topreviewers': 'Top reviewers',
			'pagetriagestats-topreviewers-caption': 'Top reviewers, $1',
			'pagetriagestats-topreviewers-last-day': 'last day',
			'pagetriagestats-topreviewers-last-month': 'last month',
			'pagetriagestats-topreviewers-last-week': 'last week',
			'pagetriagestats-topreviewers-num': 'Number',
			'pagetriagestats-topreviewers-total': 'Total',
			'pagetriagestats-topreviewers-user': 'User',
			'pagetriagestats-unreviewedarticle': 'Unreviewed articles',
			'pagetriagestats-unreviewedarticle-count': 'Count: $1',
			'pagetriagestats-unreviewedarticle-oldest': 'Oldest: $1',
		},
		topreviewersValues = [ 'last-day', 'last-week', 'last-month' ],

		NS_USER = conf.wgNamespaceIds.user,
		NS_USER_TALK = conf.wgNamespaceIds.user_talk,

		contributionsTitle = 'Special:Contributions';

	/**
	 * 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 );
		}

		// 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';
		
		return Html.element( 'a', {
				href: page.getUrl(),
				class: classes,
				title: page.getPrefixedText()
			}, mw.msg( 'talkpagelinktext' ) );
	}

	function pagetriagestatsCallback( data ) {
		console.log( FILE, 'pagetriagestatsCallback', data );
		var stats, topreviewers, rec, total, $div, $table, $tbody;

		if ( data.pagetriagestats.result !== 'success' ) {
			mw.log.error( FILE, 'API action=pagetriagestats did not return success' );
			$content.append(
				'<p><big style="color:red">API did not return success</big></p>'
			);
			return;
		}
		
		$div = $( '<div>' ).addClass( 'pagetriagestats' );
		$content.append( $div );

		stats = data.pagetriagestats.stats;

		$div.append(
			Html.element( 'dl', { class: 'stats' }, new Html.Raw(

				Html.element( 'dt', { class: 'unreviewedarticle' },
					mw.msg( 'pagetriagestats-unreviewedarticle' ) ) +
				Html.element( 'dd', { class: 'unreviewedarticle-count' },
					mw.msg( 'pagetriagestats-unreviewedarticle-count',
						stats.unreviewedarticle.count ) ) +
				Html.element( 'dd', { class: 'unreviewedarticle-oldest' },
					mw.msg( 'pagetriagestats-unreviewedarticle-oldest',
						new Date( stats.unreviewedarticle.oldest.replace(
							/^(\d{4})(\d{2})(\d{2})(\d{2})(\d{2})(\d{2})$/,
							'$1-$2-$3T$4:$5:$6Z'
						) )
					)
				) +

				Html.element( 'dt', { class: 'reviewedarticle' },
					mw.msg( 'pagetriagestats-reviewedarticle' ) ) +
				Html.element( 'dd', { class: 'reviewedarticle-count' },
					mw.msg( 'pagetriagestats-reviewedarticle-count',
						stats.reviewedarticle.reviewed_count ) ) +

				Html.element( 'dt', { class: 'filteredarticle' },
					mw.msg( 'pagetriagestats-filteredarticle' ) ) +
				Html.element( 'dd', { class: 'filteredarticle-count' },
					mw.msg( 'pagetriagestats-filteredarticle-count',
						stats.filteredarticle ) )
			) )
		);

		topreviewers = stats.topreviewers;

		$div.append(
			Html.element( 'h2', {}, mw.msg( 'pagetriagestats-topreviewers' ) )
		);

		$table = $( '<table>' ).addClass( 'topreviewers wikitable' );
		$table.append( Html.element( 'caption', {},
			mw.msg( 'pagetriagestats-topreviewers-caption',
				mw.msg( 'pagetriagestats-topreviewers-' + topreviewersParam ) )
		) );
		
		$table.append( Html.element( 'thead', {}, new Html.Raw(
			Html.element( 'tr', {}, new Html.Raw(
				Html.element( 'th',
					{ scope: 'col' },
					'#' ) +
				Html.element( 'th',
					{ scope: 'col', style: 'text-align: left;' },
					mw.msg( 'pagetriagestats-topreviewers-user' ) ) +
				Html.element( 'th',
					{ scope: 'col', style: 'text-align: left;' },
					mw.msg( 'pagetriagestats-topreviewers-num' ) )
			) )
		) ) );

		$tbody = $( '<tbody>' );
		total = 0;
		for ( var i in topreviewers ) {
			rec = topreviewers[ i ];
			$tbody.append( Html.element( 'tr', {}, new Html.Raw(
				Html.element( 'th', { scope: 'row' }, i ) +
				Html.element( 'td', {}, new Html.Raw(
					userLink( rec.user_id, rec.user_name ) +
					userToolLinks( rec.user_id, rec.user_name )
				) ) +
				Html.element( 'td', {}, rec.num )
			) ) );
			total += Number(rec.num);
		}
		$table.append( $tbody );

		$table.append( Html.element( 'tfoot', {}, new Html.Raw(
			Html.element( 'tr', {}, new Html.Raw(
				//Html.element( 'th' ) +
				Html.element( 'th',
					{ scope: 'row', style: 'text-align: left;', colspan: 2 },
					mw.msg( 'pagetriagestats-topreviewers-total' ) ) +
				Html.element( 'th',
					{ scope: 'col', style: 'text-align: left;' },
					total )
			) )
		) ) );

		$div.append( $table );
	}

	function initPage() {
		console.log( FILE, 'ready' );

		var title = mw.msg( 'pagetriagestats-title' );

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

		if ( conf.wgCanonicalSpecialPageName === 'Blankpage' ) {
			document.title = mw.msg( 'pagetitle', title );

			// Normal skins use #firstHeading
			// Mobile skin uses #section_0 for no good reason
			$( '#firstHeading, #section_0' ).text( title );
			$content.empty();
		} else {
			$content.append( Html.element( 'h1', {}, title ) );
		}

		$spinner = $.createSpinner( { size: 'large', type: 'block' } );
		$content.append( $spinner );
	}

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

		var maxage;

		if ( conf.wgCanonicalSpecialPageName === 'Blankpage' &&
			 mw.util.getParamValue( 'action' ) !== 'pagetriagestats' ) {
			console.info( FILE + ': rejecting, not a request for this script' );
			return $.Deferred().reject();
		}

		mw.messages.set( messages );

		topreviewersParam = mw.util.getParamValue( 'topreviewers' );
		if ( !topreviewersParam ||
			 !topreviewersValues.includes( topreviewersParam ) ) {
			topreviewersParam = 'last-week';
		}

		/**
		 * https://phabricator.wikimedia.org/diffusion/EPTR/browse/master/includes/PageTriageUtil.php
		 * 
		 * Data has server-side caching, with expiry as follows:
		 *	last-day: 60 * 60
		 *	last-week: 24 * 60 * 60
		 *	last-month: 24 * 60 * 60
		 * 
		 * Set our maxage based on that (but a small fraction of it, to avoid
		 * doubling the delay).
		 */
		switch ( topreviewersParam ) {
			case 'last-day':
				maxage = 5 * 60;
				break;
			//case 'last-week':
			//case 'last-month':
			default:
				maxage = 60 * 60;
		}

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

		return $.when(
			Api.get( {
				action: 'pagetriagestats',
				topreviewers: topreviewersParam,
				smaxage: maxage,
				maxage: maxage
			} ),
			$.when(
				Api.loadMessagesIfMissing( [
					'contribslink',
					'pagetitle',
					'parentheses',
					'pipe-separator',
					'talkpagelinktext',
					'word-separator',
				], {
					smaxage: 86400,
					maxage: 86400
				} ),
				$.ready
			).then( initPage )
		);
	}

	if ( conf.wgCanonicalSpecialPageName === 'Blankpage' ||
		 ( conf.wgPageName === FILE && conf.wgAction === 'submit' ) ) {
		mw.loader.using( [
			'mediawiki.Title',
			'mediawiki.api',
			'mediawiki.api.messages',
			'mediawiki.jqueryMsg',
			'mediawiki.util',
			'jquery.spinner'
		] ).then( loaderCallback ).done( function ( data ) {
			pagetriagestatsCallback.apply( this, data );
			mw.hook( 'wikipage.content' ).fire( $content );
		} ).always( function () {
			if ( $spinner ) {
				$spinner.remove();
			}
		} );
	}
} )( mediaWiki, jQuery );