User:Mr. Stradivarius/gadgets/SignpostTagger-test.js
Appearance
Code that you insert on this page could contain malicious content capable of compromising your account. If you import a script from another page with "importScript", "mw.loader.load", "iusc", or "lusc", take note that this causes you to dynamically load a remote script, which could be changed by others. Editors are responsible for all edits and actions they perform, including by scripts. User scripts are not centrally supported and may malfunction or become inoperable due to software changes. A guide to help you find broken scripts is available. If you are unsure whether code you are adding to this page is safe, you can ask at the appropriate village pump. This code will be executed when previewing this page. |
![]() | Documentation for this user script can be added at User:Mr. Stradivarius/gadgets/SignpostTagger-test. |
// <nowiki>
/*
* 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: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 += ' ([[WP:SPT|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: $( '<span>' ).css( 'font-style', 'italic' ).text( currentPage.getDate() )
} );
this.subpageLabel = new OO.ui.LabelWidget( {
$: this.$,
label: $( '<span>' ).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 = $( '<span>' );
for (i = 0, len = authors.length; i < len; i++ ) {
$authorList.append( $( '<a>' )
.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 $( '<a>' )
.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 = $( '<div>' )
.css( 'text-align', 'center' )
.append( $( '<p>' )
.append( $( '<strong>' )
.addClass( 'error' )
.text( message )
)
);
// Add title links
if ( titleObj ) {
$small = $( '<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( $( '<p>' ).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();
} );
// </nowiki>