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