Brazen Base Search Enhancer

Base class for search enhancement scripts

2021/06/04のページです。最新版はこちら

このスクリプトは単体で利用できません。右のようなメタデータを含むスクリプトから、ライブラリとして読み込まれます: // @require https://update.sleazyfork.org/scripts/416105/937540/Brazen%20Base%20Search%20Enhancer.js

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==UserScript==
// @name         Brazen Base Search Enhancer
// @namespace    brazenvoid
// @version      3.0.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Base class for search enhancement scripts
// ==/UserScript==

// Selectors

const SELECTOR_RESTORE_CONFIG_INPUT = 'brazen-restore-settings'
const SELECTOR_SUBSCRIPTION_LOADER_BUTTON = 'brazen-subscriptions-loader'

// 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_PRESET_DURATION_RANGE = 'presetDurationRange'
const ITEM_ATTRIBUTE_PRESET_NAME = 'presetName'
const ITEM_ATTRIBUTE_PRESET_PERCENTAGE_RATING = 'presetPercentageRating'
const ITEM_ATTRIBUTE_PRESET_USERNAME = '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._deepAttributes) {
			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}
	 */
	
	/**
	 * @callback SubscriptionsFilterUsernameCallback
	 * @param {JQuery} item
	 * @return {boolean|string}
	 */
	
	/**
	 * @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.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, version: string, released: string}}
		 * @private
		 */
		this._scriptBrandingInformation = configuration.about
		
		/**
		 * @type {StatisticsRecorder}
		 * @protected
		 */
		this._statistics = new StatisticsRecorder
		
		/**
		 * @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 = $('<button id="brazen-sync-config-btn" style="position: fixed">${SVG_REFRESH}</button>')
			.hide()
			.appendTo($('body'))
			.on('click', () => {
				this._onResetSettings()
				this._syncConfigButton.hide()
			})
		
		/**
		 * @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((manager) => {
				                          this._syncConfigButton.show()
			                          })
		
		// 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
		
		/**
		 * Get item name from its node
		 * @type {Function}
		 * @param {JQuery} item
		 * @protected
		 */
		this._onGetItemName = 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 item
	 * @private
	 */
	_complyItem (item)
	{
		let itemComplies = true
		
		if (!this._configurationManager.getValue(OPTION_DISABLE_COMPLIANCE_VALIDATION) &&
			this._validateItemWhiteList(item) &&
			Utilities.callEventHandler(this._onBeforeCompliance, [item], true)
		) {
			let configField
			
			for (let complianceFilter of this._complianceFilters) {
				
				configField = this._configurationManager.getFieldOrFail(complianceFilter.configKey)
				if (complianceFilter.validate(configField.optimized ?? configField.value)) {
					
					itemComplies = complianceFilter.comply(item, configField.optimized ?? configField.value)
					this._statistics.record(complianceFilter.configKey, itemComplies)
					
					if (!itemComplies) {
						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, 3, helpText, null, null, (rules) => Utilities.buildWholeWordMatchingRegex(rules) ?? '')
		this._addItemComplexComplianceFilter(
			FILTER_TEXT_BLACKLIST,
			(value) => value !== '',
			(item, value) => this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_NAME).match(value) === null,
		)
	}
	
	/**
	 * @param {JQuery.Selector} durationNodeSelector
	 * @param {string|null} helpText
	 * @protected
	 */
	_addItemDurationRangeFilter (durationNodeSelector, helpText = null)
	{
		this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 'seconds', 0, 100000, helpText ?? 'Filter items by duration.')
		
		this._itemAttributesResolver.addAttribute(ITEM_ATTRIBUTE_PRESET_DURATION_RANGE, (item) => {
			let duration = 0
			let durationNode = item.find(durationNodeSelector)
			if (durationNode.length) {
				duration = durationNode.text().split(':')
				duration = (parseInt(duration[0]) * 60) + parseInt(duration[1])
			}
			if (duration === 0 && !durationNode.length) {
				duration = -1
			}
			return duration
		})
		
		this._addItemComplianceFilter(FILTER_DURATION_RANGE, (item, range) => {
			let duration = this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_DURATION_RANGE)
			return duration > 0 ? Validator.isInRange(duration, range.minimum, range.maximum) : duration === -1
		})
	}
	
	/**
	 * @param {JQuery.Selector} ratingNodeSelector
	 * @param {string|null} helpText
	 * @param {string|null} unratedHelpText
	 * @protected
	 */
	_addItemPercentageRatingRangeFilter (ratingNodeSelector, 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._itemAttributesResolver.addAttribute(ITEM_ATTRIBUTE_PRESET_PERCENTAGE_RATING, (item) => {
			let rating = item.find(ratingNodeSelector)
			return rating.length === 0 ? 0 : parseInt(rating.text().replace('%', ''))
		})
		
		this._addItemComplianceFilter(FILTER_PERCENTAGE_RATING_RANGE, (item, range) => {
			let rating = this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_PERCENTAGE_RATING)
			return rating === 0 ? !this._configurationManager.getValue(FILTER_UNRATED) : Validator.isInRange(rating, range.minimum, range.maximum)
		})
	}
	
	/**
	 * @param {string} helpText
	 * @protected
	 */
	_addItemTextSanitizationFilter (helpText)
	{
		this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION, 2, helpText, (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
		}, (rules) => {
			let sanitizationRulesText = []
			for (let substitute in rules) {
				sanitizationRulesText.push(substitute + '=' + rules[substitute].join(','))
			}
			return sanitizationRulesText
			
		}, (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_PRESET_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, 3, helpText, null, null, (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
	 * @param {SubscriptionsFilterUsernameCallback} getItemUsername Return username of the item or return false to skip
	 * @return {BrazenSubscriptionsLoader}
	 * @protected
	 */
	_addSubscriptionsFilter (exclusionsCallback, getItemUsername)
	{
		this._configurationManager.addFlagField(FILTER_SUBSCRIBED_VIDEOS, 'Hide videos from subscribed channels.')
		    .addTextField(STORE_SUBSCRIPTIONS, 'Recorded subscription accounts.')
		
		this._itemAttributesResolver.addAttribute(ITEM_ATTRIBUTE_PRESET_USERNAME, (item) => getItemUsername(item))
		
		this._addItemComplexComplianceFilter(
			FILTER_SUBSCRIBED_VIDEOS,
			(value) => value && this._isUserLoggedIn && exclusionsCallback(),
			(item) => {
				let username = this.getItemAttribute(item, ITEM_ATTRIBUTE_PRESET_USERNAME)
				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._statistics.updateUI()
	}
	
	/**
	 * @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_RESTORE_CONFIG_INPUT}" 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.')
			}
			button.prop('disabled', false)
			
		}).attr('id', SELECTOR_SUBSCRIPTION_LOADER_BUTTON)
		
		return this._uiGen.createFormHelpText(
			this._uiGen.createFormGroup(this._subscriptionsLoaderButton), null, 'Makes a copy of your subscriptions in cache for related filters.')
	}
	
	/**
	 * @private
	 */
	_onApplyNewSettings ()
	{
		this._configurationManager.update()
		this._validateCompliance()
	}
	
	/**
	 * @param {JQuery} button
	 * @private
	 */
	_onBackupSettings (button)
	{
		button.prop('disabled', false)
		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
	 */
	_onResetSettings ()
	{
		this._configurationManager.revertChanges()
		this._validateCompliance()
	}
	
	/**
	 * @param {JQuery} button
	 * @private
	 */
	_onRestoreSettings (button)
	{
		let settings = $('#' + SELECTOR_RESTORE_CONFIG_INPUT).val().trim()
		if (!settings) {
			button.prop('disabled', false)
			BrazenUIGenerator.addTransientContentChangeToButton(button, 'Restore Configuration', 'No Configuration Found!')
		} else {
			try {
				this._configurationManager.restore(settings)
				
				button.prop('disabled', false)
				BrazenUIGenerator.addTransientChangeToButton(
					button, 'btn-danger', 'btn-success', 'Restore Configuration', 'Configuration Restored!')
				
				this._validateCompliance()
			} catch (e) {
				button.text('Error!')
			}
		}
	}
	
	/**
	 * @private
	 */
	_onSaveSettings ()
	{
		this._onApplyNewSettings()
		this._configurationManager.save()
	}
	
	/**
	 * @param {boolean} firstRun
	 * @protected
	 */
	_validateCompliance (firstRun = false)
	{
		let itemLists = Utilities.callEventHandler(this._onGetItemLists)
		if (!firstRun) {
			this._statistics.reset()
			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_PRESET_NAME), field.optimized) : true
			this._statistics.record(FILTER_TEXT_WHITELIST, validationResult)
			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
	 */
	init ()
	{
		if (Utilities.callEventHandler(this._onValidateInit)) {
			
			this._configurationManager.initialize(this._scriptBrandingInformation.name)
			
			this._itemAttributesResolver
			    .addAttribute(
				    ITEM_ATTRIBUTE_PRESET_NAME,
				    (item) => Utilities.callEventHandlerOrFail('getItemName', this._onGetItemName, [item]),
			    )
			if (this._paginator) {
				this._paginator.initialize()
			}
			
			Utilities.callEventHandler(this._onBeforeUIBuild)
			Utilities.callEventHandler(this._onUIBuild)
			this._uiGen.createAccordionTab('About', [
				this._uiGen.createFormLabelGroup('Name', this._scriptBrandingInformation.name),
				this._uiGen.createFormLabelGroup('Version', this._scriptBrandingInformation.version),
				this._uiGen.createFormLabelGroup('Release Date', this._scriptBrandingInformation.released),
				this._uiGen.createFormLabelGroup('Author', 'Brazenvoid'),
				this._uiGen.createFormLabelGroup('Support', 'https://www.patreon.com/brazen_scripts'),
			])
			this._uiGen.initializeEvents()
			Utilities.callEventHandler(this._onAfterUIBuild)
			
			this._configurationManager.updateInterface()
			
			this._validateCompliance(true)
			
			Utilities.callEventHandler(this._onAfterInitialization)
		}
	}
	
	/**
	 * @returns {boolean}
	 */
	isUserLoggedIn ()
	{
		return this._isUserLoggedIn
	}
}