( function ( mw, $ ) { var byteLength = require( 'mediawiki.String' ).byteLength; /* eslint no-underscore-dangle: "off" */ /** * Controller for the filters in Recent Changes * @class * * @constructor * @param {mw.rcfilters.dm.FiltersViewModel} filtersModel Filters view model * @param {mw.rcfilters.dm.ChangesListViewModel} changesListModel Changes list view model * @param {mw.rcfilters.dm.SavedQueriesModel} savedQueriesModel Saved queries model * @param {Object} config Additional configuration * @cfg {string} savedQueriesPreferenceName Where to save the saved queries * @cfg {string} daysPreferenceName Preference name for the days filter * @cfg {string} limitPreferenceName Preference name for the limit filter * @cfg {boolean} [normalizeTarget] Dictates whether or not to go through the * title normalization to separate title subpage/parts into the target= url * parameter */ mw.rcfilters.Controller = function MwRcfiltersController( filtersModel, changesListModel, savedQueriesModel, config ) { this.filtersModel = filtersModel; this.changesListModel = changesListModel; this.savedQueriesModel = savedQueriesModel; this.savedQueriesPreferenceName = config.savedQueriesPreferenceName; this.daysPreferenceName = config.daysPreferenceName; this.limitPreferenceName = config.limitPreferenceName; this.normalizeTarget = !!config.normalizeTarget; this.requestCounter = {}; this.baseFilterState = {}; this.uriProcessor = null; this.initializing = false; this.wereSavedQueriesSaved = false; this.prevLoggedItems = []; this.FILTER_CHANGE = 'filterChange'; this.SHOW_NEW_CHANGES = 'showNewChanges'; this.LIVE_UPDATE = 'liveUpdate'; }; /* Initialization */ OO.initClass( mw.rcfilters.Controller ); /** * Initialize the filter and parameter states * * @param {Array} filterStructure Filter definition and structure for the model * @param {Object} [namespaceStructure] Namespace definition * @param {Object} [tagList] Tag definition * @param {Object} [conditionalViews] Conditional view definition */ mw.rcfilters.Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) { var parsedSavedQueries, pieces, displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ), defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ), controller = this, views = $.extend( true, {}, conditionalViews ), items = [], uri = new mw.Uri(); // Prepare views if ( namespaceStructure ) { items = []; $.each( namespaceStructure, function ( namespaceID, label ) { // Build and clean up the individual namespace items definition items.push( { name: namespaceID, label: label || mw.msg( 'blanknamespace' ), description: '', identifiers: [ ( namespaceID < 0 || namespaceID % 2 === 0 ) ? 'subject' : 'talk' ], cssClass: 'mw-changeslist-ns-' + namespaceID } ); } ); views.namespaces = { title: mw.msg( 'namespaces' ), trigger: ':', groups: [ { // Group definition (single group) name: 'namespace', // parameter name is singular type: 'string_options', title: mw.msg( 'namespaces' ), labelPrefixKey: { 'default': 'rcfilters-tag-prefix-namespace', inverted: 'rcfilters-tag-prefix-namespace-inverted' }, separator: ';', fullCoverage: true, filters: items } ] }; views.invert = { groups: [ { name: 'invertGroup', type: 'boolean', hidden: true, filters: [ { name: 'invert', 'default': '0' } ] } ] }; } if ( tagList ) { views.tags = { title: mw.msg( 'rcfilters-view-tags' ), trigger: '#', groups: [ { // Group definition (single group) name: 'tagfilter', // Parameter name type: 'string_options', title: 'rcfilters-view-tags', // Message key labelPrefixKey: 'rcfilters-tag-prefix-tags', separator: '|', fullCoverage: false, filters: tagList } ] }; } // Add parameter range operations views.range = { groups: [ { name: 'limit', type: 'single_option', title: '', // Because it's a hidden group, this title actually appears nowhere hidden: true, allowArbitrary: true, validate: $.isNumeric, range: { min: 0, // The server normalizes negative numbers to 0 results max: 1000 }, sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); }, 'default': mw.user.options.get( this.limitPreferenceName, displayConfig.limitDefault ), sticky: true, filters: displayConfig.limitArray.map( function ( num ) { return controller._createFilterDataFromNumber( num, num ); } ) }, { name: 'days', type: 'single_option', title: '', // Because it's a hidden group, this title actually appears nowhere hidden: true, allowArbitrary: true, validate: $.isNumeric, range: { min: 0, max: displayConfig.maxDays }, sortFunc: function ( a, b ) { return Number( a.name ) - Number( b.name ); }, numToLabelFunc: function ( i ) { return Number( i ) < 1 ? ( Number( i ) * 24 ).toFixed( 2 ) : Number( i ); }, 'default': mw.user.options.get( this.daysPreferenceName, displayConfig.daysDefault ), sticky: true, filters: [ // Hours (1, 2, 6, 12) 0.04166, 0.0833, 0.25, 0.5 // Days ].concat( displayConfig.daysArray ) .map( function ( num ) { return controller._createFilterDataFromNumber( num, // Convert fractions of days to number of hours for the labels num < 1 ? Math.round( num * 24 ) : num ); } ) } ] }; views.display = { groups: [ { name: 'display', type: 'boolean', title: '', // Because it's a hidden group, this title actually appears nowhere hidden: true, sticky: true, filters: [ { name: 'enhanced', 'default': String( mw.user.options.get( 'usenewrc', 0 ) ) } ] } ] }; // Before we do anything, we need to see if we require additional items in the // groups that have 'AllowArbitrary'. For the moment, those are only single_option // groups; if we ever expand it, this might need further generalization: $.each( views, function ( viewName, viewData ) { viewData.groups.forEach( function ( groupData ) { var extraValues = []; if ( groupData.allowArbitrary ) { // If the value in the URI isn't in the group, add it if ( uri.query[ groupData.name ] !== undefined ) { extraValues.push( uri.query[ groupData.name ] ); } // If the default value isn't in the group, add it if ( groupData.default !== undefined ) { extraValues.push( String( groupData.default ) ); } controller.addNumberValuesToGroup( groupData, extraValues ); } } ); } ); // Initialize the model this.filtersModel.initializeFilters( filterStructure, views ); this.uriProcessor = new mw.rcfilters.UriProcessor( this.filtersModel, { normalizeTarget: this.normalizeTarget } ); if ( !mw.user.isAnon() ) { try { parsedSavedQueries = JSON.parse( mw.user.options.get( this.savedQueriesPreferenceName ) || '{}' ); } catch ( err ) { parsedSavedQueries = {}; } // Initialize saved queries this.savedQueriesModel.initialize( parsedSavedQueries ); if ( this.savedQueriesModel.isConverted() ) { // Since we know we converted, we're going to re-save // the queries so they are now migrated to the new format this._saveSavedQueries(); } } // Check whether we need to load defaults. // We do this by checking whether the current URI query // contains any parameters recognized by the system. // If it does, we load the given state. // If it doesn't, we have no values at all, and we assume // the user loads the base-page and we load defaults. // Defaults should only be applied on load (if necessary) // or on request this.initializing = true; if ( defaultSavedQueryExists ) { // This came from the server, meaning that we have a default // saved query, but the server could not load it, probably because // it was pre-conversion to the new format. // We need to load this query again this.applySavedQuery( this.savedQueriesModel.getDefault() ); } else { // There are either recognized parameters in the URL // or there are none, but there is also no default // saved query (so defaults are from the backend) // We want to update the state but not fetch results // again this.updateStateFromUrl( false ); pieces = this._extractChangesListInfo( $( '#mw-content-text' ) ); // Update the changes list with the existing data // so it gets processed this.changesListModel.update( pieces.changes, pieces.fieldset, pieces.noResultsDetails, true // We're using existing DOM elements ); } this.initializing = false; this.switchView( 'default' ); this.pollingRate = mw.config.get( 'StructuredChangeFiltersLiveUpdatePollingRate' ); if ( this.pollingRate ) { this._scheduleLiveUpdate(); } }; /** * Extracts information from the changes list DOM * * @param {jQuery} $root Root DOM to find children from * @param {boolean} [statusCode] Server response status code * @return {Object} Information about changes list * @return {Object|string} return.changes Changes list, or 'NO_RESULTS' if there are no results * (either normally or as an error) * @return {string} [return.noResultsDetails] 'NO_RESULTS_NORMAL' for a normal 0-result set, * 'NO_RESULTS_TIMEOUT' for no results due to a timeout, or omitted for more than 0 results * @return {jQuery} return.fieldset Fieldset */ mw.rcfilters.Controller.prototype._extractChangesListInfo = function ( $root, statusCode ) { var info, $changesListContents = $root.find( '.mw-changeslist' ).first().contents(), areResults = !!$changesListContents.length, checkForLogout = !areResults && statusCode === 200; // We check if user logged out on different tab/browser or the session has expired. // 205 status code returned from the server, which indicates that we need to reload the page // is not usable on WL page, because we get redirected to login page, which gives 200 OK // status code (if everything else goes well). // Bug: T177717 if ( checkForLogout && !!$root.find( '#wpName1' ).length ) { location.reload( false ); return; } info = { changes: $changesListContents.length ? $changesListContents : 'NO_RESULTS', fieldset: $root.find( 'fieldset.cloptions' ).first() }; if ( !areResults ) { if ( $root.find( '.mw-changeslist-timeout' ).length ) { info.noResultsDetails = 'NO_RESULTS_TIMEOUT'; } else if ( $root.find( '.mw-changeslist-notargetpage' ).length ) { info.noResultsDetails = 'NO_RESULTS_NO_TARGET_PAGE'; } else if ( $root.find( '.mw-changeslist-invalidtargetpage' ).length ) { info.noResultsDetails = 'NO_RESULTS_INVALID_TARGET_PAGE'; } else { info.noResultsDetails = 'NO_RESULTS_NORMAL'; } } return info; }; /** * Create filter data from a number, for the filters that are numerical value * * @param {Number} num Number * @param {Number} numForDisplay Number for the label * @return {Object} Filter data */ mw.rcfilters.Controller.prototype._createFilterDataFromNumber = function ( num, numForDisplay ) { return { name: String( num ), label: mw.language.convertNumber( numForDisplay ) }; }; /** * Add an arbitrary values to groups that allow arbitrary values * * @param {Object} groupData Group data * @param {string|string[]} arbitraryValues An array of arbitrary values to add to the group */ mw.rcfilters.Controller.prototype.addNumberValuesToGroup = function ( groupData, arbitraryValues ) { var controller = this, normalizeWithinRange = function ( range, val ) { if ( val < range.min ) { return range.min; // Min } else if ( val >= range.max ) { return range.max; // Max } return val; }; arbitraryValues = Array.isArray( arbitraryValues ) ? arbitraryValues : [ arbitraryValues ]; // Normalize the arbitrary values and the default value for a range if ( groupData.range ) { arbitraryValues = arbitraryValues.map( function ( val ) { return normalizeWithinRange( groupData.range, val ); } ); // Normalize the default, since that's user defined if ( groupData.default !== undefined ) { groupData.default = String( normalizeWithinRange( groupData.range, groupData.default ) ); } } // This is only true for single_option group // We assume these are the only groups that will allow for // arbitrary, since it doesn't make any sense for the other // groups. arbitraryValues.forEach( function ( val ) { if ( // If the group allows for arbitrary data groupData.allowArbitrary && // and it is single_option (or string_options, but we // don't have cases of those yet, nor do we plan to) groupData.type === 'single_option' && // and, if there is a validate method and it passes on // the data ( !groupData.validate || groupData.validate( val ) ) && // but if that value isn't already in the definition groupData.filters .map( function ( filterData ) { return String( filterData.name ); } ) .indexOf( String( val ) ) === -1 ) { // Add the filter information groupData.filters.push( controller._createFilterDataFromNumber( val, groupData.numToLabelFunc ? groupData.numToLabelFunc( val ) : val ) ); // If there's a sort function set up, re-sort the values if ( groupData.sortFunc ) { groupData.filters.sort( groupData.sortFunc ); } } } ); }; /** * Reset to default filters */ mw.rcfilters.Controller.prototype.resetToDefaults = function () { var params = this._getDefaultParams(); if ( this.applyParamChange( params ) ) { // Only update the changes list if there was a change to actual filters this.updateChangesList(); } else { this.uriProcessor.updateURL( params ); } }; /** * Check whether the default values of the filters are all false. * * @return {boolean} Defaults are all false */ mw.rcfilters.Controller.prototype.areDefaultsEmpty = function () { return $.isEmptyObject( this._getDefaultParams() ); }; /** * Empty all selected filters */ mw.rcfilters.Controller.prototype.emptyFilters = function () { var highlightedFilterNames = this.filtersModel.getHighlightedItems() .map( function ( filterItem ) { return { name: filterItem.getName() }; } ); if ( this.applyParamChange( {} ) ) { // Only update the changes list if there was a change to actual filters this.updateChangesList(); } else { this.uriProcessor.updateURL(); } if ( highlightedFilterNames ) { this._trackHighlight( 'clearAll', highlightedFilterNames ); } }; /** * Update the selected state of a filter * * @param {string} filterName Filter name * @param {boolean} [isSelected] Filter selected state */ mw.rcfilters.Controller.prototype.toggleFilterSelect = function ( filterName, isSelected ) { var filterItem = this.filtersModel.getItemByName( filterName ); if ( !filterItem ) { // If no filter was found, break return; } isSelected = isSelected === undefined ? !filterItem.isSelected() : isSelected; if ( filterItem.isSelected() !== isSelected ) { this.filtersModel.toggleFilterSelected( filterName, isSelected ); this.updateChangesList(); // Check filter interactions this.filtersModel.reassessFilterInteractions( filterItem ); } }; /** * Clear both highlight and selection of a filter * * @param {string} filterName Name of the filter item */ mw.rcfilters.Controller.prototype.clearFilter = function ( filterName ) { var filterItem = this.filtersModel.getItemByName( filterName ), isHighlighted = filterItem.isHighlighted(), isSelected = filterItem.isSelected(); if ( isSelected || isHighlighted ) { this.filtersModel.clearHighlightColor( filterName ); this.filtersModel.toggleFilterSelected( filterName, false ); if ( isSelected ) { // Only update the changes list if the filter changed // its selection state. If it only changed its highlight // then don't reload this.updateChangesList(); } this.filtersModel.reassessFilterInteractions( filterItem ); // Log filter grouping this.trackFilterGroupings( 'removefilter' ); } if ( isHighlighted ) { this._trackHighlight( 'clear', filterName ); } }; /** * Toggle the highlight feature on and off */ mw.rcfilters.Controller.prototype.toggleHighlight = function () { this.filtersModel.toggleHighlight(); this.uriProcessor.updateURL(); if ( this.filtersModel.isHighlightEnabled() ) { mw.hook( 'RcFilters.highlight.enable' ).fire(); } }; /** * Toggle the namespaces inverted feature on and off */ mw.rcfilters.Controller.prototype.toggleInvertedNamespaces = function () { this.filtersModel.toggleInvertedNamespaces(); if ( this.filtersModel.getFiltersByView( 'namespaces' ).filter( function ( filterItem ) { return filterItem.isSelected(); } ).length ) { // Only re-fetch results if there are namespace items that are actually selected this.updateChangesList(); } else { this.uriProcessor.updateURL(); } }; /** * Set the value of the 'showlinkedto' parameter * @param {boolean} value */ mw.rcfilters.Controller.prototype.setShowLinkedTo = function ( value ) { var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ), showLinkedToItem = this.filtersModel.getGroup( 'toOrFrom' ).getItemByParamName( 'showlinkedto' ); this.filtersModel.toggleFilterSelected( showLinkedToItem.getName(), value ); this.uriProcessor.updateURL(); // reload the results only when target is set if ( targetItem.getValue() ) { this.updateChangesList(); } }; /** * Set the target page * @param {string} page */ mw.rcfilters.Controller.prototype.setTargetPage = function ( page ) { var targetItem = this.filtersModel.getGroup( 'page' ).getItemByParamName( 'target' ); targetItem.setValue( page ); this.uriProcessor.updateURL(); this.updateChangesList(); }; /** * Set the highlight color for a filter item * * @param {string} filterName Name of the filter item * @param {string} color Selected color */ mw.rcfilters.Controller.prototype.setHighlightColor = function ( filterName, color ) { this.filtersModel.setHighlightColor( filterName, color ); this.uriProcessor.updateURL(); this._trackHighlight( 'set', { name: filterName, color: color } ); }; /** * Clear highlight for a filter item * * @param {string} filterName Name of the filter item */ mw.rcfilters.Controller.prototype.clearHighlightColor = function ( filterName ) { this.filtersModel.clearHighlightColor( filterName ); this.uriProcessor.updateURL(); this._trackHighlight( 'clear', filterName ); }; /** * Enable or disable live updates. * @param {boolean} enable True to enable, false to disable */ mw.rcfilters.Controller.prototype.toggleLiveUpdate = function ( enable ) { this.changesListModel.toggleLiveUpdate( enable ); if ( this.changesListModel.getLiveUpdate() && this.changesListModel.getNewChangesExist() ) { this.updateChangesList( null, this.LIVE_UPDATE ); } }; /** * Set a timeout for the next live update. * @private */ mw.rcfilters.Controller.prototype._scheduleLiveUpdate = function () { setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 ); }; /** * Perform a live update. * @private */ mw.rcfilters.Controller.prototype._doLiveUpdate = function () { if ( !this._shouldCheckForNewChanges() ) { // skip this turn and check back later this._scheduleLiveUpdate(); return; } this._checkForNewChanges() .then( function ( statusCode ) { // no result is 204 with the 'peek' param // logged out is 205 var newChanges = statusCode === 200; if ( !this._shouldCheckForNewChanges() ) { // by the time the response is received, // it may not be appropriate anymore return; } // 205 is the status code returned from server when user's logged in/out // status is not matching while fetching live update changes. // This works only on Recent Changes page. For WL, look _extractChangesListInfo. // Bug: T177717 if ( statusCode === 205 ) { location.reload( false ); return; } if ( newChanges ) { if ( this.changesListModel.getLiveUpdate() ) { return this.updateChangesList( null, this.LIVE_UPDATE ); } else { this.changesListModel.setNewChangesExist( true ); } } }.bind( this ) ) .always( this._scheduleLiveUpdate.bind( this ) ); }; /** * @return {boolean} It's appropriate to check for new changes now * @private */ mw.rcfilters.Controller.prototype._shouldCheckForNewChanges = function () { return !document.hidden && !this.filtersModel.hasConflict() && !this.changesListModel.getNewChangesExist() && !this.updatingChangesList && this.changesListModel.getNextFrom(); }; /** * Check if new changes, newer than those currently shown, are available * * @return {jQuery.Promise} Promise object that resolves with a bool * specifying if there are new changes or not * * @private */ mw.rcfilters.Controller.prototype._checkForNewChanges = function () { var params = { limit: 1, peek: 1, // bypasses ChangesList specific UI from: this.changesListModel.getNextFrom(), isAnon: mw.user.isAnon() }; return this._queryChangesList( 'liveUpdate', params ).then( function ( data ) { return data.status; } ); }; /** * Show the new changes * * @return {jQuery.Promise} Promise object that resolves after * fetching and showing the new changes */ mw.rcfilters.Controller.prototype.showNewChanges = function () { return this.updateChangesList( null, this.SHOW_NEW_CHANGES ); }; /** * Save the current model state as a saved query * * @param {string} [label] Label of the saved query * @param {boolean} [setAsDefault=false] This query should be set as the default */ mw.rcfilters.Controller.prototype.saveCurrentQuery = function ( label, setAsDefault ) { // Add item this.savedQueriesModel.addNewQuery( label || mw.msg( 'rcfilters-savedqueries-defaultlabel' ), this.filtersModel.getCurrentParameterState( true ), setAsDefault ); // Save item this._saveSavedQueries(); }; /** * Remove a saved query * * @param {string} queryID Query id */ mw.rcfilters.Controller.prototype.removeSavedQuery = function ( queryID ) { this.savedQueriesModel.removeQuery( queryID ); this._saveSavedQueries(); }; /** * Rename a saved query * * @param {string} queryID Query id * @param {string} newLabel New label for the query */ mw.rcfilters.Controller.prototype.renameSavedQuery = function ( queryID, newLabel ) { var queryItem = this.savedQueriesModel.getItemByID( queryID ); if ( queryItem ) { queryItem.updateLabel( newLabel ); } this._saveSavedQueries(); }; /** * Set a saved query as default * * @param {string} queryID Query Id. If null is given, default * query is reset. */ mw.rcfilters.Controller.prototype.setDefaultSavedQuery = function ( queryID ) { this.savedQueriesModel.setDefault( queryID ); this._saveSavedQueries(); }; /** * Load a saved query * * @param {string} queryID Query id */ mw.rcfilters.Controller.prototype.applySavedQuery = function ( queryID ) { var currentMatchingQuery, params = this.savedQueriesModel.getItemParams( queryID ); currentMatchingQuery = this.findQueryMatchingCurrentState(); if ( currentMatchingQuery && currentMatchingQuery.getID() === queryID ) { // If the query we want to load is the one that is already // loaded, don't reload it return; } if ( this.applyParamChange( params ) ) { // Update changes list only if there was a difference in filter selection this.updateChangesList(); } else { this.uriProcessor.updateURL( params ); } // Log filter grouping this.trackFilterGroupings( 'savedfilters' ); }; /** * Check whether the current filter and highlight state exists * in the saved queries model. * * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model */ mw.rcfilters.Controller.prototype.findQueryMatchingCurrentState = function () { return this.savedQueriesModel.findMatchingQuery( this.filtersModel.getCurrentParameterState( true ) ); }; /** * Save the current state of the saved queries model with all * query item representation in the user settings. */ mw.rcfilters.Controller.prototype._saveSavedQueries = function () { var stringified, oldPrefValue, backupPrefName = this.savedQueriesPreferenceName + '-versionbackup', state = this.savedQueriesModel.getState(); // Stringify state stringified = JSON.stringify( state ); if ( byteLength( stringified ) > 65535 ) { // Sanity check, since the preference can only hold that. return; } if ( !this.wereSavedQueriesSaved && this.savedQueriesModel.isConverted() ) { // The queries were converted from the previous version // Keep the old string in the [prefname]-versionbackup oldPrefValue = mw.user.options.get( this.savedQueriesPreferenceName ); // Save the old preference in the backup preference new mw.Api().saveOption( backupPrefName, oldPrefValue ); // Update the preference for this session mw.user.options.set( backupPrefName, oldPrefValue ); } // Save the preference new mw.Api().saveOption( this.savedQueriesPreferenceName, stringified ); // Update the preference for this session mw.user.options.set( this.savedQueriesPreferenceName, stringified ); // Tag as already saved so we don't do this again this.wereSavedQueriesSaved = true; }; /** * Update sticky preferences with current model state */ mw.rcfilters.Controller.prototype.updateStickyPreferences = function () { // Update default sticky values with selected, whether they came from // the initial defaults or from the URL value that is being normalized this.updateDaysDefault( this.filtersModel.getGroup( 'days' ).findSelectedItems()[ 0 ].getParamName() ); this.updateLimitDefault( this.filtersModel.getGroup( 'limit' ).findSelectedItems()[ 0 ].getParamName() ); // TODO: Make these automatic by having the model go over sticky // items and update their default values automatically }; /** * Update the limit default value * * @param {number} newValue New value */ mw.rcfilters.Controller.prototype.updateLimitDefault = function ( newValue ) { this.updateNumericPreference( this.limitPreferenceName, newValue ); }; /** * Update the days default value * * @param {number} newValue New value */ mw.rcfilters.Controller.prototype.updateDaysDefault = function ( newValue ) { this.updateNumericPreference( this.daysPreferenceName, newValue ); }; /** * Update the group by page default value * * @param {boolean} newValue New value */ mw.rcfilters.Controller.prototype.updateGroupByPageDefault = function ( newValue ) { this.updateNumericPreference( 'usenewrc', Number( newValue ) ); }; /** * Update a numeric preference with a new value * * @param {string} prefName Preference name * @param {number|string} newValue New value */ mw.rcfilters.Controller.prototype.updateNumericPreference = function ( prefName, newValue ) { if ( !$.isNumeric( newValue ) ) { return; } newValue = Number( newValue ); if ( mw.user.options.get( prefName ) !== newValue ) { // Save the preference new mw.Api().saveOption( prefName, newValue ); // Update the preference for this session mw.user.options.set( prefName, newValue ); } }; /** * Synchronize the URL with the current state of the filters * without adding an history entry. */ mw.rcfilters.Controller.prototype.replaceUrl = function () { this.uriProcessor.updateURL(); }; /** * Update filter state (selection and highlighting) based * on current URL values. * * @param {boolean} [fetchChangesList=true] Fetch new results into the changes * list based on the updated model. */ mw.rcfilters.Controller.prototype.updateStateFromUrl = function ( fetchChangesList ) { fetchChangesList = fetchChangesList === undefined ? true : !!fetchChangesList; this.uriProcessor.updateModelBasedOnQuery(); // Update the sticky preferences, in case we received a value // from the URL this.updateStickyPreferences(); // Only update and fetch new results if it is requested if ( fetchChangesList ) { this.updateChangesList(); } }; /** * Update the list of changes and notify the model * * @param {Object} [params] Extra parameters to add to the API call * @param {string} [updateMode='filterChange'] One of 'filterChange', 'liveUpdate', 'showNewChanges', 'markSeen' * @return {jQuery.Promise} Promise that is resolved when the update is complete */ mw.rcfilters.Controller.prototype.updateChangesList = function ( params, updateMode ) { updateMode = updateMode === undefined ? this.FILTER_CHANGE : updateMode; if ( updateMode === this.FILTER_CHANGE ) { this.uriProcessor.updateURL( params ); } if ( updateMode === this.FILTER_CHANGE || updateMode === this.SHOW_NEW_CHANGES ) { this.changesListModel.invalidate(); } this.changesListModel.setNewChangesExist( false ); this.updatingChangesList = true; return this._fetchChangesList() .then( // Success function ( pieces ) { var $changesListContent = pieces.changes, $fieldset = pieces.fieldset; this.changesListModel.update( $changesListContent, $fieldset, pieces.noResultsDetails, false, // separator between old and new changes updateMode === this.SHOW_NEW_CHANGES || updateMode === this.LIVE_UPDATE ); }.bind( this ) // Do nothing for failure ) .always( function () { this.updatingChangesList = false; }.bind( this ) ); }; /** * Get an object representing the default parameter state, whether * it is from the model defaults or from the saved queries. * * @return {Object} Default parameters */ mw.rcfilters.Controller.prototype._getDefaultParams = function () { if ( this.savedQueriesModel.getDefault() ) { return this.savedQueriesModel.getDefaultParams(); } else { return this.filtersModel.getDefaultParams(); } }; /** * Query the list of changes from the server for the current filters * * @param {string} counterId Id for this request. To allow concurrent requests * not to invalidate each other. * @param {Object} [params={}] Parameters to add to the query * * @return {jQuery.Promise} Promise object resolved with { content, status } */ mw.rcfilters.Controller.prototype._queryChangesList = function ( counterId, params ) { var uri = this.uriProcessor.getUpdatedUri(), stickyParams = this.filtersModel.getStickyParamsValues(), requestId, latestRequest; params = params || {}; params.action = 'render'; // bypasses MW chrome uri.extend( params ); this.requestCounter[ counterId ] = this.requestCounter[ counterId ] || 0; requestId = ++this.requestCounter[ counterId ]; latestRequest = function () { return requestId === this.requestCounter[ counterId ]; }.bind( this ); // Sticky parameters override the URL params // this is to make sure that whether we represent // the sticky params in the URL or not (they may // be normalized out) the sticky parameters are // always being sent to the server with their // current/default values uri.extend( stickyParams ); return $.ajax( uri.toString(), { contentType: 'html' } ) .then( function ( content, message, jqXHR ) { if ( !latestRequest() ) { return $.Deferred().reject(); } return { content: content, status: jqXHR.status }; }, // RC returns 404 when there is no results function ( jqXHR ) { if ( latestRequest() ) { return $.Deferred().resolve( { content: jqXHR.responseText, status: jqXHR.status } ).promise(); } } ); }; /** * Fetch the list of changes from the server for the current filters * * @return {jQuery.Promise} Promise object that will resolve with the changes list * and the fieldset. */ mw.rcfilters.Controller.prototype._fetchChangesList = function () { return this._queryChangesList( 'updateChangesList' ) .then( function ( data ) { var $parsed; // Status code 0 is not HTTP status code, // but is valid value of XMLHttpRequest status. // It is used for variety of network errors, for example // when an AJAX call was cancelled before getting the response if ( data && data.status === 0 ) { return { changes: 'NO_RESULTS', // We need empty result set, to avoid exceptions because of undefined value fieldset: $( [] ), noResultsDetails: 'NO_RESULTS_NETWORK_ERROR' }; } $parsed = $( '
' ).append( $( $.parseHTML( data ? data.content : '' ) ) ); return this._extractChangesListInfo( $parsed, data.status ); }.bind( this ) ); }; /** * Track usage of highlight feature * * @param {string} action * @param {Array|Object|string} filters */ mw.rcfilters.Controller.prototype._trackHighlight = function ( action, filters ) { filters = typeof filters === 'string' ? { name: filters } : filters; filters = !Array.isArray( filters ) ? [ filters ] : filters; mw.track( 'event.ChangesListHighlights', { action: action, filters: filters, userId: mw.user.getId() } ); }; /** * Track filter grouping usage * * @param {string} action Action taken */ mw.rcfilters.Controller.prototype.trackFilterGroupings = function ( action ) { var controller = this, rightNow = new Date().getTime(), randomIdentifier = String( mw.user.sessionId() ) + String( rightNow ) + String( Math.random() ), // Get all current filters filters = this.filtersModel.findSelectedItems().map( function ( item ) { return item.getName(); } ); action = action || 'filtermenu'; // Check if these filters were the ones we just logged previously // (Don't log the same grouping twice, in case the user opens/closes) // the menu without action, or with the same result if ( // Only log if the two arrays are different in size filters.length !== this.prevLoggedItems.length || // Or if any filters are not the same as the cached filters filters.some( function ( filterName ) { return controller.prevLoggedItems.indexOf( filterName ) === -1; } ) || // Or if any cached filters are not the same as given filters this.prevLoggedItems.some( function ( filterName ) { return filters.indexOf( filterName ) === -1; } ) ) { filters.forEach( function ( filterName ) { mw.track( 'event.ChangesListFilterGrouping', { action: action, groupIdentifier: randomIdentifier, filter: filterName, userId: mw.user.getId() } ); } ); // Cache the filter names this.prevLoggedItems = filters; } }; /** * Apply a change of parameters to the model state, and check whether * the new state is different than the old state. * * @param {Object} newParamState New parameter state to apply * @return {boolean} New applied model state is different than the previous state */ mw.rcfilters.Controller.prototype.applyParamChange = function ( newParamState ) { var after, before = this.filtersModel.getSelectedState(); this.filtersModel.updateStateFromParams( newParamState ); after = this.filtersModel.getSelectedState(); return !OO.compare( before, after ); }; /** * Mark all changes as seen on Watchlist */ mw.rcfilters.Controller.prototype.markAllChangesAsSeen = function () { var api = new mw.Api(); api.postWithToken( 'csrf', { formatversion: 2, action: 'setnotificationtimestamp', entirewatchlist: true } ).then( function () { this.updateChangesList( null, 'markSeen' ); }.bind( this ) ); }; /** * Set the current search for the system. * * @param {string} searchQuery Search query, including triggers */ mw.rcfilters.Controller.prototype.setSearch = function ( searchQuery ) { this.filtersModel.setSearch( searchQuery ); }; /** * Switch the view by changing the search query trigger * without changing the search term * * @param {string} view View to change to */ mw.rcfilters.Controller.prototype.switchView = function ( view ) { this.setSearch( this.filtersModel.getViewTrigger( view ) + this.filtersModel.removeViewTriggers( this.filtersModel.getSearch() ) ); }; /** * Reset the search for a specific view. This means we null the search query * and replace it with the relevant trigger for the requested view * * @param {string} [view='default'] View to change to */ mw.rcfilters.Controller.prototype.resetSearchForView = function ( view ) { view = view || 'default'; this.setSearch( this.filtersModel.getViewTrigger( view ) ); }; }( mediaWiki, jQuery ) );