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