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