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