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