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