3 var byteLength
= require( 'mediawiki.String' ).byteLength
;
5 /* eslint no-underscore-dangle: "off" */
7 * Controller for the filters in Recent Changes
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
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
;
31 this.requestCounter
= {};
32 this.baseFilterState
= {};
33 this.uriProcessor
= null;
34 this.initializing
= false;
35 this.wereSavedQueriesSaved
= false;
37 this.prevLoggedItems
= [];
39 this.FILTER_CHANGE
= 'filterChange';
40 this.SHOW_NEW_CHANGES
= 'showNewChanges';
41 this.LIVE_UPDATE
= 'liveUpdate';
45 OO
.initClass( mw
.rcfilters
.Controller
);
48 * Initialize the filter and parameter states
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
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' ),
60 views
= $.extend( true, {}, conditionalViews
),
65 if ( namespaceStructure
) {
67 $.each( namespaceStructure
, function ( namespaceID
, label
) {
68 // Build and clean up the individual namespace items definition
71 label
: label
|| mw
.msg( 'blanknamespace' ),
74 ( namespaceID
< 0 || namespaceID
% 2 === 0 ) ?
77 cssClass
: 'mw-changeslist-ns-' + namespaceID
82 title
: mw
.msg( 'namespaces' ),
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' },
110 title
: mw
.msg( 'rcfilters-view-tags' ),
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',
125 // Add parameter range operations
130 type
: 'single_option',
131 title
: '', // Because it's a hidden group, this title actually appears nowhere
133 allowArbitrary
: true,
134 validate
: $.isNumeric
,
136 min
: 0, // The server normalizes negative numbers to 0 results
139 sortFunc: function ( a
, b
) { return Number( a
.name
) - Number( b
.name
); },
140 'default': mw
.user
.options
.get( this.limitPreferenceName
, displayConfig
.limitDefault
),
142 filters
: displayConfig
.limitArray
.map( function ( num
) {
143 return controller
._createFilterDataFromNumber( num
, num
);
148 type
: 'single_option',
149 title
: '', // Because it's a hidden group, this title actually appears nowhere
151 allowArbitrary
: true,
152 validate
: $.isNumeric
,
155 max
: displayConfig
.maxDays
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 ) :
163 'default': mw
.user
.options
.get( this.daysPreferenceName
, displayConfig
.daysDefault
),
166 // Hours (1, 2, 6, 12)
167 0.04166, 0.0833, 0.25, 0.5
169 ].concat( displayConfig
.daysArray
)
170 .map( function ( num
) {
171 return controller
._createFilterDataFromNumber(
173 // Convert fractions of days to number of hours for the labels
174 num
< 1 ? Math
.round( num
* 24 ) : num
186 title
: '', // Because it's a hidden group, this title actually appears nowhere
192 'default': String( mw
.user
.options
.get( 'usenewrc', 0 ) )
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
] );
210 // If the default value isn't in the group, add it
211 if ( groupData
.default !== undefined ) {
212 extraValues
.push( String( groupData
.default ) );
214 controller
.addNumberValuesToGroup( groupData
, extraValues
);
219 // Initialize the model
220 this.filtersModel
.initializeFilters( filterStructure
, views
);
222 this.uriProcessor
= new mw
.rcfilters
.UriProcessor(
224 { normalizeTarget
: this.normalizeTarget
}
227 if ( !mw
.user
.isAnon() ) {
229 parsedSavedQueries
= JSON
.parse( mw
.user
.options
.get( this.savedQueriesPreferenceName
) || '{}' );
231 parsedSavedQueries
= {};
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();
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)
251 this.initializing
= true;
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() );
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
265 this.updateStateFromUrl( false );
267 pieces
= this._extractChangesListInfo( $( '#mw-content-text' ) );
269 // Update the changes list with the existing data
270 // so it gets processed
271 this.changesListModel
.update(
274 pieces
.noResultsDetails
,
275 true // We're using existing DOM elements
279 this.initializing
= false;
280 this.switchView( 'default' );
282 this.pollingRate
= mw
.config
.get( 'StructuredChangeFiltersLiveUpdatePollingRate' );
283 if ( this.pollingRate
) {
284 this._scheduleLiveUpdate();
289 * Extracts information from the changes list DOM
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
300 mw
.rcfilters
.Controller
.prototype._extractChangesListInfo = function ( $root
, statusCode
) {
302 $changesListContents
= $root
.find( '.mw-changeslist' ).first().contents(),
303 areResults
= !!$changesListContents
.length
,
304 checkForLogout
= !areResults
&& statusCode
=== 200;
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).
311 if ( checkForLogout
&& !!$root
.find( '#wpName1' ).length
) {
312 location
.reload( false );
317 changes
: $changesListContents
.length
? $changesListContents
: 'NO_RESULTS',
318 fieldset
: $root
.find( 'fieldset.cloptions' ).first()
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';
327 info
.noResultsDetails
= 'NO_RESULTS_NORMAL';
335 * Create filter data from a number, for the filters that are numerical value
337 * @param {Number} num Number
338 * @param {Number} numForDisplay Number for the label
339 * @return {Object} Filter data
341 mw
.rcfilters
.Controller
.prototype._createFilterDataFromNumber = function ( num
, numForDisplay
) {
344 label
: mw
.language
.convertNumber( numForDisplay
)
349 * Add an arbitrary values to groups that allow arbitrary values
351 * @param {Object} groupData Group data
352 * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group
354 mw
.rcfilters
.Controller
.prototype.addNumberValuesToGroup = function ( groupData
, arbitraryValues
) {
355 var controller
= this,
356 normalizeWithinRange = function ( range
, val
) {
357 if ( val
< range
.min
) {
358 return range
.min
; // Min
359 } else if ( val
>= range
.max
) {
360 return range
.max
; // Max
365 arbitraryValues
= Array
.isArray( arbitraryValues
) ? arbitraryValues
: [ arbitraryValues
];
367 // Normalize the arbitrary values and the default value for a range
368 if ( groupData
.range
) {
369 arbitraryValues
= arbitraryValues
.map( function ( val
) {
370 return normalizeWithinRange( groupData
.range
, val
);
373 // Normalize the default, since that's user defined
374 if ( groupData
.default !== undefined ) {
375 groupData
.default = String( normalizeWithinRange( groupData
.range
, groupData
.default ) );
379 // This is only true for single_option group
380 // We assume these are the only groups that will allow for
381 // arbitrary, since it doesn't make any sense for the other
383 arbitraryValues
.forEach( function ( val
) {
385 // If the group allows for arbitrary data
386 groupData
.allowArbitrary
&&
387 // and it is single_option (or string_options, but we
388 // don't have cases of those yet, nor do we plan to)
389 groupData
.type
=== 'single_option' &&
390 // and, if there is a validate method and it passes on
392 ( !groupData
.validate
|| groupData
.validate( val
) ) &&
393 // but if that value isn't already in the definition
395 .map( function ( filterData
) {
396 return String( filterData
.name
);
398 .indexOf( String( val
) ) === -1
400 // Add the filter information
401 groupData
.filters
.push( controller
._createFilterDataFromNumber(
403 groupData
.numToLabelFunc
?
404 groupData
.numToLabelFunc( val
) :
408 // If there's a sort function set up, re-sort the values
409 if ( groupData
.sortFunc
) {
410 groupData
.filters
.sort( groupData
.sortFunc
);
417 * Reset to default filters
419 mw
.rcfilters
.Controller
.prototype.resetToDefaults = function () {
420 var params
= this._getDefaultParams();
421 if ( this.applyParamChange( params
) ) {
422 // Only update the changes list if there was a change to actual filters
423 this.updateChangesList();
425 this.uriProcessor
.updateURL( params
);
430 * Check whether the default values of the filters are all false.
432 * @return {boolean} Defaults are all false
434 mw
.rcfilters
.Controller
.prototype.areDefaultsEmpty = function () {
435 return $.isEmptyObject( this._getDefaultParams() );
439 * Empty all selected filters
441 mw
.rcfilters
.Controller
.prototype.emptyFilters = function () {
442 var highlightedFilterNames
= this.filtersModel
.getHighlightedItems()
443 .map( function ( filterItem
) { return { name
: filterItem
.getName() }; } );
445 if ( this.applyParamChange( {} ) ) {
446 // Only update the changes list if there was a change to actual filters
447 this.updateChangesList();
449 this.uriProcessor
.updateURL();
452 if ( highlightedFilterNames
) {
453 this._trackHighlight( 'clearAll', highlightedFilterNames
);
458 * Update the selected state of a filter
460 * @param {string} filterName Filter name
461 * @param {boolean} [isSelected] Filter selected state
463 mw
.rcfilters
.Controller
.prototype.toggleFilterSelect = function ( filterName
, isSelected
) {
464 var filterItem
= this.filtersModel
.getItemByName( filterName
);
467 // If no filter was found, break
471 isSelected
= isSelected
=== undefined ? !filterItem
.isSelected() : isSelected
;
473 if ( filterItem
.isSelected() !== isSelected
) {
474 this.filtersModel
.toggleFilterSelected( filterName
, isSelected
);
476 this.updateChangesList();
478 // Check filter interactions
479 this.filtersModel
.reassessFilterInteractions( filterItem
);
484 * Clear both highlight and selection of a filter
486 * @param {string} filterName Name of the filter item
488 mw
.rcfilters
.Controller
.prototype.clearFilter = function ( filterName
) {
489 var filterItem
= this.filtersModel
.getItemByName( filterName
),
490 isHighlighted
= filterItem
.isHighlighted(),
491 isSelected
= filterItem
.isSelected();
493 if ( isSelected
|| isHighlighted
) {
494 this.filtersModel
.clearHighlightColor( filterName
);
495 this.filtersModel
.toggleFilterSelected( filterName
, false );
498 // Only update the changes list if the filter changed
499 // its selection state. If it only changed its highlight
501 this.updateChangesList();
504 this.filtersModel
.reassessFilterInteractions( filterItem
);
506 // Log filter grouping
507 this.trackFilterGroupings( 'removefilter' );
510 if ( isHighlighted
) {
511 this._trackHighlight( 'clear', filterName
);
516 * Toggle the highlight feature on and off
518 mw
.rcfilters
.Controller
.prototype.toggleHighlight = function () {
519 this.filtersModel
.toggleHighlight();
520 this.uriProcessor
.updateURL();
522 if ( this.filtersModel
.isHighlightEnabled() ) {
523 mw
.hook( 'RcFilters.highlight.enable' ).fire();
528 * Toggle the namespaces inverted feature on and off
530 mw
.rcfilters
.Controller
.prototype.toggleInvertedNamespaces = function () {
531 this.filtersModel
.toggleInvertedNamespaces();
533 this.filtersModel
.getFiltersByView( 'namespaces' ).filter(
534 function ( filterItem
) { return filterItem
.isSelected(); }
537 // Only re-fetch results if there are namespace items that are actually selected
538 this.updateChangesList();
540 this.uriProcessor
.updateURL();
545 * Set the value of the 'showlinkedto' parameter
546 * @param {boolean} value
548 mw
.rcfilters
.Controller
.prototype.setShowLinkedTo = function ( value
) {
549 var targetItem
= this.filtersModel
.getGroup( 'page' ).getItemByParamName( 'target' ),
550 showLinkedToItem
= this.filtersModel
.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' );
552 this.filtersModel
.toggleFilterSelected( showLinkedToItem
.getName(), value
);
553 this.uriProcessor
.updateURL();
554 // reload the results only when target is set
555 if ( targetItem
.getValue() ) {
556 this.updateChangesList();
561 * Set the target page
562 * @param {string} page
564 mw
.rcfilters
.Controller
.prototype.setTargetPage = function ( page
) {
565 var targetItem
= this.filtersModel
.getGroup( 'page' ).getItemByParamName( 'target' );
566 targetItem
.setValue( page
);
567 this.uriProcessor
.updateURL();
568 this.updateChangesList();
572 * Set the highlight color for a filter item
574 * @param {string} filterName Name of the filter item
575 * @param {string} color Selected color
577 mw
.rcfilters
.Controller
.prototype.setHighlightColor = function ( filterName
, color
) {
578 this.filtersModel
.setHighlightColor( filterName
, color
);
579 this.uriProcessor
.updateURL();
580 this._trackHighlight( 'set', { name
: filterName
, color
: color
} );
584 * Clear highlight for a filter item
586 * @param {string} filterName Name of the filter item
588 mw
.rcfilters
.Controller
.prototype.clearHighlightColor = function ( filterName
) {
589 this.filtersModel
.clearHighlightColor( filterName
);
590 this.uriProcessor
.updateURL();
591 this._trackHighlight( 'clear', filterName
);
595 * Enable or disable live updates.
596 * @param {boolean} enable True to enable, false to disable
598 mw
.rcfilters
.Controller
.prototype.toggleLiveUpdate = function ( enable
) {
599 this.changesListModel
.toggleLiveUpdate( enable
);
600 if ( this.changesListModel
.getLiveUpdate() && this.changesListModel
.getNewChangesExist() ) {
601 this.updateChangesList( null, this.LIVE_UPDATE
);
606 * Set a timeout for the next live update.
609 mw
.rcfilters
.Controller
.prototype._scheduleLiveUpdate = function () {
610 setTimeout( this._doLiveUpdate
.bind( this ), this.pollingRate
* 1000 );
614 * Perform a live update.
617 mw
.rcfilters
.Controller
.prototype._doLiveUpdate = function () {
618 if ( !this._shouldCheckForNewChanges() ) {
619 // skip this turn and check back later
620 this._scheduleLiveUpdate();
624 this._checkForNewChanges()
625 .then( function ( statusCode
) {
626 // no result is 204 with the 'peek' param
628 var newChanges
= statusCode
=== 200;
630 if ( !this._shouldCheckForNewChanges() ) {
631 // by the time the response is received,
632 // it may not be appropriate anymore
636 // 205 is the status code returned from server when user's logged in/out
637 // status is not matching while fetching live update changes.
638 // This works only on Recent Changes page. For WL, look _extractChangesListInfo.
640 if ( statusCode
=== 205 ) {
641 location
.reload( false );
646 if ( this.changesListModel
.getLiveUpdate() ) {
647 return this.updateChangesList( null, this.LIVE_UPDATE
);
649 this.changesListModel
.setNewChangesExist( true );
653 .always( this._scheduleLiveUpdate
.bind( this ) );
657 * @return {boolean} It's appropriate to check for new changes now
660 mw
.rcfilters
.Controller
.prototype._shouldCheckForNewChanges = function () {
661 return !document
.hidden
&&
662 !this.filtersModel
.hasConflict() &&
663 !this.changesListModel
.getNewChangesExist() &&
664 !this.updatingChangesList
&&
665 this.changesListModel
.getNextFrom();
669 * Check if new changes, newer than those currently shown, are available
671 * @return {jQuery.Promise} Promise object that resolves with a bool
672 * specifying if there are new changes or not
676 mw
.rcfilters
.Controller
.prototype._checkForNewChanges = function () {
679 peek
: 1, // bypasses ChangesList specific UI
680 from: this.changesListModel
.getNextFrom(),
681 isAnon
: mw
.user
.isAnon()
683 return this._queryChangesList( 'liveUpdate', params
).then(
691 * Show the new changes
693 * @return {jQuery.Promise} Promise object that resolves after
694 * fetching and showing the new changes
696 mw
.rcfilters
.Controller
.prototype.showNewChanges = function () {
697 return this.updateChangesList( null, this.SHOW_NEW_CHANGES
);
701 * Save the current model state as a saved query
703 * @param {string} [label] Label of the saved query
704 * @param {boolean} [setAsDefault=false] This query should be set as the default
706 mw
.rcfilters
.Controller
.prototype.saveCurrentQuery = function ( label
, setAsDefault
) {
708 this.savedQueriesModel
.addNewQuery(
709 label
|| mw
.msg( 'rcfilters-savedqueries-defaultlabel' ),
710 this.filtersModel
.getCurrentParameterState( true ),
715 this._saveSavedQueries();
719 * Remove a saved query
721 * @param {string} queryID Query id
723 mw
.rcfilters
.Controller
.prototype.removeSavedQuery = function ( queryID
) {
724 this.savedQueriesModel
.removeQuery( queryID
);
726 this._saveSavedQueries();
730 * Rename a saved query
732 * @param {string} queryID Query id
733 * @param {string} newLabel New label for the query
735 mw
.rcfilters
.Controller
.prototype.renameSavedQuery = function ( queryID
, newLabel
) {
736 var queryItem
= this.savedQueriesModel
.getItemByID( queryID
);
739 queryItem
.updateLabel( newLabel
);
741 this._saveSavedQueries();
745 * Set a saved query as default
747 * @param {string} queryID Query Id. If null is given, default
750 mw
.rcfilters
.Controller
.prototype.setDefaultSavedQuery = function ( queryID
) {
751 this.savedQueriesModel
.setDefault( queryID
);
752 this._saveSavedQueries();
758 * @param {string} queryID Query id
760 mw
.rcfilters
.Controller
.prototype.applySavedQuery = function ( queryID
) {
761 var currentMatchingQuery
,
762 params
= this.savedQueriesModel
.getItemParams( queryID
);
764 currentMatchingQuery
= this.findQueryMatchingCurrentState();
767 currentMatchingQuery
&&
768 currentMatchingQuery
.getID() === queryID
770 // If the query we want to load is the one that is already
771 // loaded, don't reload it
775 if ( this.applyParamChange( params
) ) {
776 // Update changes list only if there was a difference in filter selection
777 this.updateChangesList();
779 this.uriProcessor
.updateURL( params
);
782 // Log filter grouping
783 this.trackFilterGroupings( 'savedfilters' );
787 * Check whether the current filter and highlight state exists
788 * in the saved queries model.
790 * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
792 mw
.rcfilters
.Controller
.prototype.findQueryMatchingCurrentState = function () {
793 return this.savedQueriesModel
.findMatchingQuery(
794 this.filtersModel
.getCurrentParameterState( true )
799 * Save the current state of the saved queries model with all
800 * query item representation in the user settings.
802 mw
.rcfilters
.Controller
.prototype._saveSavedQueries = function () {
803 var stringified
, oldPrefValue
,
804 backupPrefName
= this.savedQueriesPreferenceName
+ '-versionbackup',
805 state
= this.savedQueriesModel
.getState();
808 stringified
= JSON
.stringify( state
);
810 if ( byteLength( stringified
) > 65535 ) {
811 // Sanity check, since the preference can only hold that.
815 if ( !this.wereSavedQueriesSaved
&& this.savedQueriesModel
.isConverted() ) {
816 // The queries were converted from the previous version
817 // Keep the old string in the [prefname]-versionbackup
818 oldPrefValue
= mw
.user
.options
.get( this.savedQueriesPreferenceName
);
820 // Save the old preference in the backup preference
821 new mw
.Api().saveOption( backupPrefName
, oldPrefValue
);
822 // Update the preference for this session
823 mw
.user
.options
.set( backupPrefName
, oldPrefValue
);
826 // Save the preference
827 new mw
.Api().saveOption( this.savedQueriesPreferenceName
, stringified
);
828 // Update the preference for this session
829 mw
.user
.options
.set( this.savedQueriesPreferenceName
, stringified
);
831 // Tag as already saved so we don't do this again
832 this.wereSavedQueriesSaved
= true;
836 * Update sticky preferences with current model state
838 mw
.rcfilters
.Controller
.prototype.updateStickyPreferences = function () {
839 // Update default sticky values with selected, whether they came from
840 // the initial defaults or from the URL value that is being normalized
841 this.updateDaysDefault( this.filtersModel
.getGroup( 'days' ).getSelectedItems()[ 0 ].getParamName() );
842 this.updateLimitDefault( this.filtersModel
.getGroup( 'limit' ).getSelectedItems()[ 0 ].getParamName() );
844 // TODO: Make these automatic by having the model go over sticky
845 // items and update their default values automatically
849 * Update the limit default value
851 * @param {number} newValue New value
853 mw
.rcfilters
.Controller
.prototype.updateLimitDefault = function ( newValue
) {
854 this.updateNumericPreference( this.limitPreferenceName
, newValue
);
858 * Update the days default value
860 * @param {number} newValue New value
862 mw
.rcfilters
.Controller
.prototype.updateDaysDefault = function ( newValue
) {
863 this.updateNumericPreference( this.daysPreferenceName
, newValue
);
867 * Update the group by page default value
869 * @param {boolean} newValue New value
871 mw
.rcfilters
.Controller
.prototype.updateGroupByPageDefault = function ( newValue
) {
872 this.updateNumericPreference( 'usenewrc', Number( newValue
) );
876 * Update a numeric preference with a new value
878 * @param {string} prefName Preference name
879 * @param {number|string} newValue New value
881 mw
.rcfilters
.Controller
.prototype.updateNumericPreference = function ( prefName
, newValue
) {
882 if ( !$.isNumeric( newValue
) ) {
886 newValue
= Number( newValue
);
888 if ( mw
.user
.options
.get( prefName
) !== newValue
) {
889 // Save the preference
890 new mw
.Api().saveOption( prefName
, newValue
);
891 // Update the preference for this session
892 mw
.user
.options
.set( prefName
, newValue
);
897 * Synchronize the URL with the current state of the filters
898 * without adding an history entry.
900 mw
.rcfilters
.Controller
.prototype.replaceUrl = function () {
901 this.uriProcessor
.updateURL();
905 * Update filter state (selection and highlighting) based
906 * on current URL values.
908 * @param {boolean} [fetchChangesList=true] Fetch new results into the changes
909 * list based on the updated model.
911 mw
.rcfilters
.Controller
.prototype.updateStateFromUrl = function ( fetchChangesList
) {
912 fetchChangesList
= fetchChangesList
=== undefined ? true : !!fetchChangesList
;
914 this.uriProcessor
.updateModelBasedOnQuery();
916 // Update the sticky preferences, in case we received a value
918 this.updateStickyPreferences();
920 // Only update and fetch new results if it is requested
921 if ( fetchChangesList
) {
922 this.updateChangesList();
927 * Update the list of changes and notify the model
929 * @param {Object} [params] Extra parameters to add to the API call
930 * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen'
931 * @return {jQuery.Promise} Promise that is resolved when the update is complete
933 mw
.rcfilters
.Controller
.prototype.updateChangesList = function ( params
, updateMode
) {
934 updateMode
= updateMode
=== undefined ? this.FILTER_CHANGE
: updateMode
;
936 if ( updateMode
=== this.FILTER_CHANGE
) {
937 this.uriProcessor
.updateURL( params
);
939 if ( updateMode
=== this.FILTER_CHANGE
|| updateMode
=== this.SHOW_NEW_CHANGES
) {
940 this.changesListModel
.invalidate();
942 this.changesListModel
.setNewChangesExist( false );
943 this.updatingChangesList
= true;
944 return this._fetchChangesList()
947 function ( pieces
) {
948 var $changesListContent
= pieces
.changes
,
949 $fieldset
= pieces
.fieldset
;
950 this.changesListModel
.update(
953 pieces
.noResultsDetails
,
955 // separator between old and new changes
956 updateMode
=== this.SHOW_NEW_CHANGES
|| updateMode
=== this.LIVE_UPDATE
959 // Do nothing for failure
961 .always( function () {
962 this.updatingChangesList
= false;
967 * Get an object representing the default parameter state, whether
968 * it is from the model defaults or from the saved queries.
970 * @return {Object} Default parameters
972 mw
.rcfilters
.Controller
.prototype._getDefaultParams = function () {
973 if ( this.savedQueriesModel
.getDefault() ) {
974 return this.savedQueriesModel
.getDefaultParams();
976 return this.filtersModel
.getDefaultParams();
981 * Query the list of changes from the server for the current filters
983 * @param {string} counterId Id for this request. To allow concurrent requests
984 * not to invalidate each other.
985 * @param {Object} [params={}] Parameters to add to the query
987 * @return {jQuery.Promise} Promise object resolved with { content, status }
989 mw
.rcfilters
.Controller
.prototype._queryChangesList = function ( counterId
, params
) {
990 var uri
= this.uriProcessor
.getUpdatedUri(),
991 stickyParams
= this.filtersModel
.getStickyParamsValues(),
995 params
= params
|| {};
996 params
.action
= 'render'; // bypasses MW chrome
998 uri
.extend( params
);
1000 this.requestCounter
[ counterId
] = this.requestCounter
[ counterId
] || 0;
1001 requestId
= ++this.requestCounter
[ counterId
];
1002 latestRequest = function () {
1003 return requestId
=== this.requestCounter
[ counterId
];
1006 // Sticky parameters override the URL params
1007 // this is to make sure that whether we represent
1008 // the sticky params in the URL or not (they may
1009 // be normalized out) the sticky parameters are
1010 // always being sent to the server with their
1011 // current/default values
1012 uri
.extend( stickyParams
);
1014 return $.ajax( uri
.toString(), { contentType
: 'html' } )
1016 function ( content
, message
, jqXHR
) {
1017 if ( !latestRequest() ) {
1018 return $.Deferred().reject();
1022 status
: jqXHR
.status
1025 // RC returns 404 when there is no results
1026 function ( jqXHR
) {
1027 if ( latestRequest() ) {
1028 return $.Deferred().resolve(
1030 content
: jqXHR
.responseText
,
1031 status
: jqXHR
.status
1040 * Fetch the list of changes from the server for the current filters
1042 * @return {jQuery.Promise} Promise object that will resolve with the changes list
1045 mw
.rcfilters
.Controller
.prototype._fetchChangesList = function () {
1046 return this._queryChangesList( 'updateChangesList' )
1051 // Status code 0 is not HTTP status code,
1052 // but is valid value of XMLHttpRequest status.
1053 // It is used for variety of network errors, for example
1054 // when an AJAX call was cancelled before getting the response
1055 if ( data
&& data
.status
=== 0 ) {
1057 changes
: 'NO_RESULTS',
1058 // We need empty result set, to avoid exceptions because of undefined value
1060 noResultsDetails
: 'NO_RESULTS_NETWORK_ERROR'
1064 $parsed
= $( '<div>' ).append( $( $.parseHTML(
1065 data
? data
.content
: ''
1068 return this._extractChangesListInfo( $parsed
, data
.status
);
1074 * Track usage of highlight feature
1076 * @param {string} action
1077 * @param {Array|Object|string} filters
1079 mw
.rcfilters
.Controller
.prototype._trackHighlight = function ( action
, filters
) {
1080 filters
= typeof filters
=== 'string' ? { name
: filters
} : filters
;
1081 filters
= !Array
.isArray( filters
) ? [ filters
] : filters
;
1083 'event.ChangesListHighlights',
1087 userId
: mw
.user
.getId()
1093 * Track filter grouping usage
1095 * @param {string} action Action taken
1097 mw
.rcfilters
.Controller
.prototype.trackFilterGroupings = function ( action
) {
1098 var controller
= this,
1099 rightNow
= new Date().getTime(),
1100 randomIdentifier
= String( mw
.user
.sessionId() ) + String( rightNow
) + String( Math
.random() ),
1101 // Get all current filters
1102 filters
= this.filtersModel
.getSelectedItems().map( function ( item
) {
1103 return item
.getName();
1106 action
= action
|| 'filtermenu';
1108 // Check if these filters were the ones we just logged previously
1109 // (Don't log the same grouping twice, in case the user opens/closes)
1110 // the menu without action, or with the same result
1112 // Only log if the two arrays are different in size
1113 filters
.length
!== this.prevLoggedItems
.length
||
1114 // Or if any filters are not the same as the cached filters
1115 filters
.some( function ( filterName
) {
1116 return controller
.prevLoggedItems
.indexOf( filterName
) === -1;
1118 // Or if any cached filters are not the same as given filters
1119 this.prevLoggedItems
.some( function ( filterName
) {
1120 return filters
.indexOf( filterName
) === -1;
1123 filters
.forEach( function ( filterName
) {
1125 'event.ChangesListFilterGrouping',
1128 groupIdentifier
: randomIdentifier
,
1130 userId
: mw
.user
.getId()
1135 // Cache the filter names
1136 this.prevLoggedItems
= filters
;
1141 * Apply a change of parameters to the model state, and check whether
1142 * the new state is different than the old state.
1144 * @param {Object} newParamState New parameter state to apply
1145 * @return {boolean} New applied model state is different than the previous state
1147 mw
.rcfilters
.Controller
.prototype.applyParamChange = function ( newParamState
) {
1149 before
= this.filtersModel
.getSelectedState();
1151 this.filtersModel
.updateStateFromParams( newParamState
);
1153 after
= this.filtersModel
.getSelectedState();
1155 return !OO
.compare( before
, after
);
1159 * Mark all changes as seen on Watchlist
1161 mw
.rcfilters
.Controller
.prototype.markAllChangesAsSeen = function () {
1162 var api
= new mw
.Api();
1163 api
.postWithToken( 'csrf', {
1165 action
: 'setnotificationtimestamp',
1166 entirewatchlist
: true
1167 } ).then( function () {
1168 this.updateChangesList( null, 'markSeen' );
1173 * Set the current search for the system.
1175 * @param {string} searchQuery Search query, including triggers
1177 mw
.rcfilters
.Controller
.prototype.setSearch = function ( searchQuery
) {
1178 this.filtersModel
.setSearch( searchQuery
);
1182 * Switch the view by changing the search query trigger
1183 * without changing the search term
1185 * @param {string} view View to change to
1187 mw
.rcfilters
.Controller
.prototype.switchView = function ( view
) {
1189 this.filtersModel
.getViewTrigger( view
) +
1190 this.filtersModel
.removeViewTriggers( this.filtersModel
.getSearch() )
1195 * Reset the search for a specific view. This means we null the search query
1196 * and replace it with the relevant trigger for the requested view
1198 * @param {string} [view='default'] View to change to
1200 mw
.rcfilters
.Controller
.prototype.resetSearchForView = function ( view
) {
1201 view
= view
|| 'default';
1204 this.filtersModel
.getViewTrigger( view
)
1207 }( mediaWiki
, jQuery
) );