User:Mr. Stradivarius/gadgets/SignpostTagger-test.js

// /* * SignpostTagger * * This gadget adds an window for editing tags for articles of The Signpost. * The tags are stored in Lua data modules and used to generate lists of * Signpost articles on the fly. Updating the modules manually would be * tedious, so this gadget simplifies the process. * * To install it, add the following to your personal .js page: importScript( 'User:Mr. Stradivarius/gadgets/SignpostTagger.js' ); // Linkback: User:Mr. Stradivarius/gadgets/SignpostTagger.js

* Author: Mr. Stradivarius * Licence: Public domain */

mw.loader.using( [	'mediawiki.api',	'mediawiki.jqueryMsg',	'mediawiki.Title',	'mediawiki.util',	'oojs-ui' ], function {

"use strict";

/****************************************************************************** *                          Default tags * * The following object defines the tags that are loaded for an article when * that article does not yet have any article data. The properties (on the left * side) are the subpage names of the articles, and the values (on the right * side) are arrays of tag names. * * When SignpostTagger is run on an article page, it checks the Signpost Lua * module for the year of the article to see if the article already has article * data. If it doesn't have any article data, then it compares the subpage name * of the article with the subpage names in the defaultTags object. If there is * a match, then it loads the corresponding tags. * * Tags should be all lower case, with no punctuation or spaces. If a tag can * have multiple possible names, then you should use the canonical name here, * and you should add the other names as aliases in Module:Signpost/aliases. ******************************************************************************/

var defaultTags = { 'Arbitration report': [ 'arbitrationreport' ], 'Blog': [ 'blogs' ], 'Community view': [ 'communityview' ], 'Discussion report': [ 'discussionreport' ], 'Education report': [ 'educationreport' ], 'Essay': [ 'essay' ], 'Featured content': [ 'featuredcontent' ], 'Features': [ 'featuredcontent' ], 'Features and admins': [ 'featuredcontent', 'featuresandadmins', 'newadmins' ], 'Forum': [ 'forum' ], 'From the archives': [ 'fromthearchives' ], 'From the editor': [ 'fromtheeditor' ], 'From the editors': [ 'fromtheeditor' ], 'Gallery': [ 'gallery' ], 'Humour': [ 'humour' ], 'News and notes': [ 'newsandnotes' ], 'News from the WMF': [ 'newsfromthewmf' ], 'In focus': [ 'infocus' ], 'In review': [ 'inreview' ], 'In the media': [ 'inthemedia' ], 'In the news': [ 'inthemedia' ], 'Interview': [ 'interviews' ], 'Op-ed': [ 'opinion' ], 'Op-Ed': [ 'opinion' ], 'Opinion': [ 'opinion' ], 'Recent research': [ 'recentresearch', 'research' ], 'Special report': [ 'specialreport' ], 'Technology report': [ 'tech', 'techreport' ], 'Traffic report': [ 'statistics', 'traffic', 'trafficreport' ], 'WikiProject report': [ 'wikiprojectreport', 'wikiprojects' ], };

/****************************************************************************** *                          MW config ******************************************************************************/

var config = mw.config.get( [	'skin',  	'wgAction',	'wgArticleId',	'wgNamespaceNumber',	'wgTitle' ] );

// Quick exit for pages we definitely won't be working on. if ( config.wgNamespaceNumber !== 4 || config.wgAction !== 'view' ) { return; }

/****************************************************************************** *                          currentPage object ******************************************************************************/

/** * Global object representing the current page. */ var currentPage = { text: config.wgTitle, namespace: config.wgNamespaceNumber, action: config.wgAction, exists: config.wgArticleId !== 0, skin: config.skin,

/**	 * @private */	_prefixedText: null, _year: null, _date: null, _subpage: null, _parsed: false, _isSignpostArticle: null };

/** * Parse the page title and cache the results. * * @private */ currentPage._parseTitle = function { var regex = /^Wikipedia Signpost\/((\d{4})-\d{2}-\d{2})\/([^\/]+)$/; var match = regex.exec( this.text ); if ( match ) { this._isSignpostArticle = true; this._date = match[ 1 ]; this._year = Number( match[ 2 ] ); this._subpage = match[ 3 ]; } else { this._isSignpostArticle = false; }	this._parsed = true; };

/** * Get the prefixed text. * * @return {string} */ currentPage.getPrefixedText = function { if ( !this._prefixedText ) { this._prefixedText = mw.Title.newFromText(			this.text,			this.namespace		).getPrefixedText; }	return this._prefixedText; };

/** * Get the year. * * @return {number} */ currentPage.getYear = function { if ( !this._parsed ) { this._parseTitle; }	return this._year; };

/** * Get the date. * * @return {string} */ currentPage.getDate = function { if ( !this._parsed ) { this._parseTitle; }	return this._date; };

/** * Get the subpage name. * * @return {string} */ currentPage.getSubpage = function { if ( !this._parsed ) { this._parseTitle; }	return this._subpage; };

/** * Get the list of authors. * * @return {Array} */ currentPage.getAuthors = function { return $( '#signpost-article-authors a' ).map( function {		var match = this.pathname.match( /^\/wiki\/User:(.*)/ );		if ( match ) {			return decodeURI( match[ 1 ] ).replace( /_/g, ' ' );		} else {			return "";		}	} ).get.filter( function ( username ) { return username; } ); };

/** * Whether this page is a Signpost article. * * @return {boolean} */ currentPage.isSignpostArticle = function { if ( !this._parsed ) { this._parseTitle; }	return this.namespace === 4 && this._isSignpostArticle; };

/** * Whether this page is a redirect. * * @return {boolean} */ currentPage.isRedirect = function { return /\bredirect=no\b/.test( window.location.search ); };

/** * Whether this page needs tagging. * * @return {boolean} */ currentPage.needsTagging = function { return this.isSignpostArticle && this.action === 'view' && this.exists && !this.isRedirect; };

/** * Find the article title by scraping the HTML of the current page. * * @return {string} */ currentPage.getArticleTitle = function { var $h2, $span, title;

// Try to get the title from the data-signpost-article-title attribute of // the element with ID "signpost-article-title". This is added by // Wikipedia Signpost/Templates/Signpost-article-header-v2. $h2 = $( '#signpost-article-title' ); if ( $h2.length ) { title = $h2.attr( 'data-signpost-article-title' ); if ( title && title.length > 0 ) { return title; }	} else { // We couldn't find the title header, so just use the first header. $h2 = $( '#bodyContent h2:first' ); }	if ( !$h2.length ) { return ''; }

// Try to get the span containing the title text. This avoids any // "subscribe" or "edit section" links, etc.	$span = $h2.find( 'span.mw-headline' ); if ( $span.length ) { return $span.text; } else { return $h2.text; } };

/** * Create a new window manager with one dialog and append it to the DOM. * * @param {Object} [dialog] A OOjs-ui window object * @return {Object} The window manager */ currentPage.initializeWindowManager = function ( dialog ) { var windowManager = new OO.ui.WindowManager; $( 'body' ).append( windowManager.$element ); windowManager.addWindows( [ dialog ] ); return windowManager; };

/** * Add a Signpost portlet link that initializes a dialog. * * @param {Object} [dialog] A OOjs-ui window object */ currentPage.addSignpostPortlet = function ( dialog ) { var windowManager = this.initializeWindowManager( dialog ); var location = this.skin === 'vector' ? 'p-views' : 'p-cactions'; var portletLink = mw.util.addPortletLink(		location,		'#',		'Manage tags',		'ca-signpost-tagger',		'Manage Signpost tags',		'g',		'#ca-watch'	); $( portletLink ).click( function ( e ) {		e.preventDefault;		windowManager.openWindow( dialog );	}); };

/****************************************************************************** *                          LuaTitle class ******************************************************************************/

/** * Title in the Module namespace that houses Signpost index data. * * @class * * @constructor * @param {Object} [options] Configuration options */ var LuaTitle = function ( options ) { this.prefixedText = 'Module:' + options.title; this.title = new mw.Title( this.prefixedText ); this.api = new mw.Api; this.content = null; };

OO.initClass( LuaTitle );

LuaTitle.static.signpostModule = 'Signpost';

LuaTitle.static.luaRestrictedTokens = { 'and': true, 'break': true, 'do': true, 'else': true, 'elseif': true, 'end': true, 'false': true, 'for': true, 'function': true, 'if': true, 'in': true, 'local': true, 'nil': true, 'not': true, 'or': true, 'repeat': true, 'return': true, 'then': true, 'true': true, 'until': true, 'while': true };

LuaTitle.static.makeLuaString = function ( s ) { return '"' + s.replace( /(["\\])/g, '\\$1' ) + '"'; };

LuaTitle.prototype.getTitle = function { return this.title; };

/* Load the Lua module and return its contents as a JavaScript object. * * @return {promise} */ LuaTitle.prototype.load = function { var luaTitle = this; var makeLuaString = LuaTitle.static.makeLuaString;

return this.api.postWithToken( 'csrf', {		action: 'scribunto-console',		format: 'json',		title: luaTitle.constructor.static.signpostModule,		question: "local success, ret = pcall( require, " + makeLuaString( this.prefixedText ) + " )\n" +			"if success then\n" +			"   print( mw.text.jsonEncode( { hasError = false, error = '', result = ret } ) )\n" +			"else\n" +			"    print( mw.text.jsonEncode( { hasError = true, error = ret, result = nil } ) )\n" +			"end"	} ).then( function ( obj ) {		return $.Deferred( function ( deferred ) { if ( obj.type === 'normal' ) { // Lua command succeeded try { var response = JSON.parse( obj.print ); } catch ( e ) { // There was a problem parsing the JSON data from Lua return deferred.reject(						'luajsonparseerror',						{ error: {							code: 'luajsonparseerror',							info: e.message						} },						{ recoverable: false, title: luaTitle.getTitle }					); }				if ( !response.hasError ) { // We got the content successfully return deferred.resolve( response.result ); } else if ( response.error.search( /module '.*' not found/ ) ) { // The module does not exist return deferred.reject(						'luamodulenotfound',						{ error: {							code: 'luamodulenotfound',							info: response.error						} },						{ recoverable: true, title: luaTitle.getTitle }					); } else { // The Lua require call failed for some other reason return deferred.reject(						'luarequireerror',						{ error: {							code: 'luarequireerror',							info: response.error						} },						{ recoverable: false, title: luaTitle.getTitle }					); }			} else if ( obj.type === 'error' ) { // Lua command failed but API call succeeded return deferred.reject(					'luacommandfailed',					{ error: {						code: 'luacommandfailed',						info: obj.message					} },					{ recoverable: false, title: luaTitle.getTitle }				); } else if ( obj.error ) { // API call failed return deferred.reject( obj.error.code, obj ); } else { return deferred.reject(					'unknownapiresponse',					{ error: {						code: 'unknownapiresponse',						info: 'Unknown API response'					} }				); }		} ).promise;	} ); };

/** * Turn a javascript value into the equivalent Lua code, and set it in the * object. Values can be nested, and can be strings, numbers, booleans, arrays, * and objects. Arrays and objects cannot contain self-references; doing so will * result in an infinite loop. * * @param {Object} data */ LuaTitle.prototype.setContent = function ( options ) { options = options || {}; var makeLuaString = LuaTitle.static.makeLuaString;

function makeLuaTableKey( s ) { if (			s.match( /^[_a-zA-Z][_a-zA-Z0-9]*$/ ) && // Basic Lua name requirements			!s.match( /^_[A-Z]+$/ ) && // Reserved for internal Lua use			!LuaTitle.static.luaRestrictedTokens[ s ]		) { return s;		} else { return '[' + makeLuaString( s ) + ']'; }	}

function isOneLine( val, indent ) { for ( var i = 0, len = val.length; i < len; i++ ) { if ( typeof val[ i ] === 'object' ) { return false; }		}		return indent >= 2; }

function repeatPush( arr, s, n ) { for ( var i = 0; i < n; i++ ) { arr.push( s ); }	}

function pushLuaArray( val, ret, indent ) { var i, len; var oneLine = isOneLine( val, indent ); var nextIndent = indent + 1; ret.push( '{' ); for ( i = 0, len = val.length; i < len; i++ ) { if ( oneLine ) { pushLuaCode( val[ i ], ret, nextIndent ); if ( i < len - 1 ) { ret.push( ', ' ); }			} else { ret.push( '\n' ); repeatPush( ret, '\t', nextIndent ); pushLuaCode( val[ i ], ret, nextIndent ); ret.push( ',' ); }		}		if ( !oneLine ) { ret.push( '\n' ); repeatPush( ret, '\t', indent ); }		ret.push( '}' ); }

function pushLuaTable( val, ret, indent ) { var i, p, len; var oneLine = isOneLine( val, indent ); var nextIndent = indent + 1; var props = []; ret.push( '{' ); for ( p in val ) { if ( val.hasOwnProperty( p ) ) { props.push( p ); }		}		props.sort( options.sortFunc ); for ( i = 0, len = props.length; i < len; i++ ) { p = props[ i ]; if ( !oneLine ) { ret.push( '\n' ); repeatPush( ret, '\t', nextIndent ); }			ret.push( makeLuaTableKey( p ) ); ret.push( ' = ' ); pushLuaCode( val[ p ], ret, nextIndent ); if ( !oneLine ) { ret.push( ',' ); } else if ( i < len - 1 ) { ret.push( ', ' ); }		}		if ( !oneLine ) { ret.push( '\n' ); repeatPush( ret, '\t', indent ); }		ret.push( '}' ); }	function pushLuaCode( val, ret, indent ) { var tp = typeof val; if ( tp == 'string' ) { ret.push( makeLuaString( val ) ); } else if ( tp == 'number' ) { ret.push( val ); } else if ( tp == 'boolean' ) { ret.push( String( val ) ); } else if ( $.isArray( val ) ) { pushLuaArray( val, ret, indent ); } else if ( tp == 'object' ) { pushLuaTable( val, ret, indent ); } else { throw new Error( 'setContent data values must be strings, numbers, booleans, arrays, or objects (' + tp + ' detected)' ); }	}	var luaCode = [ 'return ' ]; pushLuaCode( options.data, luaCode, 0 ); this.content = luaCode.join( '' ); };

/* Save the page. * * @param {string} s The string to save * @param {string} summary A custom edit summary * * @return {promise} */ LuaTitle.prototype.save = function ( options ) { if ( !this.content ) { throw new Error( 'no content has been set; use the setContent method'); }	var summary = options.summary || 'update Signpost data'; summary += ' (SPT)'; return this.api.postWithToken( 'csrf', {		format: 'json',		action: 'edit',		title: this.prefixedText,		summary: summary,		contentmodel: 'Scribunto',		text: this.content	} ); };

/****************************************************************************** *                          LuaYearIndex class ******************************************************************************/

var LuaYearIndex = function ( options ) { options = options || {}; options.title = LuaYearIndex.parent.static.signpostModule + '/index/' + options.year.toString; LuaYearIndex.super.call( this, options ); };

OO.inheritClass( LuaYearIndex, LuaTitle );

LuaYearIndex.static.sortKeys = { date: 0, subpage: 1, title: 2, authors: 3, tags: 4, views: 5 };

/* Load and validate the index module data. * * @return {jquery.promise} */ LuaYearIndex.prototype.load = function { var luaTitle = this;

var isString = function ( val ) { return val.constructor === String; };

var isMaybeString = function ( val ) { return val === undefined || isString( val ); };

var isDate = function ( s ) { return /^\d{4}-\d{2}-\d{2}$/.test( s ); };

var isValidSubpage = function ( s ) { return s.length > 0; };

var formatObjError = function ( i, what, expectType ) { return what + ' in object #' + i + ' is not a ' + expectType; };

var rejectDeferred = function ( deferred, code, message ) { return deferred.reject(			code,			{ error: {				code: code,				info: message			} },			{ recoverable: false, title: luaTitle.getTitle }		); };

return LuaYearIndex.super.prototype.load.call( this ).then(		// On success		function ( data ) {			return $.Deferred( function ( deferred ) { if ( !$.isArray( data ) ) { return rejectDeferred(						deferred,						'indexcontainernotarray',						'The outer index data container is not an array'					); }				var dataLength = data.length; for ( var i = 0; i < dataLength; i++ ) { var obj = data[ i ]; var luaKey = i + 1; if ( typeof obj !== 'object' ) { return rejectDeferred(							deferred,							'indexvaluenotobject',							'Value #' + luaKey + ' ' + 'in the index data is not an object'						); } else if ( !isMaybeString( obj.title ) ) { return rejectDeferred(							deferred,							'indextitlenotstring',							formatObjError( luaKey, 'The title property', 'string' )						); } else if ( !isString( obj.date ) || !isDate( obj.date ) ) { return rejectDeferred(							deferred,							'invalidindexdate',							formatObjError( luaKey, 'The date property', 'valid date' )						); } else if ( !isString( obj.subpage ) || !isValidSubpage( obj.subpage ) ) { return rejectDeferred(							deferred,							'invalidindexsubpage',							formatObjError( luaKey, 'The subpage property', 'valid subpage name' )						); }					var tags = obj.tags; if ( $.isArray( tags ) ) { var tagsLength = obj.tags.length; for ( var j = 0; j < tagsLength; j++ ) { if ( !isString( tags[ j ] ) ) { return rejectDeferred(									deferred,									'invalidindextag',									formatObjError( luaKey, 'Tag #' + (j + 1), 'string' )								); }						}					} else if ( obj.tags !== undefined ) { return rejectDeferred(							deferred,							'invalidindextagcontainer',							'The tags property in object #' + luaKey + ' was defined but not an array'						); }				}				return deferred.resolve( data ); } ).promise;		},		// On error		function ( code, errorObj, continuationObj ) {			return $.Deferred( function ( deferred ) { if ( code === "luamodulenotfound" ) { // If a year index is not found, return an empty array return deferred.resolve( [] ); } else { // If loading failed for another reason, pass the error on					return deferred.reject( code, errorObj, continuationObj ); }			} ).promise;		}	); };

LuaYearIndex.prototype.setContent = function ( options ) { options = typeof options === 'object' ? options : {}; if ( options.sortFunc === undefined ) { options.sortFunc = function ( a, b ) { var aSort = LuaYearIndex.static.sortKeys[ a ]; var bSort = LuaYearIndex.static.sortKeys[ b ]; if ( aSort !== undefined && bSort !== undefined ) { return aSort < bSort ? -1 : 1;			} else if ( aSort !== undefined ) { return -1; } else if ( bSort !== undefined ) { return 1; } else { return a < b ? -1 : 1;			}		};	}	LuaYearIndex.super.prototype.setContent.call( this, options ); };

/****************************************************************************** *                          LuaAliases class ******************************************************************************/

var LuaAliases = function ( options ) { options = options || {}; options.title = LuaYearIndex.parent.static.signpostModule + '/aliases'; LuaAliases.super.call( this, options ); };

OO.inheritClass( LuaAliases, LuaTitle );

/* Load and validate the aliases module data. * * @return {jquery.promise} */ LuaAliases.prototype.load = function { var luaAliases = this;

var rejectDeferred = function ( deferred, code, message ) { return deferred.reject(			code,			{ error: {				code: code,				info: message			} },			{ recoverable: false, title: luaAliases.getTitle }		); };

return LuaAliases.super.prototype.load.call( this ).then( function ( data ) {		return $.Deferred( function ( deferred ) { var tag, aliases, i, len, alias;

if ( typeof data !== 'object' || $.isArray( data ) ) { return rejectDeferred(					deferred,					'aliasescontainernotobject',					'The outer aliases data container is not an object'				); }

for ( tag in data ) { if ( data.hasOwnProperty( tag ) ) { aliases = data[ tag ];

if ( !$.isArray( aliases ) ) { return rejectDeferred(							deferred,							'aliasesnotarray',							"The value for tag '" + tag + "' was not an array"						); }

for ( i = 0, len = aliases.length; i < len; i++ ) { alias = aliases[ i ]; if ( typeof alias !== 'string' ) { return rejectDeferred(								deferred,								'aliasnotstring',								"Alias #" + i + " for tag '" + tag + "' was not a string"							); }					}				}			}

return deferred.resolve( data ); } ).promise;	} ); };

/****************************************************************************** *                         TagManager class ******************************************************************************/

/** * Class for managing tags for the current page. * * @class * * @constructor * @param {Object} [config] Configuration options */ var TagManager = function { this.luaYearIndex = new LuaYearIndex( { year: currentPage.getYear } ); this.luaAliases = new LuaAliases; this.title = ''; this.tags = ''; this.aliases = null; this.loaded = false; this.broken = false; this.indexData = null; this.articleExists = false; this.articleData = null; this.articleExistedOriginally = false; this.originalArticleData = null; };

OO.initClass( TagManager );

// Regex to strip all punctuation characters and all whitespace from tag strings. TagManager.static.tagNormalizerRegex = /[\u2000-\u206F\u2E00-\u2E7F\\'!"#\$%&\(\)\*\+,\-\.\/:;<=>\?@\[\]\^_`\{\|\}~\s]/g;

TagManager.prototype.getTitle = function { return this.title; };

TagManager.prototype.setTitle = function ( val ) { this.title = val; };

TagManager.prototype.getTags = function { return this.tags; };

TagManager.prototype.setTags = function ( val ) { this.tags = val; };

TagManager.prototype.isBroken = function { return this.broken; };

TagManager.prototype.isLoaded = function { return this.loaded; };

TagManager.prototype.doesArticleExist = function { return this.articleExists; };

TagManager.prototype.getYearIndexTitle = function { return this.luaYearIndex.getTitle; };

/** * Whether the Lua title can be saved or not. * * @return {boolean} */ TagManager.prototype.isSavable = function { var tags = this.normalizeTagString( this.getTags ); var title = this.normalizeTitleString( this.getTitle ); return !!title.length && (		title !== this.originalArticleData.title ||		tags !== this.originalArticleData.tags	); };

/** * Split a tag string into an array of tags. * * @param {string} s The tag string * @returns {Array} */ TagManager.prototype.splitTags = function ( s ) { var tagManager = this; var aliases = tagManager.aliases; var regex = tagManager.constructor.static.tagNormalizerRegex; return s.trim.split( /,/ ).map( function ( val ) {		// Remove whitespace and punctuation		val = val.toLowerCase.replace( regex, '' );		// Normalize aliases		return val && aliases[ val ] || val;	} ).filter( function ( val, i, arr ) {		// Remove blanks and duplicates		return val && arr.indexOf( val ) === i;	} ).sort( function ( s1, s2 ) {		return s1.localeCompare( s2 );	} ); };

/** * Join a tag array into a string, separated by commas. * * @param {Array} tags The tag array * @returns {string} */ TagManager.prototype.joinTags = function ( tags ) { return tags.join( ', ' ); };

/** * Normalize a tag string into one that can be tested for equality. * The result is the same format produced by TagManager.joinTags. * * @param {string} s The tag string * @returns string */ TagManager.prototype.normalizeTagString = function ( s ) { return this.joinTags( this.splitTags( s ) ); };

/** * Normalize a title string. * * @param {string} s The tag string * @returns string */ TagManager.prototype.normalizeTitleString = function ( s ) { return s.trim; };

/** * Get the index data. */ TagManager.prototype.getIndexData = function { return this.indexData || []; };

/** * Set the index data. * * @param data */ TagManager.prototype.setIndexData = function ( data ) { data = data || []; this.indexData = data; };

/** * Get the default tags for the current subpage. * * @return {string} */ TagManager.prototype.getDefaultTags = function { var tags = defaultTags[ currentPage.getSubpage ] || []; return this.joinTags( tags ); };

/** * Whether an article data object is the article data object for the current * page. * * @param {Object} obj * @return {boolean} */ TagManager.prototype.isCurrentArticleData = function ( obj ) { return obj.date === currentPage.getDate && obj.subpage === currentPage.getSubpage; };

/** * @return {jquery.promise} */ TagManager.prototype.loadData = function { var aliasesPromise, allPromise; var tagManager = this; var indexPromise = tagManager.luaYearIndex.load;

if ( tagManager.loaded ) { aliasesPromise = $.Deferred.resolve.promise; } else { aliasesPromise = this.luaAliases.load.then( function ( data ) {			var tag, aliases, i, len, alias;			tagManager.aliases = {};			for ( tag in data ) {				if ( data.hasOwnProperty( tag ) ) {					aliases = data[ tag ];					for ( i = 0, len = aliases.length; i < len; i++ ) {						alias = aliases[ i ];						tagManager.aliases[ alias ] = tag;					}				}			}		} ); }

allPromise = $.when( indexPromise, aliasesPromise );

allPromise.fail( function ( code, data ) {		tagManager.broken = true;	} );

return allPromise.then( function ( indexData ) {		var filteredIndex, articleData, articleExists;

// Set the index data. tagManager.indexData = indexData;

// Find the article data. filteredIndex = indexData.filter( function ( obj ) {			return tagManager.isCurrentArticleData( obj );		} ); articleData = filteredIndex[ 0 ]; articleExists = !!articleData;

// Normalize the article data. if ( articleData ) { articleData.title = tagManager.normalizeTitleString(				articleData.title || ''			); articleData.date = articleData.date || currentPage.getDate; articleData.subpage = articleData.subpage || currentPage.getSubpage; articleData.tags = articleData.tags || []; articleData.tags = tagManager.joinTags( articleData.tags ); } else { articleData = { title: '', date: currentPage.getDate, subpage: currentPage.getSubpage, tags: '' };		}

// Set the article data. tagManager.articleExists = articleExists; tagManager.articleData = articleData;

// Set things that should only be set on the first load. if ( !tagManager.loaded ) { tagManager.loaded = true; tagManager.articleExistedOriginally = articleExists; tagManager.originalArticleData = articleData; tagManager.setTitle( articleData.title || currentPage.getArticleTitle ); tagManager.setTags( articleData.tags || tagManager.getDefaultTags ); }	} ); };

/** * Handle page saving. * * @param {Object} options * @return {jquery.promise} */ TagManager.prototype._saveIndexData = function ( options ) { var tagManager = this; return tagManager.loadData.then( function {		return $.Deferred( function ( deferred ) { var original = tagManager.originalArticleData; var latest = tagManager.articleData;

// Check for edit conflicts. if ( tagManager.articleExists !== tagManager.articleExistedOriginally ||					original.title !== latest.title ||					original.date !== latest.date ||					original.subpage !== latest.subpage ||					original.tags !== latest.tags			) { // We have an edit conflict. tagManager.broken = true; return deferred.reject(					'spt-editconflict',					{ error: {						code: 'spt-editconflict',						info: 'Edit conflict detected while saving tags'					} },					{ recoverable: false, title: tagManager.getYearIndexTitle }				); }

// Create the new index data. var newIndexData; if ( options[ 'delete' ] ) { newIndexData = tagManager.getIndexData.filter( function ( obj ) {					return !tagManager.isCurrentArticleData( obj );				} ); } else { newIndexData = tagManager.getIndexData.map( function ( obj ) {					if ( !tagManager.isCurrentArticleData( obj ) ) {						return obj;					}					var newObj = Object.assign({}, obj); // shallow copy					newObj.title = tagManager.normalizeTitleString( tagManager.getTitle );					newObj.tags = tagManager.splitTags( tagManager.getTags );					newObj.date = currentPage.getDate;					newObj.subpage = currentPage.getSubpage;					newObj.authors = currentPage.getAuthors;					return newObj;				} ); }

// Set the new index data and sort it. tagManager.indexData = newIndexData; tagManager.indexData.sort( function ( obj1, obj2 ) {				if ( obj1.date < obj2.date ) {					return -1;				} else if ( obj1.date > obj2.date ) {					return 1;				} else {					return obj1.subpage.localeCompare( obj2.subpage );				}			} );

// Make the Lua code and save the page tagManager.luaYearIndex.setContent( {				data: tagManager.indexData			} ); var savePromise = tagManager.luaYearIndex.save( {				summary: options.summary			} );

savePromise.done( function {				deferred.resolve;			} );

savePromise.fail( function ( code, data ) {				tagManager.broken = true;				deferred.reject( code, data );			} ); } ).promise;	} ); };

/** * @param {Object} options * @return {jquery.promise} */ TagManager.prototype.saveData = function { var summary; if ( this.doesArticleExist ) { summary = 'update Signpost data for ' + currentPage.getPrefixedText + ''; } else { summary = 'create Signpost data for ' + currentPage.getPrefixedText + ''; }	return this._saveIndexData( {		summary: summary	} ); };

/** * @return {jquery.promise} */ TagManager.prototype.deleteData = function { return this._saveIndexData( {		'delete': true,		summary: 'delete Signpost data for ' + currentPage.getPrefixedText + ''	} ); };

TagManager.prototype.reset = function { this.title = null; this.tags = null; this.loaded = false; this.broken = false; this.indexData = null; this.aliases = null; this.articleExists = false; this.articleData = null; this.articleExistedOriginally = false; this.originalArticleData = null; };

/****************************************************************************** *                             TagDialog class ******************************************************************************/

/** * TagDialog for editing Signpost article tags. * * @class * @abstract * @extends OO.ui.ProcessDialog * * @constructor * @param {Object} [config] Configuration options */ function TagDialog( config ) { config = config || {}; config.size = 'large'; this.tagManager = new TagManager; TagDialog.super.call( this, config ); // Parent constructor }

/* Inheritance */

OO.inheritClass( TagDialog, OO.ui.ProcessDialog );

/* Static properties */

TagDialog.static.name = 'SignpostTaggerDialog'; TagDialog.static.title = 'Manage Signpost tags';

TagDialog.static.actions = [ { action: 'save', label: 'Save', flags: [ 'primary', 'constructive' ] }, { action: 'delete', label: 'Delete', flags: 'destructive' }, { label: 'Cancel', flags: 'safe' } ];

/** * Set custom height. */ TagDialog.prototype.getBodyHeight = function { return 285; };

/** * Initialize the dialog. */ TagDialog.prototype.initialize = function { // Parent initalize method TagDialog.super.prototype.initialize.apply( this, arguments );

// Initialize widgets this.panel = new OO.ui.PanelLayout( {		$: this.$,		padded: true,		expanded: false	} ); this.fieldset = new OO.ui.FieldsetLayout( {		$: this.$,		classes: [ 'container' ]	} ); this.titleInput = new OO.ui.TextInputWidget( {		$: this.$,		placeholder: 'Insert the article title',	} ); this.tagInput = new OO.ui.TextInputWidget( {		$: this.$,		placeholder: 'Insert tags',		selected: true,	} ); this.dateLabel = new OO.ui.LabelWidget( {		$: this.$,		label: $( ' ' ).css( 'font-style', 'italic' ).text( currentPage.getDate )	} ); this.subpageLabel = new OO.ui.LabelWidget( {		$: this.$,		label: $( ' ' ).css( 'font-style', 'italic' ).text( currentPage.getSubpage )	} ); this.authorsLabel = new OO.ui.LabelWidget( {		$: this.$,		label: this.makeAuthorList( currentPage.getAuthors )	} ); this.fieldset.addItems( [		new OO.ui.FieldLayout( this.titleInput, { $: this.$, label: 'Title', align: 'top' } ),		new OO.ui.FieldLayout( this.tagInput, { $: this.$, label: 'Tags (comma-separated)', align: 'top' } ),		new OO.ui.FieldLayout( this.dateLabel, { $: this.$, label: 'Date', align: 'left' } ),		new OO.ui.FieldLayout( this.subpageLabel, { $: this.$, label: 'Subpage', align: 'left' } ),		new OO.ui.FieldLayout( this.authorsLabel, { $: this.$, label: 'Authors', align: 'left' } )	] );

// Add widgets to the DOM this.panel.$element.append( this.fieldset.$element ); this.$body.append( this.panel.$element ); };

/** * Make a list of article authors. * * @param {Array} authors An array of author usernames * @return {jQuery} */ TagDialog.prototype.makeAuthorList = function ( authors ) { var i, len, $authorList = $( ' ' ); for (i = 0, len = authors.length; i < len; i++ ) { $authorList.append( $( '' )			.attr( 'href', mw.Title.newFromText( authors[ i ], 2 ).getUrl )			.text( authors[ i ] )		); if ( i + 1 < len ) { $authorList.append( ', ' ); }	}	return $authorList; };

/** * Call a method for all text input widgets. * * @param {string} method The method name * @param {Object} arg1 The first argument * @param {Object} arg2 The second argument */ TagDialog.prototype.setTextWidgetMethod = function ( method, arg1, arg2 ) { this.titleInput[ method ]( arg1, arg2 ); this.tagInput[ method ]( arg1, arg2 ); };

/** * Disable all the dialog's content widgets. * * @param {boolean} disabled */ TagDialog.prototype.setDisabled = function ( disabled ) { this.setTextWidgetMethod( 'setDisabled', disabled ); this.dateLabel.setDisabled( disabled ); this.subpageLabel.setDisabled( disabled ); };

/** * Set the focus. */ TagDialog.prototype.setFocus = function { if ( !this.titleInput.getValue ) { this.titleInput.focus; } else { this.tagInput.focus; } };

/** * Set the save ability. */ TagDialog.prototype.setSaveAbility = function { this.actions.setAbilities( { save: this.tagManager.isSavable } ); };

/** * Set the delete ability. */ TagDialog.prototype.setDeleteAbility = function { this.actions.setAbilities( { "delete": this.tagManager.doesArticleExist } ); };

/** * Handle text input. */ TagDialog.prototype.onTextInput = function { this.tagManager.setTitle( this.titleInput.getValue ); this.tagManager.setTags( this.tagInput.getValue ); this.setSaveAbility; };

/** * Handle enter presses on text input. */ TagDialog.prototype.onTextEnter = function { if ( this.tagManager.isSavable ) { this.executeAction( 'save' ); } };

/** * Make an OO.ui.Error object with nice formatting. * * @param {string} msg The error message * @param {Object} options * * @return {Object} the OO.ui.Error object */ TagDialog.prototype.makeError = function ( response, options ) { var titleObj, message, code, $selection, $small, sep, logTitle; var tagManager = this.tagManager; options = typeof options === 'object' ? options : {};

function makeLink ( url, title, display ) { return $( '' ) .attr( 'href', url ) .attr( 'title', title ) .text( display ); }

function makeTitleLink ( titleObj, urlParams, display ) { return makeLink(			titleObj.getUrl( urlParams ),			titleObj.getPrefixedText,			display		); }

// Get the title object, if any. if ( typeof options.title === 'object' ) { titleObj = options.title; } else if ( typeof options.title === 'string' ) { titleObj = mw.Title.newFromText( options.title ); }

// Get the error message and code if ( typeof response === 'object' && response.error ) { message = response.error.info; code = response.error.code; } else if ( typeof response === 'string' ) { message = response; code = response; } else { message = 'An unknown error occurred'; code = 'unknownerror'; }

// Set options for some known error types. if ( options.recoverable === undefined && code === 'hookaborted' ) { // We probably tried to save some invalid Lua. options.recoverable = false; }

// Format the message $selection = $( ' ' ) .css( 'text-align', 'center' ) .append( $( ' ' )			.append( $( ' ' ) .addClass( 'error' ) .text( message ) )		);

// Add title links if ( titleObj ) { $small = $( ' ' ); sep = ' | '; logTitle = mw.Title.newFromText( 'Special:Log' ); $small .append( 'Check data module: (' )			.append( makeTitleLink( titleObj, null, 'view' ) )			.append( sep )			.append( makeTitleLink( titleObj, { action: 'edit' }, 'edit' ) )			.append( sep )			.append( makeTitleLink( titleObj, { action: 'history' }, 'history' ) )			.append( sep )			.append( makeLink(				logTitle.getUrl( { page: titleObj.getPrefixedText } ),				logTitle.getPrefixedText,				'logs'			) )			.append( ')' );

$selection.append( $( ' ' ).append( $small ) ); }

return new OO.ui.Error( $selection, options ); };

/** * Handle pending status when making a network request. * * @param {jquery.promise} * @return {jquery.promise} */ TagDialog.prototype.setPending = function ( promise ) { var dialog = this;

// Disable editing dialog.pushPending; dialog.setDisabled( true ); dialog.actions.setAbilities( { save: false, "delete": false } );

// Re-enable editing when events are no longer pending. promise.always( function {		dialog.popPending;	} );

return promise.then( null, function ( code, data, options ) {		// Failure handler		return [ dialog.makeError( data, options ) ];	} ); };

/** * Handle load action. */ TagDialog.prototype.onLoad = function { var dialog = this; var tagManager = dialog.tagManager; var promise = tagManager.loadData; promise.done( function {		// Set input fields		dialog.titleInput.setValue( tagManager.getTitle );		dialog.tagInput.setValue( tagManager.getTags );

// Enable editing area dialog.setDisabled( false ); dialog.setSaveAbility; dialog.setDeleteAbility; dialog.setFocus;

// Connect event handlers dialog.setTextWidgetMethod( 'connect', dialog, {			'change': 'onTextInput',			'enter': 'onTextEnter'		} ); } );	return dialog.setPending( promise ); };

/** * Override default ready process * * @param {Object} data */ TagDialog.prototype.getReadyProcess = function ( data ) { // Parent getReadyProcess method return TagDialog.super.prototype.getReadyProcess.call( this, data ) .next( function {		// Trigger the load action once the dialog is set up.		if ( this.tagManager.isLoaded ) {			this.setFocus;			this.setSaveAbility;			this.setDeleteAbility;		} else {			this.executeAction( 'load' );		}	}, this ); };

/** * Handle shared operations for saving and deleting pages. * * @param {Object} options */ TagDialog.prototype.onDataSave = function ( options ) { var dialog = this; options.promise.done( function {		// Close the window and issue a notification when it's done.		var closePromise = dialog.close( { action: options.action } );		closePromise.done( function  { var msgSetOptions = {}; msgSetOptions[ options.notificationMessage ] = options.notificationText; mw.messages.set( msgSetOptions ); mw.notify(				mw.message( options.notificationMessage ),				{ title: options.notificationTitle }			); } );	} );	return dialog.setPending( options.promise ); };

/** * Handle saving. */ TagDialog.prototype.onSave = function { var notification, messageKey; var yearIndexPrefixedText = this.tagManager.getYearIndexTitle.getPrefixedText; if ( this.tagManager.doesArticleExist ) { notification = '' + yearIndexPrefixedText + ' was updated.'; messageKey = 'spt-data-updated'; } else { notification = 'A new entry was created at ' + yearIndexPrefixedText + '.'; messageKey = 'spt-data-created'; }	return this.onDataSave( {		promise: this.tagManager.saveData,		action: 'save',		notificationText: notification,		notificationTitle: 'Signpost data saved',		notificationMessage: messageKey	} ); };

/** * Handle deleting. */ TagDialog.prototype.onDelete = function { if ( !this.tagManager.doesArticleExist ) { throw new Error( "Tried to delete but the article data doesn't exist" ); }	return this.onDataSave( {		promise: this.tagManager.deleteData,		action: 'delete',		notificationText: 'The entry was removed from ' +			this.tagManager.getYearIndexTitle.getPrefixedText +			'.',		notificationTitle: 'Signpost data deleted',		notificationMessage: 'spt-data-deleted'	} ); };

/** * Handle actions. This also handles the load action, which doesn't have a * dedicated button. */ TagDialog.prototype.getActionProcess = function ( action ) { return TagDialog.super.prototype.getActionProcess.call( this, action ) .next( function {		if ( action === 'load' ) {			return this.onLoad;		} else if ( action === 'save' ) {			return this.onSave;		} else if ( action === 'delete' ) {			return this.onDelete;		} else {			return TagDialog.super.prototype.getActionProcess.call( this, action );		}	}, this ); };

/** * Extend the default teardown process. */ TagDialog.prototype.getTeardownProcess = function ( data ) { return TagDialog.super.prototype.getTeardownProcess.call( this, data ) .first( function {		if ( this.tagManager.isBroken || data && ( data.action === 'save' || data.action === 'delete' ) ) {			// Disconnect event handlers			this.setTextWidgetMethod( 'disconnect', this, { 'change': 'onTextInput', 'enter': 'onTextEnter' } );

// Reset everything. this.tagManager.reset; this.setTextWidgetMethod( 'setValue', '' ); }	}, this ); };

/****************************************************************************** *                          Initialisation code ******************************************************************************/

function main { if ( currentPage.needsTagging ) { var dialog = new TagDialog; currentPage.addSignpostPortlet( dialog ); } }

main;

} );

//