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