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