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