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