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