User:Danmichaelo/Gadget-externalitemsuggester.js

From Wikidata
Jump to navigation Jump to search

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.
/**
 * Gadget-externalitemsuggester.js
 *
 * Gadget that provides autocompletion for supported external-id properties on Wikidata
 * using external search services.
 *
 * Draws on code from jquery.wikibase.entitysuggester.js and jquery.wikibase.commonssuggester.js
 *
 * @author Dan Michael O. Heggø <danmichaelo@gmail.com>
 * @licence MIT
 */
 mw.loader.using(['jquery.ui', 'wikibase.api.RepoApi']).then( function () {
( function ( mw, wb, $ ) {
	'use strict';

	var config = {
			url: 'https://tools.wmflabs.org/externalitemsuggester/search?property=%PROPERTY%&value=%QUERY%',
			supportedProperties: [
				'P214', // VIAF
				'P1015', // Bibsys
				'P1566' // GeoNames
			],
			timeout: 10000
		},
		api = new mw.Api(),
		repoApi = new wb.api.RepoApi( api );

	/**
	 * The external item suggester widget.
	 *
	 * @class jQuery.ui.externalitemsuggester
	 * @extends jQuery.ui.suggester
	 *
	 * @constructor
	 */
	$.widget( 'ui.externalitemsuggester', $.ui.suggester, {

		/**
		 * Caches retrieved results.
		 *
		 * @property {Object} [_cache={}]
		 * @protected
		 */
		_cache: null,

		/**
		 * @see jQuery.ui.suggester._create
		 */
		_create: function () {

			this._cache = {};

			console.log( '[externalitemsuggester] Create suggester', this.options );

			if ( !this.options.source ) {
				this.options.source = this._initDefaultSource();
			}
			$.ui.suggester.prototype._create.call( this );

			// Piggyback on the ui-entityselector-* classes (until we define our own?)
			this.element
				.addClass( 'ui-entityselector-input' )
				.prop( 'dir', $( document ).prop( 'dir' ) )
				.css( 'background', '#ffffaa' ); // Just for testing. Should be removed @TODO

			this.options.menu.element.addClass( 'ui-entityselector-list' );
		},

		/**
		 * @inheritdoc
		 * @protected
		 */
		destroy: function () {
			this._cache = {};
			this.element.removeClass( 'ui-entityselector-input' );
			$.ui.suggester.prototype.destroy.call( this );
		},

		/**
		 * Initializes the default source pointing to the external item search service.
		 *
		 * @protected
		 * @return {Function}
		 */
		_initDefaultSource: function () {
			var self = this;

			return function ( term ) {
				var deferred = $.Deferred(),
					url = config.url
						.replace( '%PROPERTY%', encodeURIComponent( self.options.property ) )
						.replace( '%QUERY%', encodeURIComponent( term ) );

				$.ajax( {
					url: url,
					dataType: 'json',
					xhrFields: {
						withCredentials: true
					},
					timeout: config.timeout || 10000
				} ).then(
					function ( response ) {
						if ( response.error ) {
							// Question: Could we throw new Error instead here?
							deferred.reject( response.error.info );
							return;
						}

						deferred.resolve(
							response.results,
							term,
							response.continuation
						);
					},
					function ( jqXHR, textStatus ) {
						deferred.reject( textStatus );
					}
				);

				return deferred.promise();
			};
		},

		/**
		 * @inheritdoc
		 * @protected
		 */
		_getSuggestions: function () {
			var self = this;

			return $.ui.suggester.prototype._getSuggestions.apply( this, arguments )
				.then( function ( suggestions, searchTerm, nextSuggestionOffset ) {
					var deferred = $.Deferred();

					if ( self._cache.term === searchTerm && self._cache.nextSuggestionOffset ) {
						self._cache.suggestions = self._cache.suggestions.concat( suggestions );
						self._cache.nextSuggestionOffset = nextSuggestionOffset;
					} else {
						self._cache = {
							term: searchTerm,
							suggestions: suggestions,
							nextSuggestionOffset: nextSuggestionOffset
						};
					}

					deferred.resolve( self._cache.suggestions, searchTerm );
					return deferred.promise();
				} );
		},

		/**
		 * Generate the menu element for a suggester entity.
		 *
		 * @param {Object} result
		 * @protected
		 * @return {jQuery}
		 */
		_createLabelFromSuggestion: function ( result ) {

			var $label = $( '<span>', { class: 'ui-entityselector-label' } ).text( result.label || result.id ),
				$description = $( '<span>', { class: 'ui-entityselector-description' } );

			if ( result.aliases ) {
				$label.append(
					$( '<span>', { class: 'ui-entityselector-aliases' } ).text( ' (' + result.aliases + ')' )
				);
			}

			if ( result.description && result.description.length ) {
				$description.text( result.description );
			}

			return $( '<span>', { class: 'ui-entityselector-itemcontent' } )
				.append( $label )
				.append( $description );
		},

		/**
		 * Generate URL to the external item using the formatter URL (P1630) template.
		 *
		 * @param {string} identifier
		 * @protected
		 * @return {string}
		 */
		_linkTo: function ( identifier ) {
			if ( this.options.urlFormat ) {
				return this.options.urlFormat.replace( '$1', identifier );
			}
		},

		/**
		 * @inheritdoc
		 * @protected
		 */
		_createMenuItemFromSuggestion: function ( suggestion ) {
			var $label = this._createLabelFromSuggestion( suggestion ),
				value = suggestion.id,
				link = this._linkTo( suggestion.id );

			return new $.ui.ooMenu.Item( $label, value, link );
		},

		/**
		 * @inheritdoc
		 * @protected
		 */
		_initMenu: function ( ooMenu ) {
			var self = this,
				customItems = ooMenu.option( 'customItems' );

			$.ui.suggester.prototype._initMenu.apply( this, arguments );

			$( this.options.menu )
				.on( 'selected.suggester', function ( event, item ) {
					console.log( '[externalitemsuggester] Select item:', item.getValue() );

					// The superclass (jquery.ui.suggester) already updates the textfield,
					// but does not update the view model, so we need to somehow notify the
					// view about the change (Otherwise, the correct value will not be saved
					// when the statement is saved).
					// Solution: By triggering the 'eachchange' event, the expert in
					// StringValue.js will notify the view about the change.
					self.element.trigger( 'eachchange' );
				} );

			// TODO: Add 'More..' element
			//
			// customItems.unshift( new $.ui.ooMenu.CustomItem(
			//     self.options.messages.more,
			//     function () {
			//         return self._cache.term === self._term && self._cache.nextSuggestionOffset;
			//     },
			//     function () {
			//         self.search( $.Event( 'programmatic' ) );
			//     },
			//     'ui-entityselector-more'
			// ) );

			// Add "Not found" menu item
			customItems.unshift( new $.ui.ooMenu.CustomItem(
				self.options.messages.notfound,
				function () {
					return self._cache.suggestions && !self._cache.suggestions.length && self.element.val().trim() !== '';
				},
				null,
				'ui-entityselector-notfound'
			) );

			ooMenu._evaluateVisibility = function ( customItem ) {
				if ( customItem instanceof $.ui.ooMenu.CustomItem ) {
					return customItem.getVisibility( ooMenu );
				} else {
					return ooMenu._evaluateVisibility.apply( this, arguments );
				}
			};

			ooMenu.option( 'customItems', customItems );

			return ooMenu;
		}

	} );

	// --------------------------------------------------------------------------------------------

	var FormatterUrlService = ( function () {
		var cache = {};

		/**
		 * Given a Wikidata property, find the formatter URL (P1630).
		 *
		 * @param {string} entityId The property ID (example: 'P1018')
		 * @return {Object} Promise that returns the formatter URL.
		 */
		function get( entityId ) {
			// First check if we have it in our local cache
			if ( cache[ entityId ] ) {

				// Question: Can we start using ES6 promises, or should we stick we jQuery ones?
				var deferred = $.Deferred();
				deferred.resolve( cache[ entityId ] );

				return deferred.promise();
			}

			// If not, then look it up using the api and add it to cache
			return repoApi.getEntities( entityId, 'claims' ).then( function ( data ) {
				var url = data.entities[ entityId ].claims.P1630[ 0 ].mainsnak.datavalue.value;
				cache[ entityId ] = url;
				return url;
			} );
		}

		return {
			get: get
		};
	}() );

	// --------------------------------------------------------------------------------------------

	/**
	 * Add the externalitemsuggester widget to a text input field.
	 *
	 * @param {jQuery} $input jQuery selector for the text input field
	 * @param {string} property The property ID
	 */
	function createSuggester( $input, property ) {
		FormatterUrlService.get( property ).then( function ( url ) {
			$input.externalitemsuggester( {
				property: property,
				urlFormat: url,
				messages: {
					more: mw.msg( 'wikibase-entityselector-more' ),
					notfound: mw.msg( 'wikibase-entityselector-notfound' )
				}
			} );

			// Remove focus from the original text field
			$input.off( 'blur' );
		} );
	}

	/**
	 * Initialize the gadget.
	 */
	function initGadget() {
		var properties = {};

		/**
		 * When a property has been selected from the dropdown menu for new statements,
		 * we take note of which property was selected.
		 *
		 * @param {jQuery} event The event
		 */
		$( '.wikibase-statementgrouplistview', this ).on( 'entityselectorselected',
			function onEntitySelectorSelected( event, propertyId ) {
				var $statement = $( event.target ).closest( '.wikibase-statementgroupview' );
				properties[ $statement ] = propertyId;
			}
		);

		$( '.wikibase-statementgrouplistview', this ).on( 'valueviewafterstartediting',
			/**
			 * When a new or existing statement of type StringValue is being edited, attach the
			 * autocomplete widget. New statements will not have an 'id' attribute, so we need to
			 * use the value we gathered from the `entityselectorselected` event above.
			 *
			 * @param {jQuery} event The event
			 */
			function onValueViewAfterStartEditing( event ) {
				var $statement = $( event.target ).closest( '.wikibase-statementgroupview' ),
					$input = $statement.find( '.valueview-expert-StringValue-input' ),
					property = properties[ $statement ] || $statement.attr( 'id' );

				if ( !$input.length ) {
					return; // Not a StringValue statement
				}

				if ( !~config.supportedProperties.indexOf( property ) ) {
					return; // Not a supported property
				}

				// Add the autocomplete widget
				createSuggester( $input, property );
			}
		);
	}
	
	// Depends on: mediawiki.api, wikibase.ui.entityViewInit
	initGadget();

}( mediaWiki, wikibase, jQuery ) );

 });