Brazen Base Search Enhancer

Base class for search enhancement scripts

当前为 2022-11-08 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.sleazyfork.org/scripts/416105/1114720/Brazen%20Base%20Search%20Enhancer.js

// ==UserScript==
// @name         Brazen Base Search Enhancer
// @namespace    brazenvoid
// @version      3.2.1
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Base class for search enhancement scripts
// ==/UserScript==

// Selectors

const SELECTOR_BUTTON_DISABLE_FILTERS = 'brazen-disable-filters'
const SELECTOR_BUTTON_SUBSCRIPTION_LOADER = 'brazen-subscriptions-loader'
const SELECTOR_BUTTON_SYNC = '#brazen-sync'
const SELECTOR_INPUT_RESTORE_CONFIG = '#brazen-restore-settings'

// Preset filter configuration keys

const CONFIG_PAGINATOR_LIMIT = 'Pagination Limit'
const CONFIG_PAGINATOR_THRESHOLD = 'Pagination Threshold'

const FILTER_DURATION_RANGE = 'Duration'
const FILTER_PERCENTAGE_RATING_RANGE = 'Rating'
const FILTER_SUBSCRIBED_VIDEOS = 'Hide Subscribed Videos'
const FILTER_TEXT_BLACKLIST = 'Blacklist'
const FILTER_TEXT_SEARCH = 'Search'
const FILTER_TEXT_SANITIZATION = 'Text Sanitization Rules'
const FILTER_TEXT_WHITELIST = 'Whitelist'
const FILTER_UNRATED = 'Unrated'
const FILTER_VIEWED_VIDEOS = 'Hide Browsed Videos'
// const FILTER_WATCHED_VIDEOS = 'Hide Watched Videos'

const STORE_SUBSCRIPTIONS = 'Account Subscriptions'
// const STORE_VIEWED_ADDRESSES = 'Viewed Addresses Store'
// const STORE_WATCHED_ADDRESSES = 'Watched Addresses Store'

const UI_MARK_WATCHED_VIDEOS = 'Mark Watched Videos'

// Item preset attributes

const ITEM_ATTRIBUTE_DURATION = 'presetDurationRange'
const ITEM_ATTRIBUTE_NAME = 'presetName'
const ITEM_ATTRIBUTE_RATING = 'presetPercentageRating'
const ITEM_ATTRIBUTE_USER = 'username'

const ITEM_PROCESSED_ONCE = 'scriptItemProcessedOnce'

// Configuration

const OPTION_ALWAYS_SHOW_SETTINGS_PANE = 'Always Show Settings Pane'
const OPTION_DISABLE_COMPLIANCE_VALIDATION = 'Disable All Filters'

class BrazenItemAttributesResolver
{
    /**
     * @callback ItemAttributesResolverCallback
     * @param {JQuery} item
     * @return {*}
     */
    
    /**
     * @param {JQuery.Selector} itemPageLinkSelector
     * @param {JQuery.Selector} itemPageDeepAnalysisSelector
     */
    constructor(itemPageLinkSelector, itemPageDeepAnalysisSelector)
    {
        /**
         * @type {{}}
         * @private
         */
        this._attributes = {}
        
        /**
         * @type {{}}
         * @private
         */
        this._deepAttributes = {}
        
        /**
         * @type {boolean}
         * @private
         */
        this._hasDeepAttributes = false
        
        /**
         * @type {JQuery.Selector}
         * @protected
         */
        this._itemPageLinkSelector = itemPageLinkSelector
        
        /**
         * @type {JQuery.Selector}
         * @protected
         */
        this._itemPageDeepAnalysisSelector = itemPageDeepAnalysisSelector
        
        /**
         * @type {JQuery<HTMLElement> | jQuery | HTMLElement}
         * @private
         */
        this._sandbox = $('<div id="brazen-item-attributes-resolver-sandbox" hidden/>').appendTo('body')
    }
    
