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