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