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