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