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