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