RCFilters: Add range group filters - limit, days and hours
[lhc/web/wiklou.git] / resources / src / mediawiki.rcfilters / mw.rcfilters.Controller.js
1 ( function ( mw, $ ) {
2 /* eslint no-underscore-dangle: "off" */
3 /**
4 * Controller for the filters in Recent Changes
5 *
6 * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model
7 * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model
8 * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model
9 */
10 mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel ) {
11 this.filtersModel = filtersModel;
12 this.changesListModel = changesListModel;
13 this.savedQueriesModel = savedQueriesModel;
14 this.requestCounter = 0;
15 this.baseFilterState = {};
16 this.uriProcessor = null;
17 this.initializing = false;
18 };
19
20 /* Initialization */
21 OO.initClass( mw.rcfilters.Controller );
22
23 /**
24 * Initialize the filter and parameter states
25 *
26 * @param {Array} filterStructure Filter definition and structure for the model
27 * @param {Object} [namespaceStructure] Namespace definition
28 * @param {Object} [tagList] Tag definition
29 */
30 mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList ) {
31 var parsedSavedQueries,
32 views = {},
33 items = [],
34 uri = new mw.Uri(),
35 $changesList = $( '.mw-changeslist' ).first().contents(),
36 createFilterDataFromNumber = function ( num, convertedNumForLabel ) {
37 return {
38 name: String( num ),
39 label: mw.language.convertNumber( convertedNumForLabel )
40 };
41 };
42
43 // Prepare views
44 if ( namespaceStructure ) {
45 items = [];
46 $.each( namespaceStructure, function ( namespaceID, label ) {
47 // Build and clean up the individual namespace items definition
48 items.push( {
49 name: namespaceID,
50 label: label || mw.msg( 'blanknamespace' ),
51 description: '',
52 identifiers: [
53 ( namespaceID < 0 || namespaceID % 2 === 0 ) ?
54 'subject' : 'talk'
55 ],
56 cssClass: 'mw-changeslist-ns-' + namespaceID
57 } );
58 } );
59
60 views.namespaces = {
61 title: mw.msg( 'namespaces' ),
62 trigger: ':',
63 groups: [ {
64 // Group definition (single group)
65 name: 'namespace', // parameter name is singular
66 type: 'string_options',
67 title: mw.msg( 'namespaces' ),
68 labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' },
69 separator: ';',
70 fullCoverage: true,
71 filters: items
72 } ]
73 };
74 }
75 if ( tagList ) {
76 views.tags = {
77 title: mw.msg( 'rcfilters-view-tags' ),
78 trigger: '#',
79 groups: [ {
80 // Group definition (single group)
81 name: 'tagfilter', // Parameter name
82 type: 'string_options',
83 title: 'rcfilters-view-tags', // Message key
84 labelPrefixKey: 'rcfilters-tag-prefix-tags',
85 separator: '|',
86 fullCoverage: false,
87 filters: tagList
88 } ]
89 };
90 }
91
92 // Add parameter range operations
93 views.range = {
94 groups: [
95 {
96 name: 'limit',
97 type: 'single_option',
98 title: '', // Because it's a hidden group, this title actually appears nowhere
99 hidden: true,
100 allowArbitrary: true,
101 validate: $.isNumeric,
102 sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
103 'default': '50',
104 filters: [ 50, 100, 250, 500 ].map( function ( num ) {
105 return createFilterDataFromNumber( num, num );
106 } )
107 },
108 {
109 name: 'days',
110 type: 'single_option',
111 title: '', // Because it's a hidden group, this title actually appears nowhere
112 hidden: true,
113 allowArbitrary: true,
114 validate: $.isNumeric,
115 sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); },
116 'default': '7',
117 filters: [
118 // Hours (1, 2, 6, 12)
119 0.04166, 0.0833, 0.25, 0.5,
120 // Days
121 1, 3, 7, 14, 30
122 ].map( function ( num ) {
123 return createFilterDataFromNumber(
124 num,
125 // Convert fractions of days to number of hours for the labels
126 num < 1 ? Math.round( num * 24 ) : num
127 );
128 } )
129 }
130 ]
131 };
132
133 // Before we do anything, we need to see if we require another item in the
134 // groups that have 'AllowArbitrary'. For the moment, those are only single_option
135 // groups; if we ever expand it, this might need further generalization:
136 $.each( views, function ( viewName, viewData ) {
137 viewData.groups.forEach( function ( groupData ) {
138 // This is only true for single_option and string_options
139 // We assume these are the only groups that will allow for
140 // arbitrary, since it doesn't make any sense for the other
141 // groups.
142 var uriValue = uri.query[ groupData.name ];
143
144 if (
145 // If the group allows for arbitrary data
146 groupData.allowArbitrary &&
147 // and it is single_option (or string_options, but we
148 // don't have cases of those yet, nor do we plan to)
149 groupData.type === 'single_option' &&
150 // and if there is a valid value in the URI already
151 uri.query[ groupData.name ] !== undefined &&
152 // and, if there is a validate method and it passes on
153 // the data
154 ( !groupData.validate || groupData.validate( uri.query[ groupData.name ] ) ) &&
155 // but if that value isn't already in the definition
156 groupData.filters
157 .map( function ( filterData ) {
158 return filterData.name;
159 } )
160 .indexOf( uri.query[ groupData.name ] ) === -1
161 ) {
162 // Add the filter information
163 if ( groupData.name === 'days' ) {
164 // Specific fix for hours/days which go by the same param
165 groupData.filters.push( createFilterDataFromNumber(
166 uriValue,
167 // In this case we don't want to round because it can be arbitrary
168 // weird numbers but we want to round to 2 decimal digits
169 Number( uriValue ) < 1 ?
170 ( Number( uriValue ) * 24 ).toFixed( 2 ) :
171 Number( uriValue )
172 ) );
173 } else {
174 groupData.filters.push( createFilterDataFromNumber( uriValue, uriValue ) );
175 }
176
177 // If there's a sort function set up, re-sort the values
178 if ( groupData.sortFunc ) {
179 groupData.filters.sort( groupData.sortFunc );
180 }
181 }
182 } );
183 } );
184
185 // Initialize the model
186 this.filtersModel.initializeFilters( filterStructure, views );
187
188 this._buildBaseFilterState();
189
190 this.uriProcessor = new mw.rcfilters.UriProcessor(
191 this.filtersModel
192 );
193
194 try {
195 parsedSavedQueries = JSON.parse( mw.user.options.get( 'rcfilters-saved-queries' ) || '{}' );
196 } catch ( err ) {
197 parsedSavedQueries = {};
198 }
199
200 // The queries are saved in a minimized state, so we need
201 // to send over the base state so the saved queries model
202 // can normalize them per each query item
203 this.savedQueriesModel.initialize(
204 parsedSavedQueries,
205 this._getBaseFilterState()
206 );
207
208 // Check whether we need to load defaults.
209 // We do this by checking whether the current URI query
210 // contains any parameters recognized by the system.
211 // If it does, we load the given state.
212 // If it doesn't, we have no values at all, and we assume
213 // the user loads the base-page and we load defaults.
214 // Defaults should only be applied on load (if necessary)
215 // or on request
216 this.initializing = true;
217 if (
218 this.savedQueriesModel.getDefault() &&
219 !this.uriProcessor.doesQueryContainRecognizedParams( uri.query )
220 ) {
221 // We have defaults from a saved query.
222 // We will load them straight-forward (as if
223 // they were clicked in the menu) so we trigger
224 // a full ajax request and change of URL
225 this.applySavedQuery( this.savedQueriesModel.getDefault() );
226 } else {
227 // There are either recognized parameters in the URL
228 // or there are none, but there is also no default
229 // saved query (so defaults are from the backend)
230 // We want to update the state but not fetch results
231 // again
232 this.updateStateFromUrl( false );
233
234 // Update the changes list with the existing data
235 // so it gets processed
236 this.changesListModel.update(
237 $changesList.length ? $changesList : 'NO_RESULTS',
238 $( 'fieldset.rcoptions' ).first()
239 );
240 }
241
242 this.initializing = false;
243 this.switchView( 'default' );
244 };
245
246 /**
247 * Switch the view of the filters model
248 *
249 * @param {string} view Requested view
250 */
251 mw.rcfilters.Controller.prototype.switchView = function ( view ) {
252 this.filtersModel.switchView( view );
253 };
254
255 /**
256 * Reset to default filters
257 */
258 mw.rcfilters.Controller.prototype.resetToDefaults = function () {
259 this.uriProcessor.updateModelBasedOnQuery( this._getDefaultParams() );
260 this.updateChangesList();
261 };
262
263 /**
264 * Empty all selected filters
265 */
266 mw.rcfilters.Controller.prototype.emptyFilters = function () {
267 var highlightedFilterNames = this.filtersModel
268 .getHighlightedItems()
269 .map( function ( filterItem ) { return { name: filterItem.getName() }; } );
270
271 this.filtersModel.emptyAllFilters();
272 this.filtersModel.clearAllHighlightColors();
273 // Check all filter interactions
274 this.filtersModel.reassessFilterInteractions();
275
276 this.updateChangesList();
277
278 if ( highlightedFilterNames ) {
279 this._trackHighlight( 'clearAll', highlightedFilterNames );
280 }
281 };
282
283 /**
284 * Update the selected state of a filter
285 *
286 * @param {string} filterName Filter name
287 * @param {boolean} [isSelected] Filter selected state
288 */
289 mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) {
290 var filterItem = this.filtersModel.getItemByName( filterName );
291
292 if ( !filterItem ) {
293 // If no filter was found, break
294 return;
295 }
296
297 isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected;
298
299 if ( filterItem.isSelected() !== isSelected ) {
300 this.filtersModel.toggleFilterSelected( filterName, isSelected );
301
302 this.updateChangesList();
303
304 // Check filter interactions
305 this.filtersModel.reassessFilterInteractions( filterItem );
306 }
307 };
308
309 /**
310 * Clear both highlight and selection of a filter
311 *
312 * @param {string} filterName Name of the filter item
313 */
314 mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) {
315 var filterItem = this.filtersModel.getItemByName( filterName ),
316 isHighlighted = filterItem.isHighlighted();
317
318 if ( filterItem.isSelected() || isHighlighted ) {
319 this.filtersModel.clearHighlightColor( filterName );
320 this.filtersModel.toggleFilterSelected( filterName, false );
321 this.updateChangesList();
322 this.filtersModel.reassessFilterInteractions( filterItem );
323 }
324
325 if ( isHighlighted ) {
326 this._trackHighlight( 'clear', filterName );
327 }
328 };
329
330 /**
331 * Toggle the highlight feature on and off
332 */
333 mw.rcfilters.Controller.prototype.toggleHighlight = function () {
334 this.filtersModel.toggleHighlight();
335 this._updateURL();
336
337 if ( this.filtersModel.isHighlightEnabled() ) {
338 mw.hook( 'RcFilters.highlight.enable' ).fire();
339 }
340 };
341
342 /**
343 * Toggle the namespaces inverted feature on and off
344 */
345 mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () {
346 this.filtersModel.toggleInvertedNamespaces();
347 this.updateChangesList();
348 };
349
350 /**
351 * Set the highlight color for a filter item
352 *
353 * @param {string} filterName Name of the filter item
354 * @param {string} color Selected color
355 */
356 mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) {
357 this.filtersModel.setHighlightColor( filterName, color );
358 this._updateURL();
359 this._trackHighlight( 'set', { name: filterName, color: color } );
360 };
361
362 /**
363 * Clear highlight for a filter item
364 *
365 * @param {string} filterName Name of the filter item
366 */
367 mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) {
368 this.filtersModel.clearHighlightColor( filterName );
369 this._updateURL();
370 this._trackHighlight( 'clear', filterName );
371 };
372
373 /**
374 * Enable or disable live updates.
375 * @param {boolean} enable True to enable, false to disable
376 */
377 mw.rcfilters.Controller.prototype.toggleLiveUpdate = function ( enable ) {
378 if ( enable && !this.liveUpdateTimeout ) {
379 this._scheduleLiveUpdate();
380 } else if ( !enable && this.liveUpdateTimeout ) {
381 clearTimeout( this.liveUpdateTimeout );
382 this.liveUpdateTimeout = null;
383 }
384 };
385
386 /**
387 * Set a timeout for the next live update.
388 * @private
389 */
390 mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () {
391 this.liveUpdateTimeout = setTimeout( this._doLiveUpdate.bind( this ), 3000 );
392 };
393
394 /**
395 * Perform a live update.
396 * @private
397 */
398 mw.rcfilters.Controller.prototype._doLiveUpdate = function () {
399 var controller = this;
400 this.updateChangesList( {}, true )
401 .always( function () {
402 if ( controller.liveUpdateTimeout ) {
403 // Live update was not disabled in the meantime
404 controller._scheduleLiveUpdate();
405 }
406 } );
407 };
408
409 /**
410 * Save the current model state as a saved query
411 *
412 * @param {string} [label] Label of the saved query
413 */
414 mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label ) {
415 var highlightedItems = {},
416 highlightEnabled = this.filtersModel.isHighlightEnabled();
417
418 // Prepare highlights
419 this.filtersModel.getHighlightedItems().forEach( function ( item ) {
420 highlightedItems[ item.getName() ] = highlightEnabled ?
421 item.getHighlightColor() : null;
422 } );
423 // These are filter states; highlight is stored as boolean
424 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
425
426 // Add item
427 this.savedQueriesModel.addNewQuery(
428 label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ),
429 {
430 filters: this.filtersModel.getSelectedState(),
431 highlights: highlightedItems,
432 invert: this.filtersModel.areNamespacesInverted()
433 }
434 );
435
436 // Save item
437 this._saveSavedQueries();
438 };
439
440 /**
441 * Remove a saved query
442 *
443 * @param {string} queryID Query id
444 */
445 mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) {
446 this.savedQueriesModel.removeQuery( queryID );
447
448 this._saveSavedQueries();
449 };
450
451 /**
452 * Rename a saved query
453 *
454 * @param {string} queryID Query id
455 * @param {string} newLabel New label for the query
456 */
457 mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) {
458 var queryItem = this.savedQueriesModel.getItemByID( queryID );
459
460 if ( queryItem ) {
461 queryItem.updateLabel( newLabel );
462 }
463 this._saveSavedQueries();
464 };
465
466 /**
467 * Set a saved query as default
468 *
469 * @param {string} queryID Query Id. If null is given, default
470 * query is reset.
471 */
472 mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
473 this.savedQueriesModel.setDefault( queryID );
474 this._saveSavedQueries();
475 };
476
477 /**
478 * Load a saved query
479 *
480 * @param {string} queryID Query id
481 */
482 mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) {
483 var data, highlights,
484 queryItem = this.savedQueriesModel.getItemByID( queryID );
485
486 if ( queryItem ) {
487 data = queryItem.getData();
488 highlights = data.highlights;
489
490 // Backwards compatibility; initial version mispelled 'highlight' with 'highlights'
491 highlights.highlight = highlights.highlights || highlights.highlight;
492
493 // Update model state from filters
494 this.filtersModel.toggleFiltersSelected( data.filters );
495
496 // Update namespace inverted property
497 this.filtersModel.toggleInvertedNamespaces( !!Number( data.invert ) );
498
499 // Update highlight state
500 this.filtersModel.toggleHighlight( !!Number( highlights.highlight ) );
501 this.filtersModel.getItems().forEach( function ( filterItem ) {
502 var color = highlights[ filterItem.getName() ];
503 if ( color ) {
504 filterItem.setHighlightColor( color );
505 } else {
506 filterItem.clearHighlightColor();
507 }
508 } );
509
510 // Check all filter interactions
511 this.filtersModel.reassessFilterInteractions();
512
513 this.updateChangesList();
514 }
515 };
516
517 /**
518 * Check whether the current filter and highlight state exists
519 * in the saved queries model.
520 *
521 * @return {boolean} Query exists
522 */
523 mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () {
524 var highlightedItems = {};
525
526 // Prepare highlights of the current query
527 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
528 highlightedItems[ item.getName() ] = item.getHighlightColor();
529 } );
530 highlightedItems.highlight = this.filtersModel.isHighlightEnabled();
531
532 return this.savedQueriesModel.findMatchingQuery(
533 {
534 filters: this.filtersModel.getSelectedState(),
535 highlights: highlightedItems,
536 invert: this.filtersModel.areNamespacesInverted()
537 }
538 );
539 };
540
541 /**
542 * Get an object representing the base state of parameters
543 * and highlights.
544 *
545 * This is meant to make sure that the saved queries that are
546 * in memory are always the same structure as what we would get
547 * by calling the current model's "getSelectedState" and by checking
548 * highlight items.
549 *
550 * In cases where a user saved a query when the system had a certain
551 * set of filters, and then a filter was added to the system, we want
552 * to make sure that the stored queries can still be comparable to
553 * the current state, which means that we need the base state for
554 * two operations:
555 *
556 * - Saved queries are stored in "minimal" view (only changed filters
557 * are stored); When we initialize the system, we merge each minimal
558 * query with the base state (using 'getNormalizedFilters') so all
559 * saved queries have the exact same structure as what we would get
560 * by checking the getSelectedState of the filter.
561 * - When we save the queries, we minimize the object to only represent
562 * whatever has actually changed, rather than store the entire
563 * object. To check what actually is different so we can store it,
564 * we need to obtain a base state to compare against, this is
565 * what #_getMinimalFilterList does
566 */
567 mw.rcfilters.Controller.prototype._buildBaseFilterState = function () {
568 var defaultParams = this.filtersModel.getDefaultParams(),
569 highlightedItems = {};
570
571 // Prepare highlights
572 this.filtersModel.getItemsSupportingHighlights().forEach( function ( item ) {
573 highlightedItems[ item.getName() ] = null;
574 } );
575 highlightedItems.highlight = false;
576
577 this.baseFilterState = {
578 filters: this.filtersModel.getFiltersFromParameters( defaultParams ),
579 highlights: highlightedItems,
580 invert: false
581 };
582 };
583
584 /**
585 * Get an object representing the base filter state of both
586 * filters and highlights. The structure is similar to what we use
587 * to store each query in the saved queries object:
588 * {
589 * filters: {
590 * filterName: (bool)
591 * },
592 * highlights: {
593 * filterName: (string|null)
594 * }
595 * }
596 *
597 * @return {Object} Object representing the base state of
598 * parameters and highlights
599 */
600 mw.rcfilters.Controller.prototype._getBaseFilterState = function () {
601 return this.baseFilterState;
602 };
603
604 /**
605 * Get an object that holds only the parameters and highlights that have
606 * values different than the base default value.
607 *
608 * This is the reverse of the normalization we do initially on loading and
609 * initializing the saved queries model.
610 *
611 * @param {Object} valuesObject Object representing the state of both
612 * filters and highlights in its normalized version, to be minimized.
613 * @return {Object} Minimal filters and highlights list
614 */
615 mw.rcfilters.Controller.prototype._getMinimalFilterList = function ( valuesObject ) {
616 var result = { filters: {}, highlights: {} },
617 baseState = this._getBaseFilterState();
618
619 // XOR results
620 $.each( valuesObject.filters, function ( name, value ) {
621 if ( baseState.filters !== undefined && baseState.filters[ name ] !== value ) {
622 result.filters[ name ] = value;
623 }
624 } );
625
626 $.each( valuesObject.highlights, function ( name, value ) {
627 if ( baseState.highlights !== undefined && baseState.highlights[ name ] !== value ) {
628 result.highlights[ name ] = value;
629 }
630 } );
631
632 return result;
633 };
634
635 /**
636 * Save the current state of the saved queries model with all
637 * query item representation in the user settings.
638 */
639 mw.rcfilters.Controller.prototype._saveSavedQueries = function () {
640 var stringified,
641 state = this.savedQueriesModel.getState(),
642 controller = this;
643
644 // Minimize before save
645 $.each( state.queries, function ( queryID, info ) {
646 state.queries[ queryID ].data = controller._getMinimalFilterList( info.data );
647 } );
648
649 // Stringify state
650 stringified = JSON.stringify( state );
651
652 if ( stringified.length > 65535 ) {
653 // Sanity check, since the preference can only hold that.
654 return;
655 }
656
657 // Save the preference
658 new mw.Api().saveOption( 'rcfilters-saved-queries', stringified );
659 // Update the preference for this session
660 mw.user.options.set( 'rcfilters-saved-queries', stringified );
661 };
662
663 /**
664 * Synchronize the URL with the current state of the filters
665 * without adding an history entry.
666 */
667 mw.rcfilters.Controller.prototype.replaceUrl = function () {
668 mw.rcfilters.UriProcessor.static.replaceState( this._getUpdatedUri() );
669 };
670
671 /**
672 * Update filter state (selection and highlighting) based
673 * on current URL values.
674 *
675 * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
676 * list based on the updated model.
677 */
678 mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) {
679 fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList;
680
681 this.uriProcessor.updateModelBasedOnQuery( new mw.Uri().query );
682
683 // Only update and fetch new results if it is requested
684 if ( fetchChangesList ) {
685 this.updateChangesList();
686 }
687 };
688
689 /**
690 * Update the list of changes and notify the model
691 *
692 * @param {Object} [params] Extra parameters to add to the API call
693 * @param {boolean} [isLiveUpdate] Don't update the URL or invalidate the changes list
694 * @return {jQuery.Promise} Promise that is resolved when the update is complete
695 */
696 mw.rcfilters.Controller.prototype.updateChangesList = function ( params, isLiveUpdate ) {
697 if ( !isLiveUpdate ) {
698 this._updateURL( params );
699 this.changesListModel.invalidate();
700 }
701 return this._fetchChangesList()
702 .then(
703 // Success
704 function ( pieces ) {
705 var $changesListContent = pieces.changes,
706 $fieldset = pieces.fieldset;
707 this.changesListModel.update( $changesListContent, $fieldset );
708 }.bind( this )
709 // Do nothing for failure
710 );
711 };
712
713 /**
714 * Get an object representing the default parameter state, whether
715 * it is from the model defaults or from the saved queries.
716 *
717 * @return {Object} Default parameters
718 */
719 mw.rcfilters.Controller.prototype._getDefaultParams = function () {
720 var data, queryHighlights,
721 savedParams = {},
722 savedHighlights = {},
723 defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
724
725 if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
726 defaultSavedQueryItem ) {
727
728 data = defaultSavedQueryItem.getData();
729
730 queryHighlights = data.highlights || {};
731 savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
732
733 // Translate highlights to parameters
734 savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
735 $.each( queryHighlights, function ( filterName, color ) {
736 if ( filterName !== 'highlights' ) {
737 savedHighlights[ filterName + '_color' ] = color;
738 }
739 } );
740
741 return $.extend( true, {}, savedParams, savedHighlights, { invert: data.invert } );
742 }
743
744 return $.extend(
745 { highlight: '0' },
746 this.filtersModel.getDefaultParams()
747 );
748 };
749
750 /**
751 * Get an object representing the default parameter state, whether
752 * it is from the model defaults or from the saved queries.
753 *
754 * @return {Object} Default parameters
755 */
756 mw.rcfilters.Controller.prototype._getDefaultParams = function () {
757 var data, queryHighlights,
758 savedParams = {},
759 savedHighlights = {},
760 defaultSavedQueryItem = this.savedQueriesModel.getItemByID( this.savedQueriesModel.getDefault() );
761
762 if ( mw.config.get( 'wgStructuredChangeFiltersEnableSaving' ) &&
763 defaultSavedQueryItem ) {
764
765 data = defaultSavedQueryItem.getData();
766
767 queryHighlights = data.highlights || {};
768 savedParams = this.filtersModel.getParametersFromFilters( data.filters || {} );
769
770 // Translate highlights to parameters
771 savedHighlights.highlight = String( Number( queryHighlights.highlight ) );
772 $.each( queryHighlights, function ( filterName, color ) {
773 if ( filterName !== 'highlights' ) {
774 savedHighlights[ filterName + '_color' ] = color;
775 }
776 } );
777
778 return $.extend( true, {}, savedParams, savedHighlights );
779 }
780
781 return this.filtersModel.getDefaultParams();
782 };
783
784 /**
785 * Update the URL of the page to reflect current filters
786 *
787 * This should not be called directly from outside the controller.
788 * If an action requires changing the URL, it should either use the
789 * highlighting actions below, or call #updateChangesList which does
790 * the uri corrections already.
791 *
792 * @param {Object} [params] Extra parameters to add to the API call
793 */
794 mw.rcfilters.Controller.prototype._updateURL = function ( params ) {
795 var currentUri = new mw.Uri(),
796 updatedUri = this._getUpdatedUri();
797
798 updatedUri.extend( params || {} );
799
800 if (
801 this.uriProcessor.getVersion( currentUri.query ) !== 2 ||
802 this.uriProcessor.isNewState( currentUri.query, updatedUri.query )
803 ) {
804 mw.rcfilters.UriProcessor.static.replaceState( updatedUri );
805 }
806 };
807
808 /**
809 * Get an updated mw.Uri object based on the model state
810 *
811 * @return {mw.Uri} Updated Uri
812 */
813 mw.rcfilters.Controller.prototype._getUpdatedUri = function () {
814 var uri = new mw.Uri();
815
816 // Minimize url
817 uri.query = this.uriProcessor.minimizeQuery(
818 $.extend(
819 true,
820 {},
821 // We want to retain unrecognized params
822 // The uri params from model will override
823 // any recognized value in the current uri
824 // query, retain unrecognized params, and
825 // the result will then be minimized
826 uri.query,
827 this.uriProcessor.getUriParametersFromModel(),
828 { urlversion: '2' }
829 )
830 );
831
832 return uri;
833 };
834
835 /**
836 * Fetch the list of changes from the server for the current filters
837 *
838 * @return {jQuery.Promise} Promise object that will resolve with the changes list
839 * or with a string denoting no results.
840 */
841 mw.rcfilters.Controller.prototype._fetchChangesList = function () {
842 var uri = this._getUpdatedUri(),
843 requestId = ++this.requestCounter,
844 latestRequest = function () {
845 return requestId === this.requestCounter;
846 }.bind( this );
847
848 return $.ajax( uri.toString(), { contentType: 'html' } )
849 .then(
850 // Success
851 function ( html ) {
852 var $parsed;
853 if ( !latestRequest() ) {
854 return $.Deferred().reject();
855 }
856
857 $parsed = $( $.parseHTML( html ) );
858
859 return {
860 // Changes list
861 changes: $parsed.find( '.mw-changeslist' ).first().contents(),
862 // Fieldset
863 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
864 };
865 },
866 // Failure
867 function ( responseObj ) {
868 var $parsed;
869
870 if ( !latestRequest() ) {
871 return $.Deferred().reject();
872 }
873
874 $parsed = $( $.parseHTML( responseObj.responseText ) );
875
876 // Force a resolve state to this promise
877 return $.Deferred().resolve( {
878 changes: 'NO_RESULTS',
879 fieldset: $parsed.find( 'fieldset.rcoptions' ).first()
880 } ).promise();
881 }
882 );
883 };
884
885 /**
886 * Track usage of highlight feature
887 *
888 * @param {string} action
889 * @param {array|object|string} filters
890 */
891 mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) {
892 filters = typeof filters === 'string' ? { name: filters } : filters;
893 filters = !Array.isArray( filters ) ? [ filters ] : filters;
894 mw.track(
895 'event.ChangesListHighlights',
896 {
897 action: action,
898 filters: filters,
899 userId: mw.user.getId()
900 }
901 );
902 };
903
904 }( mediaWiki, jQuery ) );