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