wikimedia/mediawiki-core

View on GitHub
resources/src/mediawiki.rcfilters/Controller.js

Summary

Maintainability
F
5 days
Test Coverage
var byteLength = require( 'mediawiki.String' ).byteLength,
    UriProcessor = require( './UriProcessor.js' ),
    Controller;

/* eslint no-underscore-dangle: "off" */
/**
 * Controller for the filters in Recent Changes.
 *
 * @class Controller
 * @memberof mw.rcfilters
 * @ignore
 * @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
 * @param {string} config.savedQueriesPreferenceName Where to save the saved queries
 * @param {string} config.daysPreferenceName Preference name for the days filter
 * @param {string} config.limitPreferenceName Preference name for the limit filter
 * @param {string} config.collapsedPreferenceName Preference name for collapsing and showing
 *  the active filters area
 * @param {boolean} [config.normalizeTarget] Dictates whether or not to go through the
 *  title normalization to separate title subpage/parts into the target= url
 *  parameter
 */
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.collapsedPreferenceName = config.collapsedPreferenceName;
    this.normalizeTarget = !!config.normalizeTarget;

    // TODO merge dmConfig.json and config.json virtual files, see T256836
    this.pollingRate = require( './dmConfig.json' ).StructuredChangeFiltersLiveUpdatePollingRate;

    this.requestCounter = {};
    this.uriProcessor = null;
    this.initialized = false;
    this.wereSavedQueriesSaved = false;

    this.prevLoggedItems = [];

    this.FILTER_CHANGE = 'filterChange';
    this.SHOW_NEW_CHANGES = 'showNewChanges';
    this.LIVE_UPDATE = 'liveUpdate';
};

/* Initialization */
OO.initClass( 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
 */
Controller.prototype.initialize = function ( filterStructure, namespaceStructure, tagList, conditionalViews ) {
    var parsedSavedQueries, pieces,
        nsAllContents, nsAllDiscussions,
        displayConfig = mw.config.get( 'StructuredChangeFiltersDisplayConfig' ),
        defaultSavedQueryExists = mw.config.get( 'wgStructuredChangeFiltersDefaultSavedQueryExists' ),
        controller = this,
        views = $.extend( true, {}, conditionalViews ),
        items = [],
        uri = new mw.Uri();

    // Prepare views
    nsAllContents = {
        name: 'all-contents',
        label: mw.msg( 'rcfilters-allcontents-label' ),
        description: '',
        identifiers: [ 'subject' ],
        cssClass: 'mw-changeslist-ns-subject',
        subset: []
    };
    nsAllDiscussions = {
        name: 'all-discussions',
        label: mw.msg( 'rcfilters-alldiscussions-label' ),
        description: '',
        identifiers: [ 'talk' ],
        cssClass: 'mw-changeslist-ns-talk',
        subset: []
    };
    items = [ nsAllContents, nsAllDiscussions ];
    for ( var namespaceID in namespaceStructure ) {
        var label = namespaceStructure[ namespaceID ];
        // Build and clean up the individual namespace items definition
        var isTalk = mw.Title.isTalkNamespace( namespaceID ),
            nsFilter = {
                name: namespaceID,
                label: label || mw.msg( 'blanknamespace' ),
                description: '',
                identifiers: [
                    isTalk ? 'talk' : 'subject'
                ],
                cssClass: 'mw-changeslist-ns-' + namespaceID
            };
        items.push( nsFilter );
        ( isTalk ? nsAllDiscussions : nsAllContents ).subset.push( { filter: 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: ';',
            supportsAll: false,
            fullCoverage: true,
            filters: items
        } ]
    };
    views.invertNamespaces = {
        groups: [
            {
                // Should really be called invertNamespacesGroup; legacy name is used so that
                // saved queries don't break
                name: 'invertGroup',
                type: 'boolean',
                hidden: true,
                filters: [ {
                    name: 'invert',
                    default: '0'
                } ]
            } ]
    };

    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: {
                default: 'rcfilters-tag-prefix-tags',
                inverted: 'rcfilters-tag-prefix-tags-inverted'
            },
            separator: '|',
            supportsAll: false,
            fullCoverage: false,
            filters: tagList
        } ]
    };
    views.invertTags = {
        groups: [
            {
                name: 'invertTagsGroup',
                type: 'boolean',
                hidden: true,
                filters: [ {
                    name: 'inverttags',
                    default: '0'
                } ]
            } ]
    };

    // 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,
                // FIXME: $.isNumeric is deprecated
                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,
                // FIXME: $.isNumeric is deprecated
                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:
    for ( var viewName in views ) {
        var viewData = views[ viewName ];
        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 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();
        }
    }

    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.initialized = true;
    this.switchView( 'default' );

    if ( this.pollingRate ) {
        this._scheduleLiveUpdate();
    }
};

/**
 * Check if the controller has finished initializing.
 *
 * @return {boolean} Controller is initialized
 */
Controller.prototype.isInitialized = function () {
    return this.initialized;
};

/**
 * Extracts information from the changes list DOM
 *
 * @param {jQuery} $root Root DOM to find children from
 * @param {number} [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
 */
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
 */
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
 */
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
 */
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
 */
