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