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