    /**
     * @param {string} attributeName
     * @returns {string}
     * @private
     */
    _generateAttributeName(attributeName)
    {
        return 'scriptItemAttribute' + attributeName[0].toUpperCase() + attributeName.slice(1)
    }
    
    /**
     * @param {string} name
     * @param {ItemAttributesResolverCallback} resolutionCallback
     * @returns {this}
     */
    addAttribute(name, resolutionCallback)
    {
        this._attributes[name] = resolutionCallback
        return this
    }
    
    /**
     * @param {string} name
     * @param {ItemAttributesResolverCallback} resolutionCallback
     * @returns {this}
     */
    addDeepAttribute(name, resolutionCallback)
    {
        this._deepAttributes[name] = resolutionCallback
        this._hasDeepAttributes = true
        return this
    }
    
    /**
     * @param {JQuery} item
     * @param {string} attributeName
     * @returns {*}
     */
    getAttribute(item, attributeName)
    {
        return item[0][this._generateAttributeName(attributeName)]
    }
    
    /**
     * @param {JQuery} item
     * @param {Function|null} afterResolutionCallback
     */
    resolveAttributes(item, afterResolutionCallback = null)
    {
        let element = item[0]
        for (const attributeName in this._attributes) {
            element[this._generateAttributeName(attributeName)] = this._attributes[attributeName](item)
        }
        if (this._hasDeepAttributes) {
            this._sandbox.load(item.find(this._itemPageLinkSelector).attr('href') + ' ' + this._itemPageDeepAnalysisSelector, () => {
                for (const attributeName in this._deepAttributes) {
                    element[this._generateAttributeName(attributeName)] = this._deepAttributes[attributeName](this._sandbox)
                }
                this._sandbox.empty()
            })
        }
    }
}

class BrazenBaseSearchEnhancer
{
    /**
     * @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter
     */
    
    /**
     * @callback SearchEnhancerFilterValidationCallback
     * @param {*} configValues
     * @return boolean
     */
    
    /**
     * @callback SearchEnhancerFilterComplianceCallback
     * @param {JQuery} item
     * @param {*} configValues
     * @return {*}
     */
    
    /**
     * @callback SubscriptionsFilterExclusionsCallback
     * @return {boolean}
     */
    
    /**
     * @return BrazenBaseSearchEnhancer
     */
    static initialize()
    {
        BrazenBaseSearchEnhancer.throwOverrideError()
    }
    
    static throwOverrideError()
    {
        throw new Error('Method must be overridden.')
    }
    
    /**
     * @param {Object} configuration
     * @param {Object} configuration.about
     * @param {string} configuration.about.name
     * @param {string} configuration.about.link
     * @param {string} configuration.about.version
     * @param {string} configuration.about.released
     * @param {boolean} configuration.isUserLoggedIn
     * @param {JQuery.Selector} configuration.itemPageDeepAnalysisSelector
     * @param {JQuery.Selector} configuration.itemPageLinkSelector
     * @param {string} configuration.itemSelectors
     */
    constructor(configuration)
    {
        /**
         * Array of item compliance filters ordered in intended sequence of execution
         * @type {ComplianceFilter[]}
         * @private
         */
        this._complianceFilters = []
        
        /**
         * @type {boolean}
         * @private
         */
        this._isUserLoggedIn = configuration.isUserLoggedIn
        
        /**
         * @type {string}
         * @private
         */
        this._itemClassesSelector = configuration.itemSelectors
        
        /**
         * Pagination manager
         * @type BrazenPaginator|null
         * @private
         */
        this._paginator = null
        
        /**
         * @type {BrazenItemAttributesResolver}
         * @protected
         */
        this._itemAttributesResolver = new BrazenItemAttributesResolver(configuration.itemPageLinkSelector, configuration.itemPageDeepAnalysisSelector)
        
        /**
         * @type {{name: string, link: string, version: string, released: string}}
         * @private
         */
        this._scriptBrandingInformation = configuration.about
        
        /**
         * @type {BrazenSubscriptionsLoader}
         * @protected
         */
        this._subscriptionsLoader = new BrazenSubscriptionsLoader(
            (status) => this._subscriptionsLoaderButton.text(status),
            (subscriptions) => {
                this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' + subscriptions.join('""') + '"' : ''
                this._configurationManager.save()
                
                BrazenUIGenerator.addTransientChangeToButton(
                    this._subscriptionsLoaderButton, 'btn-secondary', 'btn-success', 'Load Subscriptions', 'Subscriptions Loaded!')
            })
        
        /**
         * @type {JQuery}
         * @protected
         */
        this._subscriptionsLoaderButton = null
        
        /**
         * @type {JQuery<HTMLElement> | jQuery | HTMLElement}
         * @protected
         */
        this._syncConfigButton = null
        
        /**
         * @type {BrazenUIGenerator}
         * @protected
         */
        this._uiGen = new BrazenUIGenerator(configuration.about.name)
        
        /**
         * Local storage store with defaults
         * @type {BrazenConfigurationManager}
         * @protected
         */
        this._configurationManager = BrazenConfigurationManager.create(this._uiGen)
            .addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION, 'Disables all search filters.')
            .addFlagField(OPTION_ALWAYS_SHOW_SETTINGS_PANE, 'Always show configuration interface.')
            .onExternalConfigurationChange(() => this._syncConfigButton.prop('disable', false))
        
