MediaWiki:Gadget-wrcEditor-core.js

From Meta, a Wikimedia project coordination wiki

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

  • Firefox / Safari: Hold Shift while clicking Reload, or press either Ctrl-F5 or Ctrl-R (⌘-R on a Mac)
  • Google Chrome: Press Ctrl-Shift-R (⌘-Shift-R on a Mac)
  • Internet Explorer / Edge: Hold Ctrl while clicking Refresh, or press Ctrl-F5
  • Opera: Press Ctrl-F5.
/**
 * See:
 * https://meta.wikimedia.org/wiki/MediaWiki:Gadget-wrcEditor.js
 */
( function () {
	'use strict';

	mw.loader.using( [
		'mediawiki.api',
		'oojs-ui',
		'oojs-ui-core',
		'oojs-ui.styles.icons-editing-core',
		'ext.gadget.luaparse'
	] ).done( function () {
		var editButton, cleanRawEntry, gadgetMsg, getContentModuleQuery,
		getRelevantRawEntry, parseContentModule, openWindow, uniqueId, userLang;
		
		// Declare an asterisk to be used by mandatory editor fields
		var asterisk = " *";

		userLang = mw.config.get( 'wgUserLanguage' );
		
		new mw.Api().get( {
			action: 'query',
			list: 'messagecollection',
			mcgroup: 'page-Template:I18n/Wikimedia_Resource_Center',
			mclanguage: userLang
		} ).done( function ( data ) {
			var i, res, key, val, gadgetMsg = {};
			res = data.query.messagecollection;
			for ( i = 0; i < res.length; i++ ) {
				key = res[ i ].key.replace( 'Template:I18n/Wikimedia_Resource_Center/', '' );
				val = res[ i ].translation;
				if ( !val ) {
					// No translation; fall back to English
					val = res[ i ].definition;
				}
				gadgetMsg[ key ] = val;
			}

			 /**
			  * Provides API parameters for getting the content of the Wikimedia
			  * Resource Center.
			  *
			  * @return {Object}
			  */
			getContentModuleQuery = function () {
				return {
					action: 'query',
					prop: 'revisions',
					titles: 'Module:Wikimedia Resource Center/Content',
					rvprop: 'content',
					rvlimit: 1
				};
			};

			/**
			  * Takes Lua-formatted Wikimedia Resource Center content and returns an
			  * abstract syntax tree.
			  *
			  * @param {Object} sourceblob The original API return
			  * @return {Object} Abstract syntax tree
			  */
			parseContentModule = function ( sourceblob ) {
				var ast, i, raw;
				for ( i in sourceblob ) {  // should only be one result
					raw = sourceblob[ i ].revisions[ 0 ][ '*' ];
					ast = luaparse.parse( raw );
					return ast.body[ 0 ].arguments[ 0 ].fields;
				}
			};

			/**
			  * Picks through the abstract syntax tree and returns a specific requested
			  * entry
			  *
			  * @param {Object} entries The abstract syntax tree
			  * @param {string} uniqueId the entry we want to pick out.
			  */
			getRelevantRawEntry = function ( entries, uniqueId ) {
				var i, j;
				// Look through the individual entries
				for ( i = 0; i < entries.length; i++ ) {
					// Loop through the individual key-value pairs within each entry
					for ( j = 0; j < entries[ i ].value.fields.length; j++ ) {
						if (
							entries[ i ].value.fields[ j ].key.name == 'unique_id' &&
							entries[ i ].value.fields[ j ].value.value == uniqueId
						) {
							return entries[ i ].value.fields;
						}
					}
				}
			};

			/**
			  * Take a raw entry from the abstract syntax tree and make it an object
			  * that is easier to work with.
			  *
			  * @param {Object} relevantRawEntry the raw entry from the AST
			  * @return {Object} The cleaned up object
			  */
			cleanRawEntry = function ( relevantRawEntry ) {
				var entryData = {},
					i, j;
				for ( i = 0; i < relevantRawEntry.length; i++ ) {
					if ( relevantRawEntry[ i ].key.name == 'audiences' ) {
						entryData.audiences = [];
						for (
							j = 0;
							j < relevantRawEntry[ i ].value.fields.length;
							j++
						) {
							entryData.audiences.push(
								relevantRawEntry[ i ].value.fields[ j ].value.value
							);
						}
					} else {
						entryData[ relevantRawEntry[ i ].key.name ] = relevantRawEntry[ i ].value.value;
					}
				}
				return entryData;
			};

			/**
			  * Subclass ProcessDialog
			  *
			  * @class WrcEditor
			  * @extends OO.ui.ProcessDialog
			  *
			  * @constructor
			  * @param {Object} config
			  */
			function WrcEditor( config ) {
				this.header = config.header;
				this.description = '';
				this.contact = '';
				this.related = '';
				this.category = '';
				this.audiences = [];

				if ( config.unique_id ) {
					this.uniqueId = config.unique_id;
				}
				// If this is a new entry, there is no unique ID until the page is
				// saved. This is how we know it's a new entry.

				if ( config.description ) {
					this.description = config.description;
				}
				if ( config.contact ) {
					this.contact = config.contact;
				}
				if ( config.related ) {
					this.related = config.related;
				}
				if ( config.category ) {
					this.category = config.category;
				}
				if ( config.audiences ) {
					this.audiences = config.audiences;
				}
				if ( config.community ) {
					this.community = config.community;
				}
				WrcEditor.super.call( this, config );
			}
			OO.inheritClass( WrcEditor, OO.ui.ProcessDialog );

			WrcEditor.static.name = 'wrcEditor';
			WrcEditor.static.title = gadgetMsg[ 'editor-header' ];
			WrcEditor.static.actions = [
				{
					action: 'continue',
					modes: 'edit',
					label: gadgetMsg[ 'editor-save' ],
					flags: [ 'primary', 'constructive' ]
				},
				{
					action: 'cancel',
					modes: 'edit',
					label: gadgetMsg[ 'editor-cancel' ],
					flags: 'safe'
				}
			];

			/**
			  * Use the initialize() method to add content to the dialog's $body,
			  * to initialize widgets, and to set up event handlers.
			  */
			WrcEditor.prototype.initialize = function () {
				var dialog, fieldAudiencesSelected, fieldCommunityEvaluate, i;

				dialog = this;

				WrcEditor.super.prototype.initialize.call( this );
				this.content = new OO.ui.PanelLayout( {
					padded: true,
					expanded: false
				} );
				this.fieldHeader = new OO.ui.TextInputWidget( {
					value: this.header
				} );
				this.fieldDescription = new OO.ui.MultilineTextInputWidget( {
					value: this.description,
					rows: 5
				} );
				this.fieldRelated = new OO.ui.TextInputWidget( {
					value: this.related
				} );
				this.fieldContact = new OO.ui.TextInputWidget( {
					value: this.contact
				} );
				this.fieldCategory = new OO.ui.DropdownInputWidget( {
					options: [
						{
							data: 'Contact and Questions',
							label: gadgetMsg[ 'category-contact-and-questions' ]
						},
						{
							data: 'Skills Development',
							label: gadgetMsg[ 'category-skills-development' ]
						},
						{
							data: 'Grants Support',
							label: gadgetMsg[ 'category-grants-support' ]
						},
						{
							data: 'Programs Support',
							label: gadgetMsg[ 'category-programs-support' ]
						},
						{
							data: 'Software Basics',
							label: gadgetMsg[ 'category-software-basics' ]
						},
						{
							data: 'Software Development',
							label: gadgetMsg[ 'category-software-development' ]
						},
						{
							data: 'Technical Infrastructure',
							label: gadgetMsg[ 'category-technical-infrastructure' ]
						},
						{
							data: 'Global Reach Partnerships',
							label: gadgetMsg[ 'category-global-reach-partnerships' ]
						},
						{
							data: 'Legal',
							label: gadgetMsg[ 'category-legal' ]
						},
						{
							data: 'Communications',
							label: gadgetMsg[ 'category-communications' ]
						},
					]
				} );

				this.fieldCategory.setValue( this.category );

				fieldAudiencesSelected = [];
				for ( i = 0; i < this.audiences.length; i++ ) {
					fieldAudiencesSelected.push(
						{ data: this.audiences[ i ], label: gadgetMsg[ 'audience-' + this.audiences[ i ].toLowerCase().replace( / /g, '-' ) ] }
					);
				}
				this.fieldAudiences = new OO.ui.MenuTagMultiselectWidget( {
					selected: fieldAudiencesSelected,
					options: [
						{ data: 'For program coordinators', label: gadgetMsg[ 'audience-for-program-coordinators' ] },
						{ data: 'For contributors', label: gadgetMsg[ 'audience-for-contributors' ] },
						{ data: 'For developers', label: gadgetMsg[ 'audience-for-developers' ] },
						{ data: 'For affiliate organizers', label: gadgetMsg[ 'audience-for-affiliate-organizers' ] }
					]
				} );

				fieldCommunityEvaluate = function () {
					if ( dialog.community == 'no' ) {
						return true;
					}
					return false;
				};

				this.fieldCommunity = new OO.ui.CheckboxInputWidget( {
					selected: fieldCommunityEvaluate()
				} );
				
				// This will be used to make a notice for required fields
				//this.fieldNotice = new OO.ui.HiddenInputWidget( {} );
				
				this.deleteButton = new OO.ui.ButtonWidget( {
					label: gadgetMsg[ 'editor-remove-entry' ],
					icon: 'trash',
					flags: [ 'destructive' ]
				} ).on( 'click', function () {
					new OO.ui.confirm(
						gadgetMsg[ 'editor-remove-confirm' ]
					).done( function ( confirmed ) {
						if ( confirmed ) {
							dialog.saveItem( 'delete' );
						}
					} );
				} );

				// Append things to fieldSet
				this.fieldSet = new OO.ui.FieldsetLayout( {
					items: [
						new OO.ui.FieldLayout(
							this.fieldHeader,
							{
								label: gadgetMsg[ 'editor-field-header' ],
								align: 'top',
								help: gadgetMsg[ 'editor-help-header' ]
							}
						),
						new OO.ui.FieldLayout(
							this.fieldDescription,
							{
								label: gadgetMsg[ 'editor-field-description' ],
								align: 'top',
								help: gadgetMsg[ 'editor-help-description' ]
							}
						),
						new OO.ui.FieldLayout(
							this.fieldContact,
							{
								label: gadgetMsg[ 'editor-field-contact' ],
								align: 'top',
								help: gadgetMsg[ 'editor-help-contact' ]
							}
						),
						new OO.ui.FieldLayout(
							this.fieldRelated,
							{
								label: gadgetMsg[ 'editor-field-relatedpages' ],
								align: 'top',
								help: gadgetMsg[ 'editor-help-relatedpages' ]
							}
						),
						new OO.ui.FieldLayout(
							this.fieldCategory,
							{
								label: gadgetMsg[ 'editor-field-category' ] + asterisk,
								align: 'top'
							}
						),
						new OO.ui.FieldLayout(
							this.fieldAudiences,
							{
								label: gadgetMsg[ 'editor-field-audiences' ] + asterisk,
								align: 'top'
							}
						),
						new OO.ui.FieldLayout(
							this.fieldCommunity,
							{
								label: gadgetMsg[ 'editor-field-wmf' ],
								align: 'inline'
							}
						)/*,
						new OO.ui.FieldLayout(
							this.fieldNotice,
							{
								label: gadgetMsg[ 'required-field-notice' ],
								align: 'top'
							}
						)*/
					]
				} );

				if ( this.uniqueId ) {
					this.fieldSet.addItems( [
						new OO.ui.FieldLayout(
							this.deleteButton
						)
					] );
				}

				// When everything is done
				this.content.$element.append( this.fieldSet.$element );
				this.$body.append( this.content.$element );
			};

			/**
			  * Set custom height for the modal window
			  *
			  */
			WrcEditor.prototype.getBodyHeight = function () {
				return 660;
			};

			/**
			  * In the event "Select" is pressed
			  *
			  */
			WrcEditor.prototype.getActionProcess = function ( action ) {
				var dialog = this;
				if ( action === 'continue' && dialog.fieldHeader.getValue() ) {
					return new OO.ui.Process( function () {
						dialog.saveItem();
					} );
				} else {
					return new OO.ui.Process( function () {
						dialog.close();
					} );
				}
				return NewItemDialog.parent.prototype.getActionProcess.call( this, action );
			};

			/**
			  * Save the changes to the Lua table.
			  *
			  * @param {string} deleteFlag A string that says 'delete' (or anything,
			  *   really) if the entry being edited is flagged for deletion
			  */
			WrcEditor.prototype.saveItem = function ( deleteFlag ) {
				var dialog = this;

				dialog.pushPending();

				new mw.Api().get( getContentModuleQuery() ).done( function ( data ) {
					var i, j, editSummary, entries, insertInPlace, insertInPlacei18n,
						itemIndex, generateKeyValuePair, generateTranslateStuff,
						manifest = [],
 									processWorkingEntry, raw, sanitizeInput,
						workingEntry;

					/**
					  * Sanitizes input for saving to wiki
					  *
					  * @param {string} s
					  *
					  * @return {string}
					  */
					sanitizeInput = function ( s ) {
						return s
							.replace( /\\/g, '\\\\' )
						.replace( /\n/g, '<br />' );
					};

					/**
					  * Creates Lua-style key-value pairs, including converting the
					  * audiences array into a proper sequential table.
					  *
					  * @param {string} k The key
					  * @param {string} v The value
					  *
					  * @return {string}
					  */
					generateKeyValuePair = function ( k, v ) {
						var res, jsonarray;
						res = '\t\t'.concat( k, ' = ' );
						if ( k == 'audiences' ) {
							jsonarray = JSON.stringify( v );
							// Lua uses { } for "arrays"
							jsonarray = jsonarray.replace( '[', '{' );
							jsonarray = jsonarray.replace( ']', '}' );
							// Style changes (single quotes, spaces after commas)
							jsonarray = jsonarray.replace( /\"/g, '\'' );
							jsonarray = jsonarray.replace( /,/g, ', ' );
							// Basic input sanitation
							jsonarray = sanitizeInput( jsonarray );
							res += jsonarray;
						} else {
							v = sanitizeInput( v );
							v = v.replace( /'/g, '\\\'' );
							res += '\'' + v + '\'';
						}
						res += ',\n';
						return res;
					};

					generateTranslateStuff = function ( k, v, uid ) {
						return '* <trans' + 'late><!--T:content-' +
							uid +
							'-' +
							k +
							'--> ' +
							v +
							'</trans' + 'late>\n';
					};

					/**
					  * Compares a given Wikimedia Resource Center entry against the
					  * edit fields and applies changes where relevant.
					  *
					  * @param {Object} workingEntry the entry being worked on
					  * @return {Object} The same entry but with modifications
					  */
					processWorkingEntry = function ( workingEntry ) {
						workingEntry.header = dialog.fieldHeader.getValue();

						if ( dialog.fieldDescription.getValue() ) {
							workingEntry.description = dialog.fieldDescription.getValue();
						} else if ( !dialog.fieldDescription.getValue() && workingEntry.description ) {
							delete workingEntry.description;
						}

						if ( dialog.fieldContact.getValue() ) {
							workingEntry.contact = dialog.fieldContact.getValue();
						} else if ( !dialog.fieldContact.getValue() && workingEntry.contact ) {
							delete workingEntry.contact;
						}

						if ( dialog.fieldRelated.getValue() ) {
							workingEntry.related = dialog.fieldRelated.getValue();
						} else if ( !dialog.fieldRelated.getValue() && workingEntry.related ) {
							delete workingEntry.related;
						}

						if ( dialog.fieldCategory.getValue() ) {
							workingEntry.category = dialog.fieldCategory.getValue();
						} else if ( !dialog.fieldCategory.getValue() && workingEntry.category ) {
							delete workingEntry.category;
						}

						if ( dialog.fieldAudiences.getValue() ) {
							workingEntry.audiences = dialog.fieldAudiences.getValue();
						} else if ( !dialog.fieldAudiences.getValue() && workingEntry.audiences ) {
							delete workingEntry.audiences;
						}

						if ( dialog.fieldCommunity.isSelected() ) {
							workingEntry.community = 'no';
						} else if ( !dialog.fieldCommunity.isSelected() && workingEntry.community ) {
							delete workingEntry.community;
						}

						return workingEntry;
					};

					// Cycle through existing entries. If we are editing an existing
					// entry, that entry will be modified in place.
					entries = parseContentModule( data.query.pages );

					for ( i = 0; i < entries.length; i++ ) {
						workingEntry = cleanRawEntry( entries[ i ].value.fields );
						if ( workingEntry.unique_id == dialog.uniqueId ) {
							if ( deleteFlag ) {
								editSummary = 'Removing entry '.concat( workingEntry.header );
							} else {
								workingEntry = processWorkingEntry( workingEntry );
								editSummary = 'Editing entry '.concat( workingEntry.header );
							}
						}
						if ( workingEntry.unique_id != dialog.uniqueId || !deleteFlag ) {
							manifest.push( workingEntry );
						}
					}

					// No unique ID means this is a new entry
					if ( !dialog.uniqueId ) {
						workingEntry = {
							unique_id: Math.random().toString( 36 ).substring( 2 )
						};
						workingEntry = processWorkingEntry( workingEntry );
						editSummary = 'Adding entry '.concat( workingEntry.header );
						manifest.push( workingEntry );
					}

					// Re-generate the Lua table based on `manifest`
					// Also re-generate the translation string page
					insertInPlace = 'return {\n';
					insertInPlacei18n = '==Content==\n';
					for ( i = 0; i < manifest.length; i++ ) {
						insertInPlace += '\t{\n';
						if ( manifest[ i ].unique_id ) {
							insertInPlace += generateKeyValuePair(
								'unique_id',
								manifest[ i ].unique_id
							);
						}
						if ( manifest[ i ].header ) {
							insertInPlace += generateKeyValuePair(
								'header',
								manifest[ i ].header
							);
							insertInPlacei18n += generateTranslateStuff(
								'header',
								manifest[ i ].header,
								manifest[ i ].unique_id
							);
						}
						if ( manifest[ i ].description ) {
							insertInPlace += generateKeyValuePair(
								'description',
								manifest[ i ].description
							);
							insertInPlacei18n += generateTranslateStuff(
								'description',
								manifest[ i ].description,
								manifest[ i ].unique_id
							);
						}
						if ( manifest[ i ].contact ) {
							insertInPlace += generateKeyValuePair(
								'contact',
								manifest[ i ].contact
							);
							insertInPlacei18n += generateTranslateStuff(
								'contact',
								manifest[ i ].contact,
								manifest[ i ].unique_id
							);
						}
						if ( manifest[ i ].related ) {
							insertInPlace += generateKeyValuePair(
								'related',
								manifest[ i ].related
							);
							insertInPlacei18n += generateTranslateStuff(
								'related',
								manifest[ i ].related,
								manifest[ i ].unique_id
							);
						}
						if ( manifest[ i ].category ) {
							insertInPlace += generateKeyValuePair(
								'category',
								manifest[ i ].category
							);
						}
						if ( manifest[ i ].audiences ) {
							insertInPlace += generateKeyValuePair(
								'audiences',
								manifest[ i ].audiences
							);
						}
						if ( manifest[ i ].community ) {
							insertInPlace += generateKeyValuePair(
								'community',
								manifest[ i ].community
							);
						}
						insertInPlace += '\t},\n';
					}
					insertInPlace += '}';

					new mw.Api().postWithToken(
						'csrf',
						{
							action: 'edit',
							nocreate: true,
							summary: editSummary,
							pageid: 10355457,  // Module:Wikimedia_Resource_Center/Content
							text: insertInPlace,
							contentmodel: 'Scribunto'
						}
					).done( function () {
						dialog.close();
						new mw.Api().postWithToken(
							'csrf',
							{
								action: 'edit',
								nocreate: true,
								summary: editSummary,
								pageid: 10405434,  // Template:I18n/Wikimedia_Resource_Center
								section: 4, // ==Content==
								text: insertInPlacei18n
							}
						).done( function () {
							// Purge the cache of the page from which the edit was made
							new mw.Api().postWithToken(
								'csrf',
								{ action: 'purge', titles: mw.config.values.wgPageName }
							).done( function () {
								location.reload();
							} );
						} );
					} ).fail( function () {
						alert( gadgetMsg[ 'editor-editfailed' ] );
						dialog.close();
					} );
				} );
			};

			/**
				* Event handler for when someone clicks on an edit icon/button
				*
				* @param {Object} config
				*/
			openWindow = function ( config ) {
				var wrcEditor, windowManager;
				config.size = 'large';
				wrcEditor = new WrcEditor( config );

				windowManager = new OO.ui.WindowManager();
				$( 'body' ).append( windowManager.$element );
				windowManager.addWindows( [ wrcEditor ] );
				windowManager.openWindow( wrcEditor );
			};

			$( '.wrc-icons' ).each( function () {
				var $icon = $( this ),
					editButton;
				editButton = new OO.ui.ButtonWidget( {
					framed: false,
					icon: 'edit'
				} ).on( 'click', function () {
					new mw.Api().get( getContentModuleQuery() ).done( function ( data ) {
						var entryData, uniqueId;

						uniqueId = editButton.$element
							.closest( '.wrc-card' )
							.data( 'wrc-unique-id' );

						entryData = cleanRawEntry(
							getRelevantRawEntry(
								parseContentModule( data.query.pages ),
								uniqueId
							)
						);
						openWindow( entryData );
					} );
				} );
				$icon.append( editButton.$element );
			} );

			$( '.wrc-add-button' ).each( function () {
				var $addButton = $( this ),
					addButton;
				addButton = new OO.ui.ButtonWidget( {
					icon: 'add',
					label: gadgetMsg[ 'editor-addbutton' ],
					flags: [ 'primary', 'progressive' ]
				} ).on( 'click', function () {
					var category = addButton.$element
						.closest( '.wrc-add-button' )
						.data( 'wrc-category' );
					openWindow( { category: category } );
				} );
				$addButton.css( 'margin', '1.125em' );
				$addButton.append( addButton.$element );
			} );
		} );
	} );
}() );