Merge "Added release notes for 'ContentHandler::runLegacyHooks' removal"
[mediawiki.git] / resources / src / mediawiki.widgets / mw.widgets.TitleWidget.js
blob3a4581d5dc036efe1bf7f3c37522c984c186138d
1 /*!
2  * MediaWiki Widgets - TitleWidget class.
3  *
4  * @copyright 2011-2015 MediaWiki Widgets Team and others; see AUTHORS.txt
5  * @license The MIT License (MIT); see LICENSE.txt
6  */
7 ( function ( $, mw ) {
9         /**
10          * Mixin for title widgets
11          *
12          * @class
13          * @abstract
14          *
15          * @constructor
16          * @param {Object} [config] Configuration options
17          * @cfg {number} [limit=10] Number of results to show
18          * @cfg {number} [namespace] Namespace to prepend to queries
19          * @cfg {number} [maxLength=255] Maximum query length
20          * @cfg {boolean} [relative=true] If a namespace is set, display titles relative to it
21          * @cfg {boolean} [suggestions=true] Display search suggestions
22          * @cfg {boolean} [showRedirectTargets=true] Show the targets of redirects
23          * @cfg {boolean} [showImages] Show page images
24          * @cfg {boolean} [showDescriptions] Show page descriptions
25          * @cfg {boolean} [excludeCurrentPage] Exclude the current page from suggestions
26          * @cfg {boolean} [validateTitle=true] Whether the input must be a valid title (if set to true,
27          *  the widget will marks itself red for invalid inputs, including an empty query).
28          * @cfg {Object} [cache] Result cache which implements a 'set' method, taking keyed values as an argument
29          * @cfg {mw.Api} [api] API object to use, creates a default mw.Api instance if not specified
30          */
31         mw.widgets.TitleWidget = function MwWidgetsTitleWidget( config ) {
32                 // Config initialization
33                 config = $.extend( {
34                         maxLength: 255,
35                         limit: 10
36                 }, config );
38                 // Properties
39                 this.limit = config.limit;
40                 this.maxLength = config.maxLength;
41                 this.namespace = config.namespace !== undefined ? config.namespace : null;
42                 this.relative = config.relative !== undefined ? config.relative : true;
43                 this.suggestions = config.suggestions !== undefined ? config.suggestions : true;
44                 this.showRedirectTargets = config.showRedirectTargets !== false;
45                 this.showImages = !!config.showImages;
46                 this.showDescriptions = !!config.showDescriptions;
47                 this.excludeCurrentPage = !!config.excludeCurrentPage;
48                 this.validateTitle = config.validateTitle !== undefined ? config.validateTitle : true;
49                 this.cache = config.cache;
50                 this.api = config.api || new mw.Api();
52                 // Initialization
53                 this.$element.addClass( 'mw-widget-titleWidget' );
54         };
56         /* Setup */
58         OO.initClass( mw.widgets.TitleWidget );
60         /* Static properties */
62         mw.widgets.TitleWidget.static.interwikiPrefixesPromiseCache = {};
64         /* Methods */
66         /**
67          * Get the current value of the search query
68          *
69          * @abstract
70          * @return {string} Search query
71          */
72         mw.widgets.TitleWidget.prototype.getQueryValue = null;
74         /**
75          * Get the namespace to prepend to titles in suggestions, if any.
76          *
77          * @return {number|null} Namespace number
78          */
79         mw.widgets.TitleWidget.prototype.getNamespace = function () {
80                 return this.namespace;
81         };
83         /**
84          * Set the namespace to prepend to titles in suggestions, if any.
85          *
86          * @param {number|null} namespace Namespace number
87          */
88         mw.widgets.TitleWidget.prototype.setNamespace = function ( namespace ) {
89                 this.namespace = namespace;
90         };
92         mw.widgets.TitleWidget.prototype.getInterwikiPrefixesPromise = function () {
93                 var api = this.getApi(),
94                         cache = this.constructor.static.interwikiPrefixesPromiseCache,
95                         key = api.defaults.ajax.url;
96                 if ( !cache.hasOwnProperty( key ) ) {
97                         cache[ key ] = api.get( {
98                                 action: 'query',
99                                 meta: 'siteinfo',
100                                 siprop: 'interwikimap',
101                                 // Cache client-side for a day since this info is mostly static
102                                 maxage: 60 * 60 * 24,
103                                 smaxage: 60 * 60 * 24,
104                                 // Workaround T97096 by setting uselang=content
105                                 uselang: 'content'
106                         } ).then( function ( data ) {
107                                 return $.map( data.query.interwikimap, function ( interwiki ) {
108                                         return interwiki.prefix;
109                                 } );
110                         } );
111                 }
112                 return cache[ key ];
113         };
115         /**
116          * Get a promise which resolves with an API repsonse for suggested
117          * links for the current query.
118          *
119          * @return {jQuery.Promise} Suggestions promise
120          */
121         mw.widgets.TitleWidget.prototype.getSuggestionsPromise = function () {
122                 var req,
123                         api = this.getApi(),
124                         query = this.getQueryValue(),
125                         widget = this,
126                         promiseAbortObject = { abort: function () {
127                                 // Do nothing. This is just so OOUI doesn't break due to abort being undefined.
128                         } };
130                 if ( mw.Title.newFromText( query ) ) {
131                         return this.getInterwikiPrefixesPromise().then( function ( interwikiPrefixes ) {
132                                 var params,
133                                         interwiki = query.substring( 0, query.indexOf( ':' ) );
134                                 if (
135                                         interwiki && interwiki !== '' &&
136                                         interwikiPrefixes.indexOf( interwiki ) !== -1
137                                 ) {
138                                         return $.Deferred().resolve( { query: {
139                                                 pages: [ {
140                                                         title: query
141                                                 } ]
142                                         } } ).promise( promiseAbortObject );
143                                 } else {
144                                         params = {
145                                                 action: 'query',
146                                                 prop: [ 'info', 'pageprops' ],
147                                                 generator: 'prefixsearch',
148                                                 gpssearch: query,
149                                                 gpsnamespace: widget.namespace !== null ? widget.namespace : undefined,
150                                                 gpslimit: widget.limit,
151                                                 ppprop: 'disambiguation'
152                                         };
153                                         if ( widget.showRedirectTargets ) {
154                                                 params.redirects = true;
155                                         }
156                                         if ( widget.showImages ) {
157                                                 params.prop.push( 'pageimages' );
158                                                 params.pithumbsize = 80;
159                                                 params.pilimit = widget.limit;
160                                         }
161                                         if ( widget.showDescriptions ) {
162                                                 params.prop.push( 'pageterms' );
163                                                 params.wbptterms = 'description';
164                                         }
165                                         req = api.get( params );
166                                         promiseAbortObject.abort = req.abort.bind( req ); // TODO ew
167                                         return req.then( function ( ret ) {
168                                                 if ( ret.query === undefined ) {
169                                                         ret = api.get( { action: 'query', titles: query } );
170                                                         promiseAbortObject.abort = ret.abort.bind( ret );
171                                                 }
172                                                 return ret;
173                                         } );
174                                 }
175                         } ).promise( promiseAbortObject );
176                 } else {
177                         // Don't send invalid titles to the API.
178                         // Just pretend it returned nothing so we can show the 'invalid title' section
179                         return $.Deferred().resolve( {} ).promise( promiseAbortObject );
180                 }
181         };
183         /**
184          * Get the API object for title requests
185          *
186          * @return {mw.Api} MediaWiki API
187          */
188         mw.widgets.TitleWidget.prototype.getApi = function () {
189                 return this.api;
190         };
192         /**
193          * Get option widgets from the server response
194          *
195          * @param {Object} data Query result
196          * @return {OO.ui.OptionWidget[]} Menu items
197          */
198         mw.widgets.TitleWidget.prototype.getOptionsFromData = function ( data ) {
199                 var i, len, index, pageExists, pageExistsExact, suggestionPage, page, redirect, redirects,
200                         currentPageName = new mw.Title( mw.config.get( 'wgRelevantPageName' ) ).getPrefixedText(),
201                         items = [],
202                         titles = [],
203                         titleObj = mw.Title.newFromText( this.getQueryValue() ),
204                         redirectsTo = {},
205                         pageData = {};
207                 if ( data.redirects ) {
208                         for ( i = 0, len = data.redirects.length; i < len; i++ ) {
209                                 redirect = data.redirects[ i ];
210                                 redirectsTo[ redirect.to ] = redirectsTo[ redirect.to ] || [];
211                                 redirectsTo[ redirect.to ].push( redirect.from );
212                         }
213                 }
215                 for ( index in data.pages ) {
216                         suggestionPage = data.pages[ index ];
217                         // When excludeCurrentPage is set, don't list the current page unless the user has type the full title
218                         if ( this.excludeCurrentPage && suggestionPage.title === currentPageName && suggestionPage.title !== titleObj.getPrefixedText() ) {
219                                 continue;
220                         }
221                         pageData[ suggestionPage.title ] = {
222                                 known: suggestionPage.known !== undefined,
223                                 missing: suggestionPage.missing !== undefined,
224                                 redirect: suggestionPage.redirect !== undefined,
225                                 disambiguation: OO.getProp( suggestionPage, 'pageprops', 'disambiguation' ) !== undefined,
226                                 imageUrl: OO.getProp( suggestionPage, 'thumbnail', 'source' ),
227                                 description: OO.getProp( suggestionPage, 'terms', 'description' ),
228                                 // Sort index
229                                 index: suggestionPage.index
230                         };
232                         // Throw away pages from wrong namespaces. This can happen when 'showRedirectTargets' is true
233                         // and we encounter a cross-namespace redirect.
234                         if ( this.namespace === null || this.namespace === suggestionPage.ns ) {
235                                 titles.push( suggestionPage.title );
236                         }
238                         redirects = redirectsTo[ suggestionPage.title ] || [];
239                         for ( i = 0, len = redirects.length; i < len; i++ ) {
240                                 pageData[ redirects[ i ] ] = {
241                                         missing: false,
242                                         known: true,
243                                         redirect: true,
244                                         disambiguation: false,
245                                         description: mw.msg( 'mw-widgets-titleinput-description-redirect', suggestionPage.title ),
246                                         // Sort index, just below its target
247                                         index: suggestionPage.index + 0.5
248                                 };
249                                 titles.push( redirects[ i ] );
250                         }
251                 }
253                 titles.sort( function ( a, b ) {
254                         return pageData[ a ].index - pageData[ b ].index;
255                 } );
257                 // If not found, run value through mw.Title to avoid treating a match as a
258                 // mismatch where normalisation would make them matching (bug 48476)
260                 pageExistsExact = (
261                         Object.prototype.hasOwnProperty.call( pageData, this.getQueryValue() ) &&
262                         (
263                                 !pageData[ this.getQueryValue() ].missing ||
264                                 pageData[ this.getQueryValue() ].known
265                         )
266                 );
267                 pageExists = pageExistsExact || (
268                         titleObj &&
269                         Object.prototype.hasOwnProperty.call( pageData, titleObj.getPrefixedText() ) &&
270                         (
271                                 !pageData[ titleObj.getPrefixedText() ].missing ||
272                                 pageData[ titleObj.getPrefixedText() ].known
273                         )
274                 );
276                 if ( this.cache ) {
277                         this.cache.set( pageData );
278                 }
280                 // Offer the exact text as a suggestion if the page exists
281                 if ( pageExists && !pageExistsExact ) {
282                         titles.unshift( this.getQueryValue() );
283                 }
285                 for ( i = 0, len = titles.length; i < len; i++ ) {
286                         page = pageData[ titles[ i ] ] || {};
287                         items.push( new mw.widgets.TitleOptionWidget( this.getOptionWidgetData( titles[ i ], page ) ) );
288                 }
290                 return items;
291         };
293         /**
294          * Get menu option widget data from the title and page data
295          *
296          * @param {string} title Title object
297          * @param {Object} data Page data
298          * @return {Object} Data for option widget
299          */
300         mw.widgets.TitleWidget.prototype.getOptionWidgetData = function ( title, data ) {
301                 var mwTitle = new mw.Title( title ),
302                         description = data.description;
303                 if ( data.missing && !description ) {
304                         description = mw.msg( 'mw-widgets-titleinput-description-new-page' );
305                 }
306                 return {
307                         data: this.namespace !== null && this.relative ?
308                                 mwTitle.getRelativeText( this.namespace ) :
309                                 title,
310                         url: mwTitle.getUrl(),
311                         imageUrl: this.showImages ? data.imageUrl : null,
312                         description: this.showDescriptions ? description : null,
313                         missing: data.missing,
314                         redirect: data.redirect,
315                         disambiguation: data.disambiguation,
316                         query: this.getQueryValue()
317                 };
318         };
320         /**
321          * Get title object corresponding to given value, or #getQueryValue if not given.
322          *
323          * @param {string} [value] Value to get a title for
324          * @return {mw.Title|null} Title object, or null if value is invalid
325          */
326         mw.widgets.TitleWidget.prototype.getTitle = function ( value ) {
327                 var title = value !== undefined ? value : this.getQueryValue(),
328                         // mw.Title doesn't handle null well
329                         titleObj = mw.Title.newFromText( title, this.namespace !== null ? this.namespace : undefined );
331                 return titleObj;
332         };
334         /**
335          * Check if the query is valid
336          *
337          * @return {boolean} The query is valid
338          */
339         mw.widgets.TitleWidget.prototype.isQueryValid = function () {
340                 return this.validateTitle ? !!this.getTitle() : true;
341         };
343 }( jQuery, mediaWiki ) );