Controller.prototype.areDefaultsEmpty = function () {
    return $.isEmptyObject( this._getDefaultParams() );
};

/**
 * Empty all selected filters
 */
Controller.prototype.emptyFilters = function () {
    if ( this.applyParamChange( {} ) ) {
        // Only update the changes list if there was a change to actual filters
        this.updateChangesList();
    } else {
        this.uriProcessor.updateURL();
    }
};

/**
 * Update the selected state of a filter
 *
 * @param {string} filterName Filter name
 * @param {boolean} [isSelected] Filter selected state
 */
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
 */
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 );
    }
};

/**
 * Toggle the highlight feature on and off
 */
Controller.prototype.toggleHighlight = function () {
    this.filtersModel.toggleHighlight();
    this.uriProcessor.updateURL();

    if ( this.filtersModel.isHighlightEnabled() ) {
        /**
         * Fires when highlight feature is enabled.
         *
         * @event ~'RcFilters.highlight.enable'
         * @memberof Hooks
         */
        mw.hook( 'RcFilters.highlight.enable' ).fire();
    }
};

/**
 * Toggle the inverted tags feature on and off
 */
Controller.prototype.toggleInvertedTags = function () {
    this.filtersModel.toggleInvertedTags();

    if (
        this.filtersModel.getFiltersByView( 'tags' ).filter(
            function ( filterItem ) {
                return filterItem.isSelected();
            }
        ).length
    ) {
        // Only re-fetch results if there are tags items that are actually selected
        this.updateChangesList();
    } else {
        this.uriProcessor.updateURL();
    }
};

/**
 * Toggle the inverted namespaces feature on and off
 */
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
 */
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
 */
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
 */
Controller.prototype.setHighlightColor = function ( filterName, color ) {
    this.filtersModel.setHighlightColor( filterName, color );
    this.uriProcessor.updateURL();
};

/**
 * Clear highlight for a filter item
 *
 * @param {string} filterName Name of the filter item
 */
Controller.prototype.clearHighlightColor = function ( filterName ) {
    this.filtersModel.clearHighlightColor( filterName );
    this.uriProcessor.updateURL();
};

/**
 * Enable or disable live updates.
 *
 * @param {boolean} enable True to enable, false to disable
 */
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
 */
Controller.prototype._scheduleLiveUpdate = function () {
    setTimeout( this._doLiveUpdate.bind( this ), this.pollingRate * 1000 );
};

/**
 * Perform a live update.
 *
 * @private
 */
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
 */
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
 */
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
 */
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
 */
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
 */
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
 */
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.
 */
Controller.prototype.setDefaultSavedQuery = function ( queryID ) {
    this.savedQueriesModel.setDefault( queryID );
    this._saveSavedQueries();
};

/**
 * Load a saved query
 *
 * @param {string} queryID Query id
 */
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 );
    }
};

/**
 * Check whether the current filter and highlight state exists
 * in the saved queries model.
 *
 * @ignore
 * @return {mw.rcfilters.dm.SavedQueryItemModel} Matching item model
 */
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.
 */
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 ) {
        // Double 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
 */
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
 */
Controller.prototype.updateLimitDefault = function ( newValue ) {
    this.updateNumericPreference( this.limitPreferenceName, newValue );
};

/**
 * Update the days default value
 *
 * @param {number} newValue New value
 */
Controller.prototype.updateDaysDefault = function ( newValue ) {
    this.updateNumericPreference( this.daysPreferenceName, newValue );
};

/**
 * Update the group by page default value
 *
 * @param {boolean} newValue New value
 */
Controller.prototype.updateGroupByPageDefault = function ( newValue ) {
    this.updateNumericPreference( 'usenewrc', Number( newValue ) );
};

/**
 * Update the collapsed state value
 *
 * @param {boolean} isCollapsed Filter area is collapsed
 */
Controller.prototype.updateCollapsedState = function ( isCollapsed ) {
    this.updateNumericPreference( this.collapsedPreferenceName, Number( isCollapsed ) );
};

/**
 * Update a numeric preference with a new value
 *
 * @param {string} prefName Preference name
 * @param {number|string} newValue New value
 */
Controller.prototype.updateNumericPreference = function ( prefName, newValue ) {
    // FIXME: $.isNumeric is deprecated
    // eslint-disable-next-line no-jquery/no-is-numeric
    if ( !$.isNumeric( newValue ) ) {
        return;
    }

    if ( String( mw.user.options.get( prefName ) ) !== String( 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 a history entry.
 */
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.
 */
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
 */
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
 */
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 }
 */
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() )
        .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.
 */
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 = $( '<div>' ).append( $( $.parseHTML(
                    data ? data.content : ''
                ) ) );

                return this._extractChangesListInfo( $parsed, data.status );
            }.bind( this )
        );
};

/**
 * 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
 */
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
 */
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
 */
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
 */
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
 */
Controller.prototype.resetSearchForView = function ( view ) {
    view = view || 'default';

    this.setSearch(
        this.filtersModel.getViewTrigger( view )
    );
};

module.exports = Controller;