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