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