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