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