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