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