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