        // Events
        
        /**
         * Operations to perform after script initialization
         * @type {Function}
         * @protected
         */
        this._onAfterInitialization = null
        
        /**
         * Operations to perform after UI generation
         * @type {Function}
         * @protected
         */
        this._onAfterUIBuild = null
        
        /**
         * Operations to perform before compliance validation. This callback can also be used to skip compliance validation by returning false.
         * @type {null}
         * @protected
         */
        this._onBeforeCompliance = null
        
        /**
         * Operations to perform before UI generation
         * @type {Function}
         * @protected
         */
        this._onBeforeUIBuild = null
        
        /**
         * Operations to perform after compliance checks, the first time a item is retrieved
         * @type {Function}
         * @param {JQuery} item
         * @protected
         */
        this._onFirstHitAfterCompliance = null
        
        /**
         * Operations to perform before compliance checks, the first time a item is retrieved
         * @type {Function}
         * @param {JQuery} item
         * @protected
         */
        this._onFirstHitBeforeCompliance = null
        
        /**
         * Get item lists from the page
         * @type {Function}
         * @protected
         */
        this._onGetItemLists = null
        
        /**
         * Logic to hide a non-compliant item
         * @type {Function}
         * @param {JQuery} item
         * @protected
         */
        this._onItemHide = (item) => item.addClass('noncompliant-item').hide()
        
        /**
         * Logic to show compliant item
         * @type {Function}
         * @param {JQuery} item
         * @protected
         */
        this._onItemShow = (item) => item.removeClass('noncompliant-item').show()
        
        /**
         * Must be used to compose the UI
         * @type {Function}
         * @protected
         */
        this._onUIBuild = null
        
        /**
         * Validate initiating initialization.
         * Can be used to stop script init on specific pages or vice versa
         * @type {Function}
         * @protected
         */
        this._onValidateInit = () => true
    }
    
    /**
     * @param {JQuery} button
     * @private
     */
    _onGlobalDisableClick(button)
    {
        if (button.hasClass('btn-danger')) {
            this._configurationManager.getField(OPTION_DISABLE_COMPLIANCE_VALIDATION).value = true
            button.removeClass('btn-danger').addClass('btn-success').text('Enable Filters')
        } else {
            this._configurationManager.getField(OPTION_DISABLE_COMPLIANCE_VALIDATION).value = false
            button.removeClass('btn-success').addClass('btn-danger').text('Disable Filters')
        }
        this._validateCompliance()
    }
    
    /**
     * @private
     */
    _buildNativeUI()
    {
        // Shortcuts Menu
        // -- Add sync config button
        
        this._syncConfigButton = this._uiGen
            .createShortcutButton('Sync Config', 'warning', () => this._onResetConfig())
            .attr('id', SELECTOR_BUTTON_SYNC.slice(1))
            .prop('disabled', true)
        
        // -- Add global disable button
        
        let globalDisable = this._configurationManager.getField(OPTION_DISABLE_COMPLIANCE_VALIDATION)
        if (globalDisable.value) {
            this._uiGen.createShortcutButton('Enable Filters', 'success', (button) => this._onGlobalDisableClick(button))
        } else {
            this._uiGen.createShortcutButton('Disable Filters', 'danger', (button) => this._onGlobalDisableClick(button))
        }
        
        // Accordion Tabs
        // -- Add about tab
        
        this._uiGen.createAccordionTab('About', [
            this._uiGen.createFormLabelGroup(
                'Name', `<a href="${this._scriptBrandingInformation.link}" target="_blank">${this._scriptBrandingInformation.name}</a>`),
            this._uiGen.createFormLabelGroup(
                'Version', this._scriptBrandingInformation.version),
            this._uiGen.createFormLabelGroup(
                'Release Date', this._scriptBrandingInformation.released),
            this._uiGen.createFormLabelGroup(
                'Author', '<a href="https://greasyfork.org/en/users/220654-brazenvoid" target="_blank">Brazenvoid</a>'),
            this._uiGen.createFormLabelGroup(
                'Support', '<a href="https://www.patreon.com/brazen_scripts" target="_blank">Patreon</a>'),
        ])
        
        // Actions Menu
        // -- Add Apply Config Button
        
        this._uiGen.createActionsButton('Apply', 'warning', () => {
            this._configurationManager.update()
            this._validateCompliance()
        })
        
        // -- Add Save Config Button
        
        this._uiGen.createActionsButton('Save', 'success', () => {
            this._configurationManager.save()
            this._validateCompliance()
        })
        
        // -- Add Reset Config Button
        
        this._uiGen.createActionsButton('Reset', 'danger', () => this._onResetConfig())
    }
    
    /**
     * @param item
     * @private
     */
    _complyItem(item)
    {
        let globalDisableState = this._configurationManager.getValue(OPTION_DISABLE_COMPLIANCE_VALIDATION)
        let globalBeforeComplianceValidation = Utilities.callEventHandler(this._onBeforeCompliance, [item], true)
        let itemComplies = true
        
        if (!globalDisableState && globalBeforeComplianceValidation && this._validateItemWhiteList(item)) {
            let configField
            
            for (let complianceFilter of this._complianceFilters) {
                
                configField = this._configurationManager.getFieldOrFail(complianceFilter.configKey)
                if (complianceFilter.validate(this._configurationManager.getValue(configField))) {
                    
                    itemComplies = complianceFilter.comply(item, this._configurationManager.getValue(configField))
                    
                    if (!itemComplies) {
                        this._configurationManager.trackState(configField)
                        break
                    }
                }
            }
        }
        itemComplies ? Utilities.callEventHandler(this._onItemShow, [item]) : Utilities.callEventHandler(this._onItemHide, [item])
        item.css('opacity', 'unset')
    }
    
    /**
     * @param {string} configKey
     * @param {SearchEnhancerFilterValidationCallback|null} validationCallback
     * @param {SearchEnhancerFilterComplianceCallback} complianceCallback
     * @protected
     */
    _addItemComplexComplianceFilter(configKey, validationCallback, complianceCallback)
    {
        this._addItemComplianceFilter(configKey, complianceCallback, validationCallback)
    }
    
    /**
     * @param {string} configKey
     * @param {SearchEnhancerFilterComplianceCallback|string} action
     * @param {SearchEnhancerFilterValidationCallback|null} validationCallback
     * @protected
     */
    _addItemComplianceFilter(configKey, action, validationCallback = null)
    {
        let configType = this._configurationManager.getField(configKey).type
        if (typeof action === 'string') {
            let attributeName = action
            switch (configType) {
                case CONFIG_TYPE_FLAG:
                    action = (item) => this.getItemAttribute(item, attributeName)
                    break
                case CONFIG_TYPE_RANGE:
                    action = (item, range) => Validator.isInRange(this.getItemAttribute(item, attributeName), range.minimum, range.maximum)
                    break
                default:
                    throw new Error('Associated config type requires explicit action callback definition.')
            }
        }
        if (validationCallback === null) {
            switch (configType) {
                case CONFIG_TYPE_FLAG:
                case CONFIG_TYPE_RADIOS_GROUP:
                case CONFIG_TYPE_SELECT:
                    validationCallback = (value) => value
                    break
                case CONFIG_TYPE_CHECKBOXES_GROUP:
                    validationCallback = (valueKeys) => valueKeys.length
                    break
                case CONFIG_TYPE_NUMBER:
                    validationCallback = (value) => value > 0
                    break
                case CONFIG_TYPE_RANGE:
                    validationCallback = (range) => range.minimum > 0 || range.maximum > 0
                    break
                case CONFIG_TYPE_RULESET:
                    validationCallback = (rules) => rules.length
                    break
                case CONFIG_TYPE_TEXT:
                    validationCallback = (value) => value.length
                    break
                default:
                    throw new Error('Associated config type requires explicit validation callback definition.')
            }
        }
        this._complianceFilters.push({
            configKey: configKey,
            validate: validationCallback,
            comply: action,
        })
    }
    
    /**
     * @param {string} helpText
     * @protected
     */
    _addItemBlacklistFilter(helpText)
    {
        this._configurationManager
            .addRulesetField(FILTER_TEXT_BLACKLIST, helpText)
            .onOptimize = (rules) => Utilities.buildWholeWordMatchingRegex(rules) ?? ''
        
        this._addItemComplexComplianceFilter(
            FILTER_TEXT_BLACKLIST,
            (value) => value.length,
            (item, value) => this.getItemAttribute(item, ITEM_ATTRIBUTE_NAME).match(value) === null,
        )
    }
    
    /**
     * @param {string|null} helpText
     * @protected
     */
    _addItemDurationRangeFilter(helpText = null)
    {
        this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 'seconds', 0, 100000, helpText ?? 'Filter items by duration.')
        
        this._addItemComplianceFilter(FILTER_DURATION_RANGE, (item, range) => {
            let duration = this.getItemAttribute(item, ITEM_ATTRIBUTE_DURATION)
            return duration > 0 ? Validator.isInRange(duration, range.minimum, range.maximum) : duration === -1
        })
    }
    
    /**
     * @param {string|null} helpText
     * @param {string|null} unratedHelpText
     * @protected
     */
    _addItemPercentageRatingRangeFilter(helpText = null, unratedHelpText = null)
    {
        this._configurationManager.addRangeField(FILTER_PERCENTAGE_RATING_RANGE, '%', 0, 100000, helpText ?? 'Filter items by percentage rating.')
            .addFlagField(FILTER_UNRATED, unratedHelpText ?? 'Hide items with zero or no rating.')
        
        this._addItemComplianceFilter(FILTER_PERCENTAGE_RATING_RANGE, (item, range) => {
            let rating = this.getItemAttribute(item, ITEM_ATTRIBUTE_RATING)
            return rating === 0 ? !this._configurationManager.getValue(FILTER_UNRATED) : Validator.isInRange(rating, range.minimum, range.maximum)
        })
    }
    
    /**
     * @param {string} helpText
     * @protected
     */
    _addItemTextSanitizationFilter(helpText)
    {
        let field = this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION, helpText, false)
        field.onTranslateFromUI = (rules) => {
            let sanitizationRules = {}, fragments, validatedTargetWords
            for (let sanitizationRule of rules) {
                
                if (sanitizationRule.includes('=')) {
                    fragments = sanitizationRule.split('=')
                    if (fragments[0] === '') {
                        fragments[0] = ' '
                    }
                    
                    validatedTargetWords = Utilities.trimAndKeepNonEmptyStrings(fragments[1].split(','))
                    if (validatedTargetWords.length) {
                        sanitizationRules[fragments[0]] = validatedTargetWords
                    }
                }
            }
            return sanitizationRules
        }
        field.onFormatForUI = (rules) => {
            let sanitizationRulesText = []
            for (let substitute in rules) {
                sanitizationRulesText.push(substitute + '=' + rules[substitute].join(','))
            }
            return sanitizationRulesText
            
        }
        field.onOptimize = (rules) => {
            let optimizedRules = {}
            for (const substitute in rules) {
                optimizedRules[substitute] = Utilities.buildWholeWordMatchingRegex(rules[substitute])
            }
            return optimizedRules
        }
    }
    
    /**
     * @param {string|null} helpText
     * @protected
     */
    _addItemTextSearchFilter(helpText = null)
    {
        this._configurationManager.addTextField(FILTER_TEXT_SEARCH, helpText ?? 'Show videos with these comma separated words in their names.')
        this._addItemComplianceFilter(FILTER_TEXT_SEARCH, (item, value) => this.getItemAttribute(item, ITEM_ATTRIBUTE_NAME).includes(value))
    }
    
    // /**
    //  * @protected
    //  */
    // _addItemCustomWatchedFilters ()
    // {
    //     this._configurationManager.
    //         addFlagField(FILTER_VIEWED_VIDEOS, 'Tracks and hides all items present on opened pages on next page load. Should be purged regularly.').
    //         addFlagField(FILTER_WATCHED_VIDEOS, 'Tracks and hides 3,000 recent seen items.').
    //         addTextField(STORE_VIEWED_ADDRESSES, '').
    //         addTextField(STORE_WATCHED_ADDRESSES, '')
    // }
    
    /**
     * @param {string} helpText
     * @protected
     */
    _addItemWhitelistFilter(helpText)
    {
        this._configurationManager
            .addRulesetField(FILTER_TEXT_WHITELIST, helpText)
            .onOptimize = (rules) => Utilities.buildWholeWordMatchingRegex(rules)
    }
    
    _addPaginationConfiguration()
    {
        this._configurationManager
            .addNumberField(CONFIG_PAGINATOR_LIMIT, 'pages', 1, 50, 'Limit paginator to concatenate the specified number of maximum pages.')
            .addNumberField(CONFIG_PAGINATOR_THRESHOLD, 'results', 1, 1000, 'Make paginator ensure the specified number of minimum results.')
    }
    
    /**
     * @param {SubscriptionsFilterExclusionsCallback} exclusionsCallback Add page exclusions here
     * @return {BrazenSubscriptionsLoader}
     * @protected
     */
    _addSubscriptionsFilter(exclusionsCallback)
    {
        this._configurationManager
            .addFlagField(FILTER_SUBSCRIBED_VIDEOS, 'Hide videos from subscribed channels.')
            .addTextField(STORE_SUBSCRIPTIONS, 'Recorded subscription accounts.')
        
        this._addItemComplexComplianceFilter(
            FILTER_SUBSCRIBED_VIDEOS,
            (value) => value && this._isUserLoggedIn && exclusionsCallback(),
            (item) => {
                let username = this.getItemAttribute(item, ITEM_ATTRIBUTE_USER)
                return username === false ?
                    true : !(new RegExp('"([^"]*' + username + '[^"]*)"')).test(this._configurationManager.getValue(STORE_SUBSCRIPTIONS))
            })
        return this._subscriptionsLoader
    }
    
    /**
     * Filters items as per settings
     * @param {JQuery} itemsList
     * @param {boolean} fromObserver
     * @protected
     */
    _complyItemsList(itemsList, fromObserver = false)
    {
        let items = fromObserver ? itemsList.filter(this._itemClassesSelector) : itemsList.find(this._itemClassesSelector)
        items.css('opacity', 0.5).each((index, element) => {
            let item = $(element)
            
            // First run processing
            
            if (typeof element[ITEM_PROCESSED_ONCE] === 'undefined') {
                element[ITEM_PROCESSED_ONCE] = false
                this._itemAttributesResolver.resolveAttributes(item)
                Utilities.callEventHandler(this._onFirstHitBeforeCompliance, [item])
            }
            
            // Compliance filtering
            
            this._complyItem(item)
            
            // After first run processing
            
            if (!element[ITEM_PROCESSED_ONCE]) {
                Utilities.callEventHandler(this._onFirstHitAfterCompliance, [item])
                element[ITEM_PROCESSED_ONCE] = true
            }
        })
        this._configurationManager.updateTrackersInterface()
    }
    
    /**
     * @protected
     * @return {JQuery}
     */
    _createPaginationTab()
    {
        return this._uiGen.createAccordionTab('Pagination', [
            this._configurationManager.createElement(CONFIG_PAGINATOR_THRESHOLD),
            this._configurationManager.createElement(CONFIG_PAGINATOR_LIMIT),
        ])
    }
    
    /**
     * @protected
     * @return {JQuery}
     */
    _createSettingsBackupRestoreTab()
    {
        let inputControl = $(`<input id="${SELECTOR_INPUT_RESTORE_CONFIG}" class="form-control" type="text">`)
        
        return this._uiGen.createAccordionTab('Backup & Restore', [
            this._uiGen.createFormHelpText(
                this._uiGen.createFormGroup(
                    this._uiGen
                        .createButton('Backup Configuration', 'secondary', (button) => this._onBackupSettings(button))
                        .addClass('btn-block'),
                ),
                null,
                'Backup settings to the clipboard.',
            ),
            this._uiGen.createFormHelpText(this._uiGen.createFormGroup(inputControl), inputControl, 'Paste backed up configuration.'),
            this._uiGen.createFormHelpText(
                this._uiGen.createFormGroup(
                    this._uiGen
                        .createButton('Restore Configuration', 'danger', (button) => this._onRestoreSettings(button))
                        .addClass('btn-block'),
                ),
                null,
                'Restore backup settings.',
            ),
        ])
    }
    
    /**
     * @protected
     * @return {JQuery}
     */
    _createSubscriptionLoaderTab()
    {
        this._subscriptionsLoaderButton = this._uiGen.createButton('Load Subscriptions', 'secondary', (button) => {
            if (this._isUserLoggedIn) {
                this._subscriptionsLoaderButton.removeClass('btn-secondary').addClass('btn-warning')
                this._subscriptionsLoader.run()
            } else {
                BrazenUIGenerator.addTransientChangeToButton(
                    button, 'btn-secondary', 'btn-danger', 'Load Subscriptions', 'Please login before proceeding.')
            }
        }).attr('id', SELECTOR_BUTTON_SUBSCRIPTION_LOADER)
        
        return this._uiGen.createFormHelpText(
            this._uiGen.createFormGroup(this._subscriptionsLoaderButton), null, 'Makes a copy of your subscriptions in cache for related filters.')
    }
    
    /**
     * @param {JQuery} button
     * @private
     */
    _onBackupSettings(button)
    {
        navigator.clipboard
            .writeText(this._configurationManager.backup())
            .then(() => BrazenUIGenerator.addTransientChangeToButton(
                button, 'btn-secondary', 'btn-success', 'Backup Configuration', 'Config Copied To Clipboard!'))
            .catch(() => button.removeClass('btn-secondary').addClass('btn-danger').text('Error!'))
    }
    
    /**
     * @private
     */
    _onResetConfig()
    {
        this._configurationManager.revertChanges()
        this._validateCompliance()
    }
    
    /**
     * @param {JQuery} button
     * @return {boolean}
     * @private
     */
    _onRestoreSettings(button)
    {
        let settings = $('#' + SELECTOR_INPUT_RESTORE_CONFIG).val().trim()
        if (!settings) {
            BrazenUIGenerator.addTransientContentChangeToButton(button, 'Restore Configuration', 'No Configuration Found!')
        } else {
            try {
                this._configurationManager.restore(settings)
                
                BrazenUIGenerator.addTransientChangeToButton(
                    button, 'btn-danger', 'btn-success', 'Restore Configuration', 'Configuration Restored!')
                
                this._validateCompliance()
            } catch (e) {
                button.text('Error!')
                return false
            }
        }
        return true
    }
    
    /**
     * @protected
     */
    _setupUI()
    {
        this.constructor.throwOverrideError()
    }
    
    /**
     * @protected
     */
    _setupAttributes()
    {
        this.constructor.throwOverrideError()
    }
    
    /**
     * @protected
     */
    _setupCompliance()
    {
        this.constructor.throwOverrideError()
    }
    
    /**
     * @protected
     */
    _setupConfiguration()
    {
        this.constructor.throwOverrideError()
    }
    
    /**
     * @protected
     */
    _setupComplianceFilters()
    {
        this.constructor.throwOverrideError()
    }
    
    /**
     * @param {boolean} firstRun
     * @protected
     */
    _validateCompliance(firstRun = false)
    {
        let itemLists = Utilities.callEventHandler(this._onGetItemLists)
        if (!firstRun) {
            this._configurationManager.resetTrackers()
            itemLists.each((index, itemsList) => {
                this._complyItemsList($(itemsList))
            })
        } else {
            itemLists.each((index, itemList) => {
                let itemListObject = $(itemList)
                
                if (this._paginator && itemListObject.is(this._paginator.getListSelector())) {
                    ChildObserver.create().onNodesAdded((itemsAdded) => {
                        this._complyItemsList($(itemsAdded), true)
                        this._paginator.run(this._configurationManager.getValue(CONFIG_PAGINATOR_THRESHOLD),
                            this._configurationManager.getValue(CONFIG_PAGINATOR_LIMIT))
                    }).observe(itemList)
                } else {
                    ChildObserver.create().onNodesAdded((itemsAdded) => this._complyItemsList($(itemsAdded), true)).observe(itemList)
                }
                
                this._complyItemsList(itemListObject)
            })
        }
        if (this._paginator) {
            this._paginator.run(this._configurationManager.getValue(CONFIG_PAGINATOR_THRESHOLD), this._configurationManager.getValue(CONFIG_PAGINATOR_LIMIT))
        }
    }
    
    /**
     * @param {JQuery} item
     * @return {boolean}
     * @protected
     */
    _validateItemWhiteList(item)
    {
        let field = this._configurationManager.getField(FILTER_TEXT_WHITELIST)
        if (field) {
            let validationResult = field.value.length ? Validator.regexMatches(this.getItemAttribute(item, ITEM_ATTRIBUTE_NAME), field.optimized) : true
            if (!validationResult) {
                this._configurationManager.trackState(field)
            }
            return validationResult
        }
        return true
    }
    
    /**
     * @param {JQuery} item
     * @param {string} attributeName
     * @returns {*}
     */
    getItemAttribute(item, attributeName)
    {
        return this._itemAttributesResolver.getAttribute(item, attributeName)
    }
    
    /**
     * Initialize the script and do basic UI removals
     */
    initialize()
    {
        if (Utilities.callEventHandler(this._onValidateInit)) {
            
            this._setupConfiguration()
            this._configurationManager.initialize(this._scriptBrandingInformation.name)
            
            this._setupAttributes()
            this._setupCompliance()
            this._setupComplianceFilters()
            this._setupUI()
            
            // Paginator
            
            if (this._paginator) {
                this._paginator.initialize()
            }
            
            // UI generation
            
            Utilities.callEventHandler(this._onBeforeUIBuild)
            Utilities.callEventHandler(this._onUIBuild)
            
            this._buildNativeUI()
            this._uiGen.initialize()
            
            Utilities.callEventHandler(this._onAfterUIBuild)
            
            this._configurationManager.updateInterface()
            
            // First run
            
            this._validateCompliance(true)
            
            Utilities.callEventHandler(this._onAfterInitialization)
        }
    }
    
    /**
     * @returns {boolean}
     */
    isUserLoggedIn()
    {
        return this._isUserLoggedIn
    }
}