Brazen Framework - Framework

Main class of the Brazen user scripts framework

As of 16.07.2026. See апошняя версія.

This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.sleazyfork.org/scripts/416105/1877285/Brazen%20Framework%20-%20Framework.js

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Brazen Framework - Framework
// @namespace    brazenvoid
// @version      12.1.1
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Main class of the Brazen user scripts framework
// ==/UserScript==

const ICON_RECYCLE = '&#x267B'

// Identification Classes

const CLASS_COMPLIANT_ITEM = 'brazen-compliant-item'
const CLASS_NON_COMPLIANT_ITEM = 'brazen-noncompliant-item'

// 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_TAG_BLACKLIST = 'tag-blacklist'
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 STORE_SUBSCRIPTIONS = 'account-subscriptions'

// Item preset attributes

const ITEM_NAME = 'name'
const ITEM_PROCESSED_ONCE = 'processedOnce'

// Configuration

const OPTION_ENABLE_TEXT_BLACKLIST = 'enable-text-blacklist'
const OPTION_ENABLE_TAG_BLACKLIST = 'enable-tag-blacklist'
const OPTION_ALWAYS_SHOW_SETTINGS_PANE = 'always-show-settings-pane'
const OPTION_DOCK_POSITION = 'dock-position'
const DOCK_BRAND_PREFIX = 'Brazen Scripts - '
const OPTION_DISABLE_COMPLIANCE_VALIDATION = 'disable-all-filters'

const OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER = 'skip-duplicate-downloads'

/** Default tooltip for {@link OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER}. */
const OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER_HELP =
    'Remember prior saves in script storage and skip re-downloads. Off if you removed files and want them again.'

const OPTION_HIDE_DOWNLOADED_MEDIA = 'hide-downloaded-media'

/** Default tooltip for {@link OPTION_HIDE_DOWNLOADED_MEDIA}. */
const OPTION_HIDE_DOWNLOADED_MEDIA_HELP =
    'Hide search tiles whose media is already in the download ledger. New downloads are recorded when this option or Skip Duplicate Downloads is on.'

/**
 * `GM_download` conflict policy when `downloadDuplicateLedger` is enabled on a site script.
 * Never `'uniquify'` — ledger-enabled scripts dictate filenames.
 *
 * @type {'overwrite'}
 */
const DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION = 'overwrite'

class BrazenFramework
{
  /**
   * @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter
   */

  /**
   * @typedef {{storageKey?: string, primaryField?: string, legacyIdFields?: string[],
   *            legacyStorageKeys?: string[],
   *            enableConfigKey?: string, enableHelpText?: string, enableDefault?: boolean,
   *            isValidId?: (value: *) => boolean,
   *            getDownloadId: SearchEnhancerDownloadLedgerIdCallback}} DownloadDuplicateLedgerConfiguration
   */

  /**
   * @typedef {{configKey: string, otherTagSectionsSelector?: JQuery, styleClass: string, rows?: int, helpText: string, removeClasses?: string,
   *            formatter?: Function}} ItemTagHighlightsConfiguration
   */

  /**
   * @typedef {JQuery.Selector|Function|{[pageOrLayout: string]: JQuery.Selector, default?: JQuery.Selector}} SelectorConfig
   */

  /**
   * @typedef {{detect: Function, layout?: Function}} PageDefinition
   */

  /**
   * @typedef {{pages: string[], operation: Function}} PageOperation
   */

  /**
   * @typedef {{doItemCompliance?: Function, downloadDuplicateLedger?: DownloadDuplicateLedgerConfiguration, downloadsDelay?: int,
   *            isUserLoggedIn?: boolean, itemDeepAnalysisSelector?: JQuery.Selector,
   *            itemListSelectors: SelectorConfig, itemLinkSelector?: SelectorConfig, itemNameSelector: SelectorConfig, itemSelectors: SelectorConfig,
   *            itemSelectionMethod?: string, itemWrapperResolver?: SearchEnhancerItemWrapperResolver|null,
   *            requestDelay?: number, scriptPrefix: string, legacyScriptPrefix?: string,
   *            tagSelectorGenerator?: SearchEnhancerTagSelectorGeneratorCallback|null,
   *            trackComplianceRules?: boolean}} Configuration
   */

  /**
   * @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}
   */

  /**
   * @callback SearchEnhancerItemWorkerCallback
   * @param {JQuery} item
   */

  /**
   * @callback SearchEnhancerItemWrapperResolver
   * @param {JQuery} item
   * @return {JQuery}
   */

  /**
   * @callback SearchEnhancerTagsExtractionCallback
   * @param {JQuery} item
   * @return {string[]}
   */

  /**
   * @callback SearchEnhancerTagSelectorGeneratorCallback
   * @param {string} tag
   * @return {string}
   */

  /**
   * @callback SearchEnhancerDownloadLedgerIdCallback
   * @param {*} item - DOM node, jQuery tile, or other site-specific download context
   * @return {string|null|undefined}
   */

  // -------------------------------------------------------------------------
  // Private class variables
  // -------------------------------------------------------------------------

  /**
   * Array of item compliance filters
   * @type {ComplianceFilter[]}
   * @private
   */
  _complianceFilters = []

  /**
   * @type {BrazenDownloadManager|null}
   * @private
   */
  _downloadManager = null

  /**
   * @type {string}
   * @private
   */
  _highlightClasses = ''

  /**
   * @type {boolean}
   * @private
   */
  _sanitizationEnabled = false

  /**
   * @type {JQuery}
   * @private
   */
  _subscriptionsLoaderButton = null

  // -------------------------------------------------------------------------
  // Protected class variables
  // -------------------------------------------------------------------------

  /**
   * @type {boolean}
   * @protected
   */
  _disableUI = false

  /**
   * @type {JQuery|null}
   * @protected
   */
  _uiSection = null

  /**
   * @type {{orientations: string[], defaultOrientation: string, scriptName?: string, showBranding?: boolean, onOpenMainPanel?: Function}|null}
   * @protected
   */
  _dockConfig = null

  /**
   * Persisted dock edge; loaded from IndexedDB via `readSetting('dock-position')`, not `field.value`.
   * @type {string|null}
   * @protected
   */
  _dockOrientation = null

  /**
   * Re-syncs bottom-dock panels when the dock layout changes size.
   * @type {ResizeObserver|null}
   * @private
   */
  _dockPanelResizeObserver = null

  /**
   * @type {string|null}
   * @protected
   */
  _downloadDuplicateLedgerFieldKey = null

  /**
   * @type {{enableConfigKey: string, getDownloadId: SearchEnhancerDownloadLedgerIdCallback}|null}
   * @protected
   */
  _downloadDuplicateLedgerConfig = null

  /**
   * Operations to perform after script initialization
   * @type {function[]}
   * @protected
   */
  _onAfterInitialization = []

  /**
   * Host hooks after bookmark fields reload (e.g. GM hydrate).
   * @type {function[]}
   * @protected
   */
  _onBookmarksHydrate = []

  /**
   * Script hooks after Framework refreshes panel/dock/bookmarks on configuration change.
   * @type {function({manager: BrazenConfigurationManager, source: string, local: boolean})[]}
   * @protected
   */
  _onConfigurationChange = []

  /**
   * Session UI refresh when tag-substitution link mode changes without a config write.
   * @type {function[]}
   * @protected
   */
  _onTagSubstitutionUiChange = []

  /**
   * In-progress alias for filename substitution link mode (normalized), or null.
   * @type {string|null}
   * @private
   */
  _tagSubstitutionLinkSource = null

  /**
   * @type {{manager: BrazenConfigurationManager, source: string, local: boolean}|null}
   * @private
   */
  _pendingConfigurationChange = null

  /**
   * @type {boolean}
   * @private
   */
  _configurationChangeScheduled = false

  /**
   * Operations to perform after a complete compliance run
   * @type {function[]}
   * @protected
   */
  _onAfterComplianceRun = []

  /**
   * Operations to perform after UI generation
   * @type {function[]}
   * @protected
   */
  _onAfterUIBuild = []

  /**
   * Operations to perform before compliance validation.
   * @type {function[]}
   * @protected
   */
  _onBeforeCompliance = []

  /**
   * Operations to perform before UI generation
   * @type {function[]}
   * @protected
   */
  _onBeforeUIBuild = []

  /**
   * Operations to perform after compliance rule checks, the first time a search item is retrieved
   * @type {function(JQuery)[]}
   * @protected
   */
  _onFirstHitAfterCompliance = []

  /**
   * Operations to perform before compliance checks, the first time a search item is retrieved
   * @type {function(JQuery)[]}
   * @protected
   */
  _onFirstHitBeforeCompliance = []

  /**
   * Logic to hide a non-compliant item
   * @type {SearchEnhancerItemWorkerCallback}
   * @param {JQuery} item
   * @protected
   */
  _onItemHide = null

  /**
   * Logic to show the compliant search item
   * @type {Function[]}
   * @param {JQuery} item
   * @protected
   */
  _onItemShow = []

  /**
   * Validate initiating initialization.
   * Can be used to stop script initialization on specific pages or vice versa
   * @type {Function}
   * @protected
   */
  _onValidateInit = () => true

  /**
   * Operations to perform at the start of gated full init (Phase 2), after page detection.
   * @type {function[]}
   * @protected
   */
  _onBeforeFullInit = []

  /**
   * Registered page definitions (name → detect/layout config).
   * @type {Map<string, PageDefinition>}
   * @protected
   */
  _pages = new Map()

  /**
   * Pages active on the current document (evaluated once per `init()`).
   * @type {Set<string>}
   * @protected
   */
  _activePages = new Set()

  /**
   * Optional allow-list of page names that gate full init. `null` = all defined pages.
   * @type {string[]|null}
   * @protected
   */
  _initPages = null

  /**
   * Optional allow-list of page names that gate compliance runs. `null` = all active pages.
   * @type {string[]|null}
   * @protected
   */
  _compliancePages = null

  /**
   * Boot-time page operations (Phase 1).
   * @type {PageOperation[]}
   * @protected
   */
  _pageOperations = []

  /**
   * Layout variant per active page name (when a layout resolver is defined).
   * @type {Map<string, string|null>}
   * @protected
   */
  _layouts = new Map()

  /**
   * Runtime switch for compliance passes (independent of Disable All Filters).
   * @type {boolean}
   * @protected
   */
  _complianceEnabled = true

  /**
   * Pagination manager
   * @type BrazenPaginator|null
   * @protected
   */
  _paginator = null

  /**
   * @type {ComplianceRuleRecorder|null}
   * @protected
   */
  _complianceRules = null

  /**
   * @type {BrazenSubscriptionsLoader|null}
   * @protected
   */
  _subscriptionsLoader = null

  /**
   * Must return the generated settings section node
   * @type {JQuery[]}
   * @protected
   */
  _userInterface = []

  // -------------------------------------------------------------------------
  // Constructor
  // -------------------------------------------------------------------------

  /**
   * @param {Configuration} configuration
   */
  constructor(configuration)
  {
    this._config = configuration
    if (configuration.isUserLoggedIn === undefined) {
      this._config.isUserLoggedIn = false
    }
    if (configuration.itemSelectionMethod === undefined) {
      this._config.itemSelectionMethod = 'find'
    }
    if (configuration.itemWrapperResolver === undefined) {
      this._config.itemWrapperResolver = (item) => item
    }

    this._itemAttributesResolver = new BrazenItemAttributesResolver({
      itemDeepAnalysisSelector: this._config.itemDeepAnalysisSelector ?? '',
      itemLinkSelector: this._config.itemLinkSelector ?? '',
      requestDelay: this._config.requestDelay ?? 0,
      onDeepAttributesResolution: (item) => {
        this._complyItem(item)
        Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
      },
    })

    this._statistics = new StatisticsRecorder(this._config.scriptPrefix)
    if (configuration.trackComplianceRules) {
      this._complianceRules = new ComplianceRuleRecorder()
    }

    this._uiGen = new BrazenViewLayer(this._config.scriptPrefix)

    this._configurationManager = new BrazenConfigurationManager(this._config.scriptPrefix, this._uiGen, this._config.tagSelectorGenerator)
    if (configuration.legacyScriptPrefix) {
      this._configurationManager.setLegacyScriptPrefix(configuration.legacyScriptPrefix)
    }
    this._configurationManager.onConfigurationChange((event) => {
      this._scheduleConfigurationChange(event)
    })
    this._configurationManager.addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
        setTitle('Disable All Filters').
        setHelpText('Disables all search filters.').
        applyDockTemplate('invertedFiltersMaster')
    this._configurationManager.addFlagField(OPTION_ALWAYS_SHOW_SETTINGS_PANE).
        setTitle('Always Show Settings Pane').
        setHelpText('Always show configuration interface. Ignored when a button dock is enabled.')

    this._onItemHide = (item) => this._config.itemWrapperResolver(item).addClass(CLASS_NON_COMPLIANT_ITEM).removeClass(CLASS_COMPLIANT_ITEM).hide()

    this._onItemShow.push((item) => this._config.itemWrapperResolver(item).addClass(CLASS_COMPLIANT_ITEM).removeClass(CLASS_NON_COMPLIANT_ITEM).show())

    if (configuration.downloadDuplicateLedger?.getDownloadId) {
      this._initDownloadDuplicateLedger(configuration.downloadDuplicateLedger)
    }
  }

  // -------------------------------------------------------------------------
  // Private class methods
  // -------------------------------------------------------------------------

  /**
   * @return {ConfigurationField|null}
   * @private
   */
  _getDownloadDuplicateLedgerField()
  {
    if (!this._downloadDuplicateLedgerFieldKey) {
      return null
    }
    return this._configurationManager.getField(this._downloadDuplicateLedgerFieldKey)
  }

  /**
   * @param {DownloadDuplicateLedgerConfiguration} ledgerConfig
   * @private
   */
  _initDownloadDuplicateLedger(ledgerConfig)
  {
    let enableConfigKey = ledgerConfig.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER
    let enableHelpText = ledgerConfig.enableHelpText ?? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER_HELP
    let enableFlag = this._configurationManager.addFlagField(enableConfigKey).setHelpText(enableHelpText)
    if (enableConfigKey === OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER) {
      enableFlag.setTitle('Skip Duplicate Downloads')
    }
    if (ledgerConfig.enableDefault !== false) {
      // Prefer setDefault so _settingCache matches — getValue() reads cache after createField
      // seeds false, and a bare field.value = true leaves Skip Duplicate "off" after refresh.
      enableFlag.setDefault(true)
    }
    enableFlag.applyDockTemplate('skipDuplicates')

    this._configurationManager.addLedgerField('download-ledger').
        setTitle('Download Ledger').
        setPrimaryField(ledgerConfig.primaryField ?? 'ids').
        setIsValidId(ledgerConfig.isValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)).
        reload()
    this._downloadDuplicateLedgerFieldKey = 'download-ledger'
    this._downloadDuplicateLedgerConfig = {
      enableConfigKey,
      getDownloadId: ledgerConfig.getDownloadId,
    }
  }

  /**
   * Decodes HTML entities (e.g. `&amp;` → `&`) from DOM-derived strings.
   *
   * @param {string} text
   * @return {string}
   * @protected
   */
  _decodeHtmlEntities(text)
  {
    if (typeof text !== 'string' || text.length === 0) {
      return ''
    }
    if (!text.includes('&')) {
      return text
    }
    let textarea = document.createElement('textarea')
    textarea.innerHTML = text
    return textarea.value
  }

  /**
   * Polls and observes the DOM until `predicate(element)` is true for the first
   * match of `selector`, then invokes `callback(element)` once.
   *
   * @param {string} selector
   * @param {function(Element): boolean} predicate
   * @param {function(Element): void} callback
   * @param {{pollMs?: number, timeoutMs?: number, observeRoot?: Element}} [options]
   * @protected
   */
  _waitForDomElement(selector, predicate, callback, options = {})
  {
    let pollMs = options.pollMs ?? 150
    let timeoutMs = options.timeoutMs ?? 30000
    let observeRoot = options.observeRoot ?? document.documentElement
    let resolved = false
    let observer = null
    let poll = null
    let timeout = null

    let cleanup = () => {
      observer?.disconnect()
      if (poll) {
        clearInterval(poll)
      }
      if (timeout) {
        clearTimeout(timeout)
      }
    }

    let attempt = () => {
      if (resolved) {
        return
      }
      let element = document.querySelector(selector)
      if (!element || !predicate(element)) {
        return
      }
      resolved = true
      cleanup()
      callback(element)
    }

    attempt()
    if (resolved) {
      return
    }

    observer = new MutationObserver(attempt)
    observer.observe(observeRoot, {subtree: true, childList: true, attributes: true, attributeFilter: ['src']})
    poll = setInterval(attempt, pollMs)
    timeout = setTimeout(cleanup, timeoutMs)
  }

  /**
   * @param {string} helpText
   * @param rows
   * @protected
   */
  _addItemBlacklistFilter(helpText, rows = 5)
  {
    this._configurationManager.addFlagField(OPTION_ENABLE_TEXT_BLACKLIST).
        setTitle('Enable Text Blacklist').
        setHelpText('Applies the blacklist.')
    this._configurationManager.addRulesetField(FILTER_TEXT_BLACKLIST).
        setTitle('Blacklist').
        setRows(rows).
        setHelpText(helpText).
        setOptimize((rules) => Utilities.buildWholeWordMatchingRegex(rules) ?? '').
        setSortRules(true)
    this._addItemComplexComplianceFilter(
        FILTER_TEXT_BLACKLIST,
        (rules) => this._getConfig(OPTION_ENABLE_TEXT_BLACKLIST) && rules !== '',
        (item, value) => this._get(item, ITEM_NAME)?.match(value) === null,
    )
  }

  /**
   * @param {string} configKey
   * @param {SearchEnhancerFilterValidationCallback|null} validationCallback
   * @param {SearchEnhancerFilterComplianceCallback|null|string} complianceCallback
   * @protected
   */
  _addItemComplexComplianceFilter(configKey, validationCallback, complianceCallback)
  {
    this._addItemComplianceFilter(configKey, complianceCallback, validationCallback)
  }

  /**
   * @param {string} configKey
   * @param {SearchEnhancerFilterComplianceCallback|null|string} action
   * @param {SearchEnhancerFilterValidationCallback|null} validationCallback
   * @protected
   */
  _addItemComplianceFilter(configKey, action = null, validationCallback = null)
  {
    let configType = this._configurationManager.getField(configKey).type
    if (action === null) {
      action = configKey
    }
    if (typeof action === 'string') {
      let attributeName = action
      switch (configType) {
        case CONFIG_TYPE_CHECKBOXES_GROUP:
          action = (item, values) => {
            let attribute = this._get(item, attributeName)
            return attribute && values.length ? values.includes(attribute) : true
          }
          break
        case CONFIG_TYPE_FLAG:
          action = (item) => {
            let attribute = this._get(item, attributeName)
            return attribute === null ? true : attribute
          }
          break
        case CONFIG_TYPE_RADIOS_GROUP:
          action = (item, value) => {
            let attribute = this._get(item, attributeName)
            return attribute ? value === attribute : true
          }
          break
        case CONFIG_TYPE_RANGE:
          action = (item, range) => {
            let attribute = this._get(item, attributeName)
            return attribute ? Validator.isInRange(this._get(item, attributeName), range.minimum, range.maximum) : true
          }
          break
        default:
          throw new Error('Associated config type requires explicit action callback definition.')
      }
    }
    if (validationCallback === null) {
      validationCallback = this._configurationManager.generateValidationCallback(configKey)
    }
    this._complianceFilters.push({
      configKey: configKey,
      validate: validationCallback,
      comply: action,
    })
  }

  /**
   * @param {JQuery.Selector|Function} durationNodeSelector
   * @param {string|null} helpText
   * @param {string} separator
   * @protected
   */
  _addItemDurationRangeFilter(durationNodeSelector, helpText = null, separator = ':')
  {
    this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 0, 100000).
        setTitle('Duration').
        setHelpText(helpText ?? 'Filter items by duration.')

    this._itemAttributesResolver.addAttribute(FILTER_DURATION_RANGE, (item) => {
      let duration
      if (typeof durationNodeSelector === 'function') {
        duration = durationNodeSelector(item)
      } else {
        let durationNode = item.find(durationNodeSelector)
        if (durationNode.length) {
          duration = durationNode.text().trim()
        } else {
          return null
        }
      }
      duration = duration.split(separator)
      duration = (Number.parseInt(duration[0]) * 60) + Number.parseInt(duration[1])
      return duration === 0 ? null : duration
    })

    this._addItemComplianceFilter(FILTER_DURATION_RANGE)
  }

  /**
   * @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).
        setTitle('Rating').
        setHelpText(helpText ?? 'Filter items by percentage rating.')
    this._configurationManager.addFlagField(FILTER_UNRATED).
        setTitle('Unrated').
        setHelpText(unratedHelpText ?? 'Hide items with zero or no rating.')

    this._itemAttributesResolver.addAttribute(FILTER_PERCENTAGE_RATING_RANGE, (item) => {
      let rating = item.find(ratingNodeSelector)
      return rating.length === 0 ? null : Number.parseInt(rating.text().trim().replace('%', ''))
    })

    this._addItemComplianceFilter(FILTER_PERCENTAGE_RATING_RANGE, (item, range) => {
      let rating = this._get(item, FILTER_PERCENTAGE_RATING_RANGE)
      return rating ? Validator.isInRange(rating, range.minimum, range.maximum) : !this._getConfig(FILTER_UNRATED)
    })
  }

  /**
   * @param {string} key
   * @param {boolean} deepAttribute
   * @param {boolean} saveSelectors
   * @param {SearchEnhancerTagsExtractionCallback} extractTags
   * @protected
   */
  _addItemTagAttribute(key, deepAttribute, saveSelectors, extractTags)
  {
    let tagsToSelectorsMapper = (item) => {
      if (saveSelectors) {
        let tagSelectors = ''
        for (let tag of extractTags(item)) {
          tagSelectors += this._config.tagSelectorGenerator(tag)
        }
        return tagSelectors
      }
      let tags = []
      for (let tag of extractTags(item)) {
        tags.push(tag)
      }
      return tags
    }
    if (deepAttribute) {
      this._itemAttributesResolver.addDeepAttribute(key, tagsToSelectorsMapper)
    } else {
      this._itemAttributesResolver.addAttribute(key, tagsToSelectorsMapper)
    }
  }

  /**
   * @param {string|null} attribute
   * @param {boolean} useSelectors
   * @param {int} rows
   * @param {string|null} helpText
   * @param {string|null} key
   * @param {string|null} optionKey
   * @param {string|null} dockTemplateName Named CM dock template (default `tagBlacklist` for primary blacklist).
   * @protected
   */
  _addItemTagBlacklistFilter(attribute, useSelectors, rows = 5, helpText = null, key = null, optionKey = null, dockTemplateName = null)
  {
    if (helpText === null) {
      helpText = 'Specify the tags blacklist with one rule on each line. <br> Conditional operators; "&" "|" can be used to make complex rules.'
    }

    if (key === null) {
      key = FILTER_TAG_BLACKLIST
    }
    if (optionKey === null) {
      optionKey = OPTION_ENABLE_TAG_BLACKLIST
    }

    let enableField = this._configurationManager.addFlagField(optionKey).setHelpText('Applies the blacklist.')
    if (optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
      enableField.setTitle('Enable Tag Blacklist')
    }

    let templateName = dockTemplateName
    if (!templateName && optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
      templateName = 'tagBlacklist'
    }
    if (templateName) {
      enableField.applyDockTemplate(templateName)
    }

    this._configurationManager.addTagRulesetField(key, useSelectors).
        setTitle(key === FILTER_TAG_BLACKLIST ? 'Tag Blacklist' : key).
        setRows(rows).
        setHelpText(helpText).
        setSortRules(true)

    this._addItemComplexComplianceFilter(
        key,
        () => this._validateTagComplianceFilter(key, optionKey),
        (item) => this._complyTagComplianceFilter(item, attribute, key),
    )
  }

  /**
   * @param {string} fieldKey
   * @param {string} optionKey
   * @return {boolean}
   * @protected
   */
  _validateTagComplianceFilter(fieldKey, optionKey)
  {
    if (!this._getConfig(optionKey)) {
      return false
    }
    if (this._configurationManager.isTagRegistryField(fieldKey)) {
      return this._configurationManager.hasTagComplianceRules(fieldKey)
    }
    let field = this._configurationManager.getField(fieldKey)
    let optimized = field?.optimized ?? field?.value
    return Array.isArray(optimized) ? optimized.length > 0 : !!optimized?.length
  }

  /**
   * @param {JQuery} item
   * @param {string} attribute
   * @param {string} fieldKey
   * @return {boolean|{complies: boolean, rule: string}}
   * @protected
   */
  _complyTagComplianceFilter(item, attribute, fieldKey)
  {
    let itemTags = this._get(item, attribute)
    if (itemTags === null || !itemTags.length) {
      return true
    }
    if (this._configurationManager.isTagRegistryField(fieldKey) && this._configurationManager.canPersist()) {
      let result = this._configurationManager.evaluateTagCompliance(itemTags, fieldKey)
      return result.complies ? true : result
    }
    let field = this._configurationManager.getField(fieldKey)
    return this._handleComplianceForBlacklistFilter(item, attribute, field?.optimized ?? field?.value ?? [])
  }

  /**
   * Hides items whose media is already recorded in the download duplicate ledger.
   * Membership uses recorded ledger ids (Skip Duplicate does not have to be on).
   * Claims run when Skip **or** Hide is on ({@link _shouldClaimDownloadDuplicateLedger}).
   *
   * @param {SearchEnhancerDownloadLedgerIdCallback} getItemDownloadId Resolves a per-item ledger id from a tile
   * @param {string|null} helpText
   * @param {string} optionKey
   * @protected
   */
  _addItemHideDownloadedMediaFilter(getItemDownloadId, helpText = null, optionKey = OPTION_HIDE_DOWNLOADED_MEDIA)
  {
    let field = this._configurationManager.addFlagField(optionKey).setHelpText(helpText ?? OPTION_HIDE_DOWNLOADED_MEDIA_HELP)
    if (optionKey === OPTION_HIDE_DOWNLOADED_MEDIA) {
      field.setTitle('Hide Downloaded Media')
    }
    field.applyDockTemplate('hideDownloaded')

    this._addItemComplexComplianceFilter(
        optionKey,
        (enabled) => enabled && !!this._getDownloadDuplicateLedgerField(),
        (item) => {
          let id = getItemDownloadId(item)
          return id !== null && id !== undefined && this._isDownloadLedgerIdRecorded(String(id).trim())
              ? {complies: false, rule: 'Already downloaded'}
              : true
        },
    )
  }

  /**
   * @param {ItemTagHighlightsConfiguration} config
   * @protected
   */
  _addItemTagHighlights(config)
  {
    this.registerHighlightStyleClass(config.styleClass)

    this._configurationManager.addTagRulesetField(config.configKey, true).
        setRows(config.rows ?? 5).
        setHelpText(config.helpText ?? '').
        setFormatter(config.formatter ?? null).
        setSortRules(true)

    let highlightsHandler = (section) => {

      let optimizedRuleset = this._configurationManager.getField(config.configKey).optimized
      if (optimizedRuleset) {

        let ruleApplies, subjectTags
        for (let rule of optimizedRuleset) {

          ruleApplies = true
          subjectTags = section.find(rule.join(', '))

          for (let tagSelector of rule) {

            if (section.find(tagSelector).length === 0) {
              ruleApplies = false
              break
            }
          }
          if (ruleApplies) {
            subjectTags.addClass(config.styleClass)
            if (config.removeClasses !== undefined) {
              subjectTags.removeClass(config.removeClasses)
            }
          } else {
            subjectTags.removeClass(config.styleClass)
          }
        }
      }
    }
    if (config.otherTagSectionsSelector && config.otherTagSectionsSelector.length > 0) {
      this._onBeforeUIBuild.push(() => highlightsHandler(config.otherTagSectionsSelector))
    }
    this._onItemShow.push((item) => highlightsHandler(item))
  }

  /**
   * @param {string} helpText
   * @protected
   */
  _addItemTextSanitizationFilter(helpText)
  {
    this._sanitizationEnabled = true

    this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION).
        setTitle('Text Sanitization Rules').
        setRows(2).
        setHelpText(helpText).
        setTranslateFromUI((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
    }).
        setFormatForUI((rules) => {
      let sanitizationRulesText = []
      for (let substitute in rules) {
        sanitizationRulesText.push(substitute + '=' + rules[substitute].join(','))
      }
      return sanitizationRulesText

    }).
        setOptimize((rules) => {
      let optimizedRules = {}
      for (const substitute in rules) {
        optimizedRules[substitute] = Utilities.buildWholeWordMatchingRegex(rules[substitute])
      }
      return optimizedRules
    }).
        setSortRules(true)
  }

  /**
   * @param {string|null} helpText
   * @protected
   */
  _addItemTextSearchFilter(helpText = null)
  {
    this._configurationManager.addTextField(FILTER_TEXT_SEARCH).
        setTitle('Search').
        setHelpText(helpText ?? 'Show videos with these comma separated words in their names.')
    this._addItemComplianceFilter(FILTER_TEXT_SEARCH, (item, value) => this._get(item, ITEM_NAME).includes(value))
  }

  /**
   * @param {string} helpText
   * @protected
   */
  _addItemWhitelistFilter(helpText)
  {
    this._configurationManager.addRulesetField(FILTER_TEXT_WHITELIST).
        setTitle('Whitelist').
        setRows(5).
        setHelpText(helpText).
        setOptimize((rules) => Utilities.buildWholeWordMatchingRegex(rules)).
        setSortRules(true)
  }

  /**
   * @param {SubscriptionsFilterExclusionsCallback} exclusionsCallback Add page exclusions here
   * @param {SubscriptionsFilterUsernameCallback} getItemUsername Return username of the item or return false to skip
   * @protected
   */
  _addSubscriptionsFilter(exclusionsCallback, getItemUsername)
  {
    this._configurationManager.addFlagField(FILTER_SUBSCRIBED_VIDEOS).
        setTitle('Hide Subscribed Videos').
        setHelpText('Hide videos from subscribed channels.')
    this._configurationManager.addTextField(STORE_SUBSCRIPTIONS).
        setTitle('Account Subscriptions').
        setHelpText('Recorded subscription accounts.')

    this._addItemComplexComplianceFilter(
        FILTER_SUBSCRIBED_VIDEOS,
        (value) => value && this._config.isUserLoggedIn && exclusionsCallback(),
        (item) => {
          let username = getItemUsername(item)
          if (username === false) {
            return true
          }
          return !(new RegExp('"([^"]*' + username + '[^"]*)"')).test(this._getConfig(STORE_SUBSCRIPTIONS))
        })
  }

  /**
   * @param {JQuery} item
   * @protected
   */
  _complyItem(item)
  {
    let itemComplies = true
    let doItemCompliance = true

    if (this._config.doItemCompliance) {
      doItemCompliance = Utilities.callEventHandler(this._config.doItemCompliance)
    }

    if (doItemCompliance && !this._getConfig(OPTION_DISABLE_COMPLIANCE_VALIDATION) && this._validateItemWhiteList(item)) {

      let configField
      Utilities.processEventHandlerQueue(this._onBeforeCompliance, [item])

      for (let complianceFilter of this._complianceFilters) {

        configField = this._configurationManager.getFieldOrFail(complianceFilter.configKey)
        if (complianceFilter.validate(configField.optimized ?? configField.value)) {
          let complyResult = complianceFilter.comply(item, configField.optimized ?? configField.value)
          let {complies, rule} = this._unwrapComplianceResult(complyResult)
          itemComplies = complies
          this._statistics.record(complianceFilter.configKey, itemComplies)
          if (!itemComplies) {
            if (this._complianceRules) {
              let ruleLabel = rule ?? this._configurationManager.getField(complianceFilter.configKey)?.title ?? complianceFilter.configKey
              this._complianceRules.record(complianceFilter.configKey, ruleLabel)
            }
            break
          }
        }
      }
    }
    if (itemComplies) {
      Utilities.processEventHandlerQueue(this._onItemShow, [item])
    } else {
      Utilities.callEventHandler(this._onItemHide, [item])
    }
    item.css('opacity', 'unset')
  }

  /**
   * Filters items as per settings
   * @param {JQuery} itemsList
   * @param {boolean} fromObserver
   * @protected
   */
  _complyItemsList(itemsList, fromObserver = false)
  {
    let items
    if (fromObserver) {
      items = itemsList.filter(this._config.itemSelectors).add(itemsList.find(this._config.itemSelectors))
    } else if (this._config.itemSelectionMethod === 'find') {
      items = itemsList.find(this._config.itemSelectors)
    } else {
      items = itemsList.children(this._config.itemSelectors)
    }
    items.css('opacity', 0.75).each((index, element) => {

      let item = $(element)

      // First run processing

      if (this._get(item, ITEM_PROCESSED_ONCE) === null) {
        if (this._sanitizationEnabled) {
          Validator.sanitizeTextNode(
              item.find(this._config.itemNameSelector),
              this._configurationManager.getFieldOrFail(FILTER_TEXT_SANITIZATION).optimized)
        }
        this._itemAttributesResolver.resolveAttributes(item)
        Utilities.processEventHandlerQueue(this._onFirstHitBeforeCompliance, [item])
      }

      // Compliance filtering

      this._complyItem(item)

      // Processing of search items on later runs

      if (!this._get(item, ITEM_PROCESSED_ONCE)) {
        Utilities.processEventHandlerQueue(this._onFirstHitAfterCompliance, [item])
        this._itemAttributesResolver.set(item, ITEM_PROCESSED_ONCE, true)
      }
    })

    this._statistics.updateUI()

    Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
  }

  /**
   * @protected
   * @return {JQuery[]}
   */
  _createPaginationControls()
  {
    return [
      this._configurationManager.createElement(CONFIG_PAGINATOR_THRESHOLD),
      this._configurationManager.createElement(CONFIG_PAGINATOR_LIMIT)]
  }

  /**
   * @protected
   * @return {JQuery}
   */
  _createSettingsBackupRestoreFormActions()
  {
    return this._uiGen.createFormActions([
      this._uiGen.createFormButton('Backup Configuration', 'Download configuration file.', () => this._onBackupSettings()),
      this._uiGen.createSeparator(),
      this._uiGen.createFormGroupInput('file').attr('id', 'restore-settings').attr('placeholder', 'Browse for settings file...'),
      this._uiGen.createFormButton('Restore Configuration', 'Restore configuration from the selected file.', () => this._onRestoreSettings()),
    ], 'bv-flex-column')
  }

  /**
   * @protected
   * @return {JQuery}
   */
  _createSettingsFormActions()
  {
    return this._uiGen.createFormActions([
      this._uiGen.createFormButton('Apply', 'Apply settings.', () => this._onApplyNewSettings()),
      this._uiGen.createFormButton('Save', 'Apply and update saved configuration.', () => this._onSaveSettings()),
      this._uiGen.createFormButton('Reset', 'Revert to saved configuration.', () => this._onResetSettings()),
    ])
  }

  /**
   * Include a Donate tab panel (default Patreon: https://www.patreon.com/c/brazen_mvs).
   * Pass `Donate` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
   *
   * @param {{tabName?: string, isFirst?: boolean, patreonUrl?: string, linkLabel?: string, message?: string}} [options]
   * @return {JQuery}
   */
  createDonateTabPanel(options = {})
  {
    return this._uiGen.createDonateTabPanel(options)
  }

  /**
   * @protected
   * @return {JQuery}
   */
  _createSubscriptionLoaderControls()
  {
    return this._subscriptionsLoaderButton
  }

  /**
   * Whether the main settings panel should stay open (auto-hide suppressed).
   * Inert when a dock is active — frequent controls live on the dock rail.
   * @return {boolean}
   * @protected
   */
  _isAlwaysShowSettingsPaneActive()
  {
    if (this._dockConfig && !this._disableUI) {
      return false
    }
    return !!this._getConfig(OPTION_ALWAYS_SHOW_SETTINGS_PANE)
  }

  /**
   * @param {JQuery} UISection
   * @private
   */
  _embedUI(UISection)
  {
    let hideTimer = null
    const cancelScheduledHide = () => {
      clearTimeout(hideTimer)
      hideTimer = null
    }
    UISection.on('mouseenter', cancelScheduledHide)
    UISection.on('mouseleave', (event) => {
      if (!this._uiGen.isSettingsPaneBeingResized() && !this._isAlwaysShowSettingsPaneActive()) {
        cancelScheduledHide()
        hideTimer = setTimeout(() => {
          hideTimer = null
          if (!this._uiGen.isSettingsPaneBeingResized() && !this._isAlwaysShowSettingsPaneActive()) {
            if (this._dockConfig && !this._disableUI) {
              this._hideMainPanel(event.currentTarget)
            } else {
              $(event.currentTarget).slideUp(300)
            }
          }
        }, 1000)
      }
    })
    if (this._isAlwaysShowSettingsPaneActive()) {
      UISection.show()
    }
    this._uiGen.constructor.appendToBody(UISection)
    this._uiSection = UISection
    if (this._complianceRules && this._dockConfig && !this._disableUI) {
      this._complianceRulesPanel = this._uiGen.createComplianceRulesSlidePanel()
      this._wireComplianceRulesSlidePanel(this._complianceRulesPanel)
      this._uiGen.constructor.appendToBody(this._complianceRulesPanel)
    }
    if (this._dockConfig && !this._disableUI) {
      this._buildDock(UISection)
    } else {
      this._uiGen.constructor.appendToBody(this._uiGen.createSettingsShowButton('', UISection))
    }
  }

  /**
   * @param {JQuery} [railBody]
   * @param {{layout?: boolean}} [options] Pass `{ layout: false }` to refresh button state only.
   * @return {BrazenFramework}
   */
  refreshDockInterface(railBody, options = {})
  {
    railBody = railBody ?? $('.bv-dock .bv-dock-rail-body').first()
    this._configurationManager.setDockIncludeContext(this)
    if (options.layout !== false) {
      this.composeDockRail(railBody)
    }
    this._configurationManager.refreshDockButtonStates()
    return this
  }

  /**
   * @param {JQuery} railBody
   */
  composeDockRail(railBody)
  {
    throw new Error('composeDockRail(railBody) must be implemented when configureDock() is used.')
  }

  /**
   * @param {JQuery} UISection
   * @private
   */
  _buildDock(UISection)
  {
    let orientation = this._getDockOrientation()
    this._setDockPanelAnchor(orientation)
    let nameBar = this._getDockNameBarText()
    let dock = this._uiGen.createDock(orientation, nameBar)
    let railHead = dock.find('.bv-dock-rail-head')
    let railBody = dock.find('.bv-dock-rail-body')
    let railFoot = dock.find('.bv-dock-rail-foot')
    let openPanel = this._dockConfig.onOpenMainPanel ?? (() => this._toggleMainPanel(UISection))
    railHead.append(this._uiGen.createDockButton({
      icon: 'menu',
      tooltip: 'Toggle Main UI',
      onClick: () => openPanel(),
    }).addClass('bv-dock-main-ui-btn'))
    railHead.append(this._uiGen.createDockSeparator())

    this._configurationManager.setDockIncludeContext(this)
    this.composeDockRail(railBody)

    if (this._dockConfig.orientations.length > 1) {
      railFoot.append(this._uiGen.createDockSeparator())
      railFoot.append(this._uiGen.createDockPositionButton({
        orientations: this._dockConfig.orientations,
        value: orientation,
        onCycle: () => this._cycleDockOrientation(),
      }))
    }

    this._uiGen.constructor.appendToBody(dock)
    this._configurationManager.refreshDockButtonStates()
    this._observeDockPanelSync(dock[0])
    let panel = UISection[0]
    let wasVisible = panel && this._isDockSlidePanelVisible(panel)
    requestAnimationFrame(() => {
      this._syncDockPanelPosition()
      if (wasVisible || this._isAlwaysShowSettingsPaneActive()) {
        this._showMainPanel(panel)
      } else {
        this._hideMainPanel(panel, false)
      }
    })
    $(window).off('resize.bvDockPanel').on('resize.bvDockPanel', () => this._syncDockPanelPosition())
  }

  _cycleDockOrientation()
  {
    if (!this._dockConfig || this._dockConfig.orientations.length <= 1) {
      return
    }
    if (!this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
      return
    }
    let orientations = this._dockConfig.orientations
    let current = this._getDockOrientation()
    let index = orientations.indexOf(current)
    if (index < 0) {
      index = 0
    }
    this._dockOrientation = orientations[(index + 1) % orientations.length]
    void this._configurationManager.writeSetting(OPTION_DOCK_POSITION, this._dockOrientation).then(() => {
      this._rebuildDock()
    })
  }

  /**
   * @private
   */
  _rebuildDock()
  {
    if (this._dockPanelResizeObserver) {
      this._dockPanelResizeObserver.disconnect()
      this._dockPanelResizeObserver = null
    }
    $('.bv-dock').remove()
    if (this._uiSection && this._dockConfig && !this._disableUI) {
      this._buildDock(this._uiSection)
    }
  }

  /**
   * @return {string}
   * @private
   */
  _getDockNameBarText()
  {
    let scriptName = this._dockConfig.scriptName?.trim()
    if (!scriptName) {
      return DOCK_BRAND_PREFIX.trim()
    }
    return this._dockConfig.showBranding ? DOCK_BRAND_PREFIX + scriptName : scriptName
  }

  /**
   * @return {string}
   * @private
   */
  _getDockOrientation()
  {
    if (this._dockOrientation && this._dockConfig.orientations.includes(this._dockOrientation)) {
      return this._dockOrientation
    }
    return this._dockConfig.defaultOrientation
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _loadDockOrientationFromStorage()
  {
    if (!this._dockConfig || !this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
      return
    }
    let stored = await this._configurationManager.readSetting(OPTION_DOCK_POSITION)
    if (typeof stored === 'string' && this._dockConfig.orientations.includes(stored)) {
      this._dockOrientation = stored
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _syncDockOrientationFromStorage()
  {
    if (!this._dockConfig || !this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
      return
    }
    let stored = await this._configurationManager.readSetting(OPTION_DOCK_POSITION)
    if (typeof stored !== 'string' || !this._dockConfig.orientations.includes(stored)) {
      return
    }
    if (stored === this._dockOrientation) {
      return
    }
    this._dockOrientation = stored
    this._rebuildDock()
  }

  /**
   * @param {string} orientation
   * @private
   */
  _setDockPanelAnchor(orientation)
  {
    BrazenViewLayer.setDockSlidePanelOrientations(orientation)
    this._syncDockPanelPosition()
  }

  /**
   * @param {JQuery} panel
   * @private
   */
  _wireComplianceRulesSlidePanel(panel)
  {
    BrazenViewLayer.wireDockSlidePanel(panel[0], {
      onClose: () => this._hideDockSlidePanel(panel[0]),
      onMouseLeave: (target) => {
        if (this._isDockSlidePanelVisible(target)) {
          this._hideDockSlidePanel(target)
        }
      },
    })
  }

  /**
   * Pin dock-anchored slide panels beside the dock (and stack outward when needed).
   * @protected
   */
  _syncDockPanelPosition()
  {
    if (!this._dockConfig || this._disableUI) {
      return
    }
    let dock = $('.bv-dock').first()[0]
    if (!dock) {
      return
    }
    BrazenViewLayer.syncDockSlidePanelStack(dock, this._getDockOrientation(), [
      document.getElementById('bv-ui'),
      document.getElementById('bv-compliance-rules'),
      document.getElementById('bv-tag-discovery-panel'),
      document.getElementById('bv-human-interaction-panel'),
    ].filter(Boolean))
  }

  /**
   * @param {HTMLElement} dock
   * @private
   */
  _observeDockPanelSync(dock)
  {
    if (this._dockPanelResizeObserver) {
      this._dockPanelResizeObserver.disconnect()
      this._dockPanelResizeObserver = null
    }
    if (!dock || this._getDockOrientation() !== 'bottom') {
      return
    }
    this._dockPanelResizeObserver = new ResizeObserver(() => {
      this._syncDockPanelPosition()
    })
    this._dockPanelResizeObserver.observe(dock)
  }

  /**
   * @param {HTMLElement} panel
   * @return {boolean}
   * @private
   */
  _isDockSlidePanelVisible(panel)
  {
    return BrazenViewLayer.isDockSlidePanelVisible(panel)
  }

  /**
   * @param {HTMLElement} panel
   * @protected
   */
  _showDockSlidePanel(panel)
  {
    let orientation = this._getDockOrientation()
    BrazenViewLayer.showDockSlidePanel(panel, orientation, () => this._syncDockPanelPosition())
    this._syncDockPanelPosition()
    this._updateDockSlidePanelButtonStates()
  }

  /**
   * @param {HTMLElement} panel
   * @param {boolean} [animate]
   * @protected
   */
  _hideDockSlidePanel(panel, animate = true)
  {
    BrazenViewLayer.hideDockSlidePanel(panel, this._getDockOrientation(), animate, () => {
      this._syncDockPanelPosition()
    })
    this._updateDockSlidePanelButtonStates()
  }

  /**
   * @param {HTMLElement|JQuery} UISection
   * @protected
   */
  _toggleMainPanel(UISection)
  {
    let panel = UISection?.[0] ?? UISection ?? document.getElementById('bv-ui')
    if (!panel) {
      return
    }
    if (this._isDockSlidePanelVisible(panel)) {
      this._hideDockSlidePanel(panel)
      return
    }
    this._showDockSlidePanel(panel)
  }

  /**
   * @param {HTMLElement} panel
   * @protected
   */
  _showMainPanel(panel)
  {
    this._showDockSlidePanel(panel)
  }

  /**
   * @param {HTMLElement} panel
   * @param {boolean} [animate]
   * @protected
   */
  _hideMainPanel(panel, animate = true)
  {
    this._hideDockSlidePanel(panel, animate)
  }

  /**
   * @protected
   */
  _toggleComplianceRulesPanel()
  {
    let panel = document.getElementById('bv-compliance-rules')
    if (!panel) {
      return
    }
    if (this._isDockSlidePanelVisible(panel)) {
      this._hideDockSlidePanel(panel)
      return
    }
    this._renderComplianceRulesPanelContent()
    this._showDockSlidePanel(panel)
  }

  /**
   * @private
   */
  _updateDockSlidePanelButtonStates()
  {
    BrazenViewLayer.updateDockSlidePanelToggleButtons()
  }

  /**
   * @private
   */
  _updateDockMenuButtonState()
  {
    this._updateDockSlidePanelButtonStates()
  }

  /**
   * @protected
   */
  _refreshDockButtonStates()
  {
    this.refreshDockInterface($('.bv-dock .bv-dock-rail-body').first())
    this._syncDockPanelPosition()
  }

  /**
   * @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean}} event
   * @private
   */
  _scheduleConfigurationChange(event)
  {
    let pending = this._pendingConfigurationChange
    if (pending && pending.source !== event.source && event.source !== 'all') {
      event = {...event, source: 'all'}
    }
    this._pendingConfigurationChange = event
    if (this._configurationChangeScheduled) {
      return
    }
    this._configurationChangeScheduled = true
    queueMicrotask(() => {
      this._configurationChangeScheduled = false
      let scheduledEvent = this._pendingConfigurationChange
      this._pendingConfigurationChange = null
      if (scheduledEvent) {
        void this._handleConfigurationChange(scheduledEvent)
      }
    })
  }

  /**
   * @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean}} event
   * @return {Promise<void>}
   * @private
   */
  async _handleConfigurationChange(event)
  {
    let manager = event.manager ?? this._configurationManager
    let source = event.source ?? 'all'
    let local = event.local !== false
    // Queue/ledger/runtime writes must not wipe unsaved settings controls.
    let runtimePipeline = source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
        source === 'downloadManagerState'
    // Reload settings controls from persisted cache only after Apply/Save/reset (local
    // settings|all) or a foreign cross-tab config revision. Local tag/bookmark attribute
    // writes must not discard unsaved textarea edits.
    let reloadsSettingsControls =
        (local && (source === 'settings' || source === 'all')) ||
        (!local && (source === 'all' || source === 'settings' || source === 'tags' || source === 'bookmarks'))

    if (reloadsSettingsControls) {
      manager.refreshMountedFields()
      void this._syncDockOrientationFromStorage()
    }
    if (reloadsSettingsControls || source === 'bookmarks') {
      await manager.reloadBookmarkFields()
    }
    // Download processors put queue/state rows continuously — do not rebuild the dock on every put.
    if (!runtimePipeline) {
      this._refreshDockButtonStates()
    }
    if (reloadsSettingsControls || source === 'tags' || source === 'ledger') {
      this._validateCompliance()
    }
    if (!runtimePipeline) {
      Utilities.processEventHandlerQueue(this._onConfigurationChange, [event])
    }
  }

  /**
   * @param {JQuery} item
   * @param {string} attributeName
   * @returns {*}
   * @protected
   */
  _get(item, attributeName)
  {
    return this._itemAttributesResolver.get(item, attributeName)
  }

  /**
   * @param {string} config
   * @returns {*}
   * @protected
   */
  _getConfig(config)
  {
    return this._configurationManager.getValue(config)
  }

  /**
   * @param {*} result
   * @return {{complies: boolean, rule: string|null}}
   * @protected
   */
  _unwrapComplianceResult(result)
  {
    if (result !== null && typeof result === 'object' && 'complies' in result) {
      return {complies: !!result.complies, rule: result.rule ?? null}
    }
    return {complies: !!result, rule: null}
  }

  /**
   * @return {{filterKey: string, filterLabel: string, rules: {label: string, count: number}[]}[]}
   * @protected
   */
  _getComplianceRuleReport()
  {
    if (!this._complianceRules) {
      return []
    }
    return this._complianceRules.getReport((filterKey) => this._configurationManager.getField(filterKey)?.title ?? filterKey)
  }

  /**
   * @protected
   */
  _renderComplianceRulesPanelContent()
  {
    if (!this._complianceRulesPanel?.length) {
      return
    }
    this._uiGen.renderComplianceRulesPanelContent(this._complianceRulesPanel, this._getComplianceRuleReport(), {
      canRemoveRule: (filterKey, ruleLabel) => this._canRemoveComplianceRule(filterKey, ruleLabel),
      onRemoveRule: (filterKey, ruleLabel) => {
        this._removeComplianceRule(filterKey, ruleLabel)
        this._renderComplianceRulesPanelContent()
      },
    })
  }

  /**
   * @protected
   */
  _showComplianceRulesModal()
  {
    this._toggleComplianceRulesPanel()
  }

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {boolean}
   * @protected
   */
  _canRemoveComplianceRule(filterKey, ruleLabel)
  {
    return false
  }

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @protected
   */
  _removeComplianceRule(filterKey, ruleLabel)
  {
  }

  _handleComplianceForBlacklistFilter(item, attribute, blacklistRuleset)
  {
    let itemTags = this._get(item, attribute)

    if (itemTags === null || !itemTags.length) {
      return true
    }

    if (blacklistRuleset && !Array.isArray(blacklistRuleset)) {
      return true
    }

    for (let rule of blacklistRuleset) {
      if (typeof rule === 'string') {
        if (itemTags.includes(rule)) {
          return {complies: false, rule: rule}
        }
        continue
      }

      let isBlacklisted = true
      for (let tag of rule) {
        if (!itemTags.includes(tag)) {
          isBlacklisted = false
          break
        }
      }
      if (isBlacklisted) {
        return {complies: false, rule: rule.join(' & ')}
      }
    }
    return true
  }

  /**
   * @private
   */
  _onApplyNewSettings()
  {
    this._configurationManager.update()
    this._validateCompliance()
    this.refreshDockInterface($('.bv-dock .bv-dock-rail-body').first())
  }

  /**
   * @private
   */
  _onBackupSettings()
  {
    if (this._configurationManager.isIdbBlocked()) {
      alert('IndexedDB is unavailable — backup is disabled.')
      return
    }
    void this._configurationManager.backup()
  }

  /**
   * @private
   */
  _onResetSettings()
  {
    void this._configurationManager.revertChanges()
  }

  /**
   * @private
   */
  _onRestoreSettings()
  {
    void this._configurationManager.restore(new Response($('#restore-settings').prop('files')[0]))
  }

  /**
   * @protected
   */
  _showIdbBlockedBanner()
  {
    BrazenViewLayer.ensureIdbBlockedBanner()
  }

  /**
   * @private
   */
  async _onSaveSettings()
  {
    if (this._configurationManager.isIdbBlocked()) {
      alert('IndexedDB is unavailable — settings cannot be saved.')
      return
    }
    try {
      await this._configurationManager.save()
    } catch (error) {
      console.log('[BrazenFramework] save settings failed:', error)
      alert('Settings could not be saved. See the browser console for details.')
    }
  }

  /**
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performComplexOperation(configKey, validationCallback, actionCallback)
  {
    return this._performOperation(configKey, actionCallback, validationCallback)
  }

  /**
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performOperation(configKey, actionCallback, validationCallback = null)
  {
    let configField = this._configurationManager.getField(configKey)
    let defaultValidationCallback = this._configurationManager.generateValidationCallback(configKey)
    let validationCallbackParams
    let values = configField.optimized ?? configField.value

    if (validationCallback) {
      validationCallbackParams = [values, defaultValidationCallback]
    } else {
      validationCallbackParams = [values]
      validationCallback = defaultValidationCallback
    }
    if (Utilities.callEventHandler(validationCallback, validationCallbackParams, true)) {
      actionCallback(values)
    }
    return this
  }

  /**
   * @param {string} flagConfigKey
   * @param {string|null} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performTogglableComplexOperation(flagConfigKey, configKey, validationCallback, actionCallback)
  {
    if (this._getConfig(flagConfigKey)) {
      this._performComplexOperation(configKey ?? flagConfigKey, validationCallback, actionCallback)
    }
    return this
  }

  /**
   * @param {string} flagConfigKey
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @returns {BrazenFramework}
   * @protected
   */
  _performTogglableOperation(flagConfigKey, configKey, actionCallback, validationCallback = null)
  {
    if (this._configurationManager.getValue(flagConfigKey)) {
      this._performOperation(configKey, actionCallback, validationCallback)
    }
    return this
  }

  /**
   * @param {boolean} enableCondition
   * @param {PaginatorConfiguration} configuration
   * @protected
   */
  _setupPaginator(enableCondition, configuration)
  {
    if (enableCondition) {
      configuration.itemSelectors = this._config.itemSelectors
      this._paginator = new BrazenPaginator(configuration)
    }
    this._configurationManager.addNumberField(CONFIG_PAGINATOR_LIMIT, 1, 50).
        setTitle('Pagination Limit').
        setHelpText('Limit paginator to concatenate the specified number of maximum pages.')
    this._configurationManager.addNumberField(CONFIG_PAGINATOR_THRESHOLD, 1, 1000).
        setTitle('Pagination Threshold').
        setHelpText('Make paginator ensure the specified number of minimum results.')
  }

  /**
   * @return {BrazenSubscriptionsLoader}
   * @protected
   */
  _setupSubscriptionLoader()
  {
    this._subscriptionsLoader = new BrazenSubscriptionsLoader(
        (status) => this._subscriptionsLoaderButton.text(status),
        (subscriptions) => {
          this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' +
              subscriptions.join('""') + '"' : ''
          this._configurationManager.save()
          $('#subscriptions-loader').prop('disabled', false)
        })

    this._subscriptionsLoaderButton = this._uiGen.createFormButton(
        'Load Subscriptions',
        'Makes a copy of your subscriptions in cache for related filters.',
        (event) => {
          if (this._config.isUserLoggedIn) {
            $(event.currentTarget).prop('disabled', true)
            this._subscriptionsLoader.run()
          } else {
            this._showNotLoggedInAlert()
          }
        }).attr('id', 'subscriptions-loader')

    return this._subscriptionsLoader
  }

  /**
   * @protected
   */
  _showNotLoggedInAlert()
  {
    alert('You need to be logged in to use this functionality')
  }

  /**
   * @param {boolean} firstRun
   * @protected
   */
  _validateCompliance(firstRun = false)
  {
    if (!this._shouldRunCompliance()) {
      return
    }

    void this._prepareTagComplianceRuntime().then(() => {
      this._runComplianceValidation(firstRun)
    })
  }

  /**
   * @return {Promise<void>}
   * @protected
   */
  async _prepareTagComplianceRuntime()
  {
    if (this._configurationManager.canPersist()) {
      await this._configurationManager.prepareTagComplianceRuntime()
    }
  }

  /**
   * @param {boolean} firstRun
   * @protected
   */
  _runComplianceValidation(firstRun = false)
  {
    if (!this._shouldRunCompliance()) {
      return
    }

    let itemLists = $(this._config.itemListSelectors)
    if (firstRun) {
      itemLists.each((index, itemList) => {
        let itemListObject = $(itemList)

        if (this._paginator && itemListObject.is(this._paginator.getItemListSelector())) {

          ChildObserver.create().onNodesAdded((itemsAdded) => {
            this._complyItemsList($(itemsAdded), true)
            this._paginator.run(this._getConfig(CONFIG_PAGINATOR_THRESHOLD), this._getConfig(CONFIG_PAGINATOR_LIMIT))
          }).observe(itemList)

        } else {
          ChildObserver.create().onNodesAdded((itemsAdded) => {
            this._complyItemsList($(itemsAdded), true)
          }).observe(itemList)
        }

        this._complyItemsList(itemListObject)
      })
    } else {
      this._statistics.reset()
      if (this._complianceRules) {
        this._complianceRules.reset()
      }
      itemLists.each((index, itemsList) => {
        this._complyItemsList($(itemsList))
      })
    }
    if (this._paginator) {
      this._paginator.run(this._getConfig(CONFIG_PAGINATOR_THRESHOLD), this._getConfig(CONFIG_PAGINATOR_LIMIT))
    }
    this._itemAttributesResolver.completeResolutionRun()
    let compliancePanel = document.getElementById('bv-compliance-rules')
    if (compliancePanel && this._isDockSlidePanelVisible(compliancePanel)) {
      this._renderComplianceRulesPanelContent()
    }
  }

  /**
   * @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._get(item, ITEM_NAME), field.optimized)
          : true
      this._statistics.record(FILTER_TEXT_WHITELIST, validationResult)
      return validationResult
    }
    return true
  }

  /**
   * Whether the download duplicate ledger is recording / skipping (Skip Duplicate Downloads).
   * @return {boolean}
   * @protected
   */
  _isDownloadDuplicateLedgerActive()
  {
    if (!this._downloadDuplicateLedgerConfig) {
      return false
    }
    return !!this._getConfig(this._downloadDuplicateLedgerConfig.enableConfigKey)
  }

  /**
   * True when ledger claims should run — Skip Duplicate and/or Hide Downloaded Media.
   * Hide needs claims in the ledger even when the user has not opted into skip-on-download.
   * @return {boolean}
   * @protected
   */
  _shouldClaimDownloadDuplicateLedger()
  {
    if (!this._downloadDuplicateLedgerConfig) {
      return false
    }
    return this._isDownloadDuplicateLedgerActive() || !!this._getConfig(OPTION_HIDE_DOWNLOADED_MEDIA)
  }

  /**
   * @param {string|null|undefined} downloadId
   * @return {boolean}
   * @protected
   */
  _isDownloadLedgerIdRecorded(downloadId)
  {
    let ledgerField = this._getDownloadDuplicateLedgerField()
    if (!ledgerField || downloadId === null || downloadId === undefined) {
      return false
    }
    let id = String(downloadId).trim()
    return !!id && ledgerField.downloadedIds.has(id)
  }

  /**
   * @param {*} item
   * @return {string|null}
   * @protected
   */
  _getDownloadDuplicateLedgerId(item)
  {
    if (!this._shouldClaimDownloadDuplicateLedger()) {
      return null
    }
    let id = Utilities.callEventHandler(this._downloadDuplicateLedgerConfig.getDownloadId, [item], null)
    if (id === null || id === undefined || !String(id).length) {
      return null
    }
    return String(id)
  }

  /**
   * Replaces the in-memory ledger with the current storage snapshot.
   *
   * @return {Promise<void>}
   * @protected
   */
  async _reloadDownloadDuplicateLedgerFromStorage()
  {
    await this._getDownloadDuplicateLedgerField()?.reload()
  }

  /**
   * @return {Promise<void>}
   * @protected
   */
  async _mergeDownloadDuplicateLedgerFromStorage()
  {
    await this._getDownloadDuplicateLedgerField()?.reload()
  }

  /**
   * @param {string|null|undefined} downloadId
   * @return {boolean}
   * @protected
   */
  _isDownloadDuplicate(downloadId)
  {
    if (!this._isDownloadDuplicateLedgerActive()) {
      return false
    }
    return this._isDownloadLedgerIdRecorded(downloadId)
  }

  /**
   * Reserves a download before `GM_download` runs. Tampermonkey often does not fire
   * `onload` in default download mode, so waiting for success would allow repeat attempts.
   *
   * @param {string|null|undefined|Array<string|null|undefined>} downloadId
   * @return {Promise<boolean>}
   * @protected
   */
  async _claimDownloadDuplicateLedgerSlot(downloadId)
  {
    let ledgerField = this._getDownloadDuplicateLedgerField()
    if (!ledgerField || !this._shouldClaimDownloadDuplicateLedger()) {
      return true
    }
    if (Array.isArray(downloadId)) {
      return await ledgerField.claim(downloadId)
    }
    return await ledgerField.claim(downloadId === null || downloadId === undefined ? [] : [downloadId])
  }

  /**
   * @param {{name?: string|null}} download
   * @protected
   */
  _handleDuplicateDownloadSkipped(download)
  {
  }

  /**
   * @param {{name?: string|null}} download
   * @param {*} error
   * @protected
   */
  _handleDownloadFailed(download, error)
  {
    alert('Failed to download:\n\n' + (download.name ?? ''))
    console.log('Download error:', error?.error, error?.details)
  }

  /**
   * @param {string|string[]} names
   * @param {Function} setup
   * @return {BrazenFramework}
   * @protected
   */
  _forPage(names, setup)
  {
    return this._forPages(Array.isArray(names) ? names : [names], setup)
  }

  /**
   * @param {string[]} names
   * @param {Function} setup
   * @return {BrazenFramework}
   * @protected
   */
  _forPages(names, setup)
  {
    if (names.some((name) => this._activePages.has(name))) {
      Utilities.callEventHandler(setup)
    }
    return this
  }

  /**
   * @param {string|string[]} names
   * @param {Function} callback
   * @return {Function}
   * @protected
   */
  _gatePage(names, callback)
  {
    let pageNames = Array.isArray(names) ? names : [names]
    return (...args) => {
      if (pageNames.some((name) => this._activePages.has(name))) {
        return Utilities.callEventHandler(callback, args)
      }
    }
  }

  /**
   * @param {string|string[]} names
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @return {BrazenFramework}
   * @protected
   */
  _performOperationOnPage(names, configKey, actionCallback, validationCallback = null)
  {
    return this._forPage(names, () => this._performOperation(configKey, actionCallback, validationCallback))
  }

  /**
   * @param {string|string[]} names
   * @param {string} flagConfigKey
   * @param {string} configKey
   * @param {function(*)} actionCallback
   * @param {function(*, function?): boolean|null} validationCallback
   * @return {BrazenFramework}
   * @protected
   */
  _performTogglableOperationOnPage(names, flagConfigKey, configKey, actionCallback, validationCallback = null)
  {
    return this._forPage(names, () => this._performTogglableOperation(flagConfigKey, configKey, actionCallback, validationCallback))
  }

  /**
   * Phase 0: evaluate page detectors and resolve layout-aware selectors.
   * @protected
   */
  _initPageDetection()
  {
    this._activePages.clear()
    this._layouts.clear()

    for (let [name, page] of this._pages) {
      if (Utilities.callEventHandler(page.detect)) {
        this._activePages.add(name)
        if (page.layout) {
          this._layouts.set(name, Utilities.callEventHandler(page.layout))
        }
      }
    }

    this._resolveEffectiveSelectors()
  }

  /**
   * @param {SelectorConfig} value
   * @return {JQuery.Selector}
   * @protected
   */
  _resolveSelectorValue(value)
  {
    if (value === null || value === undefined) {
      return value
    }
    if (typeof value === 'function') {
      return Utilities.callEventHandler(value)
    }
    if (typeof value === 'string') {
      return value
    }
    if (typeof value === 'object') {
      for (let pageName of this._activePages) {
        if (pageName in value) {
          return value[pageName]
        }
        let layout = this._layouts.get(pageName)
        if (layout && layout in value) {
          return value[layout]
        }
      }
      if ('default' in value) {
        return value.default
      }
    }
    return value
  }

  /**
   * Resolves selector config values once after page/layout detection.
   * @protected
   */
  _resolveEffectiveSelectors()
  {
    this._config.itemListSelectors = this._resolveSelectorValue(this._config.itemListSelectors)
    this._config.itemSelectors = this._resolveSelectorValue(this._config.itemSelectors)
    this._config.itemNameSelector = this._resolveSelectorValue(this._config.itemNameSelector)
    if (this._config.itemLinkSelector !== undefined) {
      this._config.itemLinkSelector = this._resolveSelectorValue(this._config.itemLinkSelector)
    }
  }

  /**
   * Phase 1: run config-independent page operations for active pages.
   * @return {boolean} `true` when an operation halted the boot pipeline.
   * @protected
   */
  _runPageOperations()
  {
    for (let {pages, operation} of this._pageOperations) {
      if (pages.some((name) => this._activePages.has(name))) {
        let result = Utilities.callEventHandler(operation)
        if (result === true || (result !== null && typeof result === 'object' && result.haltInit === true)) {
          return true
        }
      }
    }
    return false
  }

  /**
   * @return {boolean}
   * @protected
   */
  _shouldRunFullInit()
  {
    if (this._pages.size === 0) {
      return Utilities.callEventHandler(this._onValidateInit)
    }
    let initPages = this._initPages ?? [...this._pages.keys()]
    let hasInitPage = initPages.some((name) => this._activePages.has(name))
    return hasInitPage && Utilities.callEventHandler(this._onValidateInit)
  }

  /**
   * @return {boolean}
   * @protected
   */
  _shouldRunCompliance()
  {
    if (!this._complianceEnabled) {
      return false
    }
    if (this._pages.size === 0) {
      return true
    }
    if (this._compliancePages === null) {
      return this._activePages.size > 0
    }
    return this._compliancePages.some((name) => this._activePages.has(name))
  }

  // -------------------------------------------------------------------------
  // Public class methods
  // -------------------------------------------------------------------------

  /**
   * Initialize the script and do basic UI removals
   */
  async init()
  {
    this._initPageDetection()

    if (this._runPageOperations()) {
      return
    }

    if (!this._shouldRunFullInit()) {
      return
    }

    Utilities.processEventHandlerQueue(this._onBeforeFullInit)

    await this._configurationManager.initialize()

    if (this._configurationManager.isIdbBlocked()) {
      this._showIdbBlockedBanner()
    }

    await this._configurationManager.reloadBookmarkFields()
    Utilities.processEventHandlerQueue(this._onBookmarksHydrate)

    await this._configurationManager.ensureTagRuleSetsCompiled()

    if (this._downloadManager) {
      await this._downloadManager.initialize()
    }

    await this._loadDockOrientationFromStorage()

    this._itemAttributesResolver.addAttribute(ITEM_PROCESSED_ONCE, () => false)

    if (this._config.itemNameSelector !== '') {
      this._itemAttributesResolver.addAttribute(ITEM_NAME, (item) => item.find(this._config.itemNameSelector).text())
    }

    if (this._paginator) {
      this._paginator.initialize()
    }

    Utilities.processEventHandlerQueue(this._onBeforeUIBuild)

    if (!this._disableUI) {

      this._embedUI(this._uiGen.createSettingsSection().append(this._userInterface))
      Utilities.processEventHandlerQueue(this._onAfterUIBuild)

      this._configurationManager.updateInterface()
    }

    this._validateCompliance(true)

    Utilities.processEventHandlerQueue(this._onAfterInitialization)
  }

  /**
   * @param {string} name
   * @param {Function|PageDefinition} detectorOrConfig
   * @return {BrazenFramework}
   */
  definePage(name, detectorOrConfig)
  {
    if (typeof detectorOrConfig === 'function') {
      this._pages.set(name, {detect: detectorOrConfig})
    } else {
      this._pages.set(name, detectorOrConfig)
    }
    return this
  }

  /**
   * @param {{[name: string]: Function|PageDefinition}} pages
   * @return {BrazenFramework}
   */
  definePages(pages)
  {
    for (let name in pages) {
      this.definePage(name, pages[name])
    }
    return this
  }

  /**
   * @param {string} name
   * @return {boolean}
   */
  isPage(name)
  {
    return this._activePages.has(name)
  }

  /**
   * @param {...string} names
   * @return {boolean}
   */
  anyPage(...names)
  {
    return names.some((name) => this._activePages.has(name))
  }

  /**
   * @return {string[]}
   */
  getActivePages()
  {
    return [...this._activePages]
  }

  /**
   * @param {string} [name]
   * @return {string|null}
   */
  getLayout(name)
  {
    if (name !== undefined) {
      return this._layouts.get(name) ?? null
    }
    for (let pageName of this._activePages) {
      if (this._layouts.has(pageName)) {
        return this._layouts.get(pageName) ?? null
      }
    }
    return null
  }

  /**
   * @param {Function} handler
   * @return {BrazenFramework}
   */
  onBookmarksHydrate(handler)
  {
    this._onBookmarksHydrate.push(handler)
    return this
  }

  /**
   * @param {function({manager: BrazenConfigurationManager, source: string, local: boolean})} handler
   * @return {BrazenFramework}
   */
  onConfigurationChange(handler)
  {
    this._onConfigurationChange.push(handler)
    return this
  }

  /**
   * Fired when substitution link-mode UI state changes (including cancel with no persist).
   * @param {Function} handler
   * @return {BrazenFramework}
   */
  onTagSubstitutionUiChange(handler)
  {
    this._onTagSubstitutionUiChange.push(handler)
    return this
  }

  /**
   * @param {string|null} [fieldKey='filename-tag-substitutions']
   * @return {boolean}
   */
  hasTagSubstitutionField(fieldKey = 'filename-tag-substitutions')
  {
    return !!this._configurationManager?.hasField(fieldKey)
  }

  /**
   * @param {string} tagName
   * @param {{normalize?: function(string): string, fieldKey?: string}} [options]
   * @return {'idle'|'linkingSource'|'isSubject'|'linkingTarget'}
   */
  getTagSubstitutionUiState(tagName, options = {})
  {
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let fieldKey = options.fieldKey ?? 'filename-tag-substitutions'
    let normalized = normalize(tagName)
    let source = this._tagSubstitutionLinkSource
    if (source != null) {
      return normalize(source) === normalized ? 'linkingSource' : 'linkingTarget'
    }
    if (this._configurationManager.hasTagSubstitutionSubject(normalized, fieldKey)) {
      return 'isSubject'
    }
    return 'idle'
  }

  /**
   * Advance or apply the substitution link-mode flow for a tag.
   * @param {string} tagName
   * @param {{normalize?: function(string): string, fieldKey?: string}} [options]
   * @return {Promise<void>}
   */
  async toggleTagSubstitutionLink(tagName, options = {})
  {
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let fieldKey = options.fieldKey ?? 'filename-tag-substitutions'
    let state = this.getTagSubstitutionUiState(tagName, {normalize, fieldKey})
    let normalized = normalize(tagName)
    switch (state) {
      case 'idle':
        this._tagSubstitutionLinkSource = normalized
        this._notifyTagSubstitutionUiChange()
        break
      case 'linkingSource':
        this._tagSubstitutionLinkSource = null
        this._notifyTagSubstitutionUiChange()
        break
      case 'isSubject':
        this._tagSubstitutionLinkSource = null
        await this._configurationManager.clearTagSubstitution(normalized, fieldKey)
        this._notifyTagSubstitutionUiChange()
        break
      case 'linkingTarget': {
        let source = this._tagSubstitutionLinkSource
        this._tagSubstitutionLinkSource = null
        if (source) {
          await this._configurationManager.setTagSubstitution(source, normalized, fieldKey)
        }
        this._notifyTagSubstitutionUiChange()
        break
      }
    }
  }

  /**
   * @param {string} tagName
   * @param {{normalize?: function(string): string, fieldKey?: string, className?: string, iconClass?: string, onAfterToggle?: function(): void}} [options]
   * @return {JQuery}
   */
  createTagSubstitutionActionButton(tagName, options = {})
  {
    let normalize = options.normalize
    let fieldKey = options.fieldKey
    return BrazenViewLayer.createTagSubstitutionActionButton({
      state: this.getTagSubstitutionUiState(tagName, {normalize, fieldKey}),
      className: options.className,
      iconClass: options.iconClass,
      onClick: () => {
        void this.toggleTagSubstitutionLink(tagName, {normalize, fieldKey}).then(() => {
          options.onAfterToggle?.()
        })
      },
    })
  }

  /**
   * @private
   */
  _notifyTagSubstitutionUiChange()
  {
    Utilities.processEventHandlerQueue(this._onTagSubstitutionUiChange, [])
  }

  /**
   * @param {string} optionKey
   * @protected
   */
  _enableConfigOption(optionKey)
  {
    if (!optionKey || !this._configurationManager?.hasField(optionKey)) {
      return
    }
    if (this._configurationManager.canPersist()) {
      if (!this._configurationManager.getValue(optionKey)) {
        void this._configurationManager.writeSetting(optionKey, true)
      }
      return
    }
    let enableField = this._configurationManager.getField(optionKey)
    if (enableField && !enableField.value) {
      enableField.value = true
      if (enableField.element) {
        enableField.updateUserInterface()
      }
    }
  }

  /**
   * @param {{name: string, type?: string|null}|string} tag
   * @param {function(string): string} normalize
   * @param {string} [source='tag-action']
   * @return {Promise<void>}
   * @private
   */
  async _ensureTagEntityForAction(tag, normalize, source = 'tag-action')
  {
    let name = typeof tag === 'string' ? tag : tag?.name
    if (!this._configurationManager.canPersist() || !name) {
      return
    }
    let normalized = normalize(name)
    if (!normalized) {
      return
    }
    let typeName = typeof tag === 'object' ? (tag.type ?? null) : null
    await this._configurationManager.getTagRuntime()?.ensureTag(normalized,
        this._configurationManager.createTagContext({typeName, source}))
  }

  /**
   * Toggle a TAG_FIELD_SPEC sole attribute (blacklist / explore / ignore).
   * @param {string} tagName
   * @param {{fieldKey: string, normalize?: function(string): string, ensureOptionKey?: string, tag?: {name: string, type?: string|null}, source?: string}} options
   * @return {Promise<boolean>}
   */
  async toggleTagSoleAttribute(tagName, options = {})
  {
    let fieldKey = options.fieldKey
    if (!fieldKey || !this._configurationManager.hasField(fieldKey)) {
      return false
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let tag = options.tag ?? {name: tagName}
    await this._ensureTagEntityForAction(tag, normalize, options.source ?? 'tag-action')
    if (options.ensureOptionKey) {
      this._enableConfigOption(options.ensureOptionKey)
    }
    let normalized = normalize(tagName)
    let context = this._configurationManager.createTagContext({
      typeName: tag.type ?? null,
      source: options.source ?? 'tag-action',
    })
    if (this._configurationManager.canPersist()) {
      await this._configurationManager.toggleTagRule(fieldKey, normalized, context)
      if (fieldKey === FILTER_TAG_BLACKLIST || fieldKey === 'explored-tags-tracker') {
        await this._configurationManager.ensureTagRuleSetsCompiled()
      }
      return true
    }
    let field = this._configurationManager.getField(fieldKey)
    field?.toggleRule?.(normalized, normalized)
    await this._configurationManager.save()
    return true
  }

  /**
   * @param {string} tagName
   * @param {{fieldKey: string, normalize?: function(string): string}} options
   * @return {boolean}
   */
  hasTagSoleAttributeActive(tagName, options = {})
  {
    let fieldKey = options.fieldKey
    if (!fieldKey) {
      return false
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    return this._configurationManager.hasTagSoleAttribute(fieldKey, normalize(tagName))
  }

  /**
   * @param {string} tagName
   * @param {{
   *   kind: 'ignore'|'blacklist'|'explore',
   *   fieldKey: string,
   *   normalize?: function(string): string,
   *   ensureOptionKey?: string,
   *   tag?: {name: string, type?: string|null},
   *   className?: string,
   *   iconClass?: string,
   *   onAfterToggle?: function(): void,
   * }} options
   * @return {JQuery}
   */
  createTagSoleAttributeActionButton(tagName, options = {})
  {
    let kind = options.kind
    let normalize = options.normalize
    let fieldKey = options.fieldKey
    let active = this.hasTagSoleAttributeActive(tagName, {fieldKey, normalize})
    let titles = {
      ignore: active ? 'Stop ignoring in filenames' : 'Ignore in filenames',
      blacklist: active ? 'Stop hiding posts with this tag' : 'Hide posts with the tag',
      explore: active ? 'Remove tag from explore list' : 'Add tag to explore list',
    }
    let iconClass = options.iconClass ?? 'bv-tag-action-icon'
    let icon = kind === 'ignore' ? BrazenViewLayer.createTagIgnoreIcon(active, iconClass) :
        kind === 'explore' ? BrazenViewLayer.createTagExploreIcon(active, iconClass) :
            BrazenViewLayer.createTagBlacklistIcon(active, iconClass)
    return BrazenViewLayer.createTagAttributeActionButton({
      title: titles[kind] ?? '',
      icon,
      className: options.className ?? 'bv-tag-action-btn',
      onClick: () => {
        void this.toggleTagSoleAttribute(tagName, {
          fieldKey,
          normalize,
          ensureOptionKey: options.ensureOptionKey,
          tag: options.tag ?? {name: tagName},
        }).then(() => options.onAfterToggle?.())
      },
    })
  }

  /**
   * @param {string} tagName
   * @param {{
   *   buildUrl: function(string): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   fieldKey?: string,
   *   normalize?: function(string): string,
   * }} options
   * @return {boolean}
   */
  isTagSearchBookmarked(tagName, options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    let field = this._configurationManager.getField(fieldKey)
    if (!field?.widget || !options.buildUrl) {
      return false
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let url = options.buildUrl(normalize(tagName))
    return !!field.widget.isCurrentPageBookmarked(url)
  }

  /**
   * Toggle bookmark for a single-tag search URL supplied by the consumer.
   * @param {string} tagName
   * @param {{
   *   buildUrl: function(string): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   fieldKey?: string,
   *   normalize?: function(string): string,
   * }} options
   * @return {Promise<void>}
   */
  async toggleTagSearchBookmark(tagName, options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    let field = this._configurationManager.getField(fieldKey)
    if (!field || !options.buildUrl) {
      return
    }
    let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
    let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
    let formatLabel = options.formatLabel ?? ((tags) => String(tags).replaceAll('_', ' '))
    let tags = normalize(tagName)
    let url = options.buildUrl(tags)
    let bookmarked = field.widget?.isCurrentPageBookmarked(url)
    if (bookmarked) {
      let bookmarks = this._configurationManager.isStorageReady() ?
          await this._configurationManager.getRepos().bookmarks.listAll() :
          (field.value ?? [])
      let bookmark = bookmarks.find((entry) => normalizeUrl(entry.url) === normalizeUrl(url))
      if (!bookmark) {
        return
      }
      let entryId = bookmark.entryId ?? bookmark.id
      if (this._configurationManager.isStorageReady()) {
        await this._configurationManager.getRepos().bookmarks.remove(Number(entryId))
      } else {
        field.value = field.value.filter((row) => row.id !== entryId)
        await field.save()
      }
      await field.reload?.()
      field.widget?.render(field._bookmarkRows ?? field.value ?? [])
      return
    }
    let existing = this._configurationManager.isStorageReady() ?
        await this._configurationManager.getRepos().bookmarks.listAll() :
        (field.value ?? [])
    if (existing.some((bookmark) => normalizeUrl(bookmark.url) === normalizeUrl(url))) {
      return
    }
    if (this._configurationManager.isStorageReady()) {
      await this._configurationManager.getRepos().bookmarks.add({
        label: formatLabel(tags),
        tags,
        url,
        sortOrder: 0,
      })
      await field.reload?.()
      field.widget?.render(field._bookmarkRows ?? [])
      return
    }
    field.value.unshift({
      id: typeof crypto !== 'undefined' && crypto.randomUUID ? crypto.randomUUID() : String(Date.now()) + Math.random(),
      label: formatLabel(tags),
      tags,
      url,
    })
    await field.save()
    field.widget?.render(field.value)
  }

  /**
   * @param {string} tagName
   * @param {{
   *   buildUrl: function(string): string,
   *   formatLabel?: function(string): string,
   *   normalizeUrl?: function(string): string,
   *   fieldKey?: string,
   *   normalize?: function(string): string,
   *   className?: string,
   *   iconClass?: string,
   *   onAfterToggle?: function(): void,
   * }} options
   * @return {JQuery|null}
   */
  createTagBookmarkActionButton(tagName, options = {})
  {
    let fieldKey = options.fieldKey ?? 'bookmarks'
    if (!this._configurationManager.hasField(fieldKey) || !options.buildUrl) {
      return null
    }
    let bookmarked = this.isTagSearchBookmarked(tagName, options)
    return BrazenViewLayer.createTagAttributeActionButton({
      title: bookmarked ? 'Remove bookmark' : 'Bookmark',
      icon: BrazenViewLayer.createTagBookmarkStarIcon(bookmarked, options.iconClass ?? 'bv-tag-action-icon'),
      className: options.className ?? 'bv-tag-action-btn',
      onClick: () => {
        void this.toggleTagSearchBookmark(tagName, options).then(() => options.onAfterToggle?.())
      },
    })
  }

  /**
   * Append framework-owned tag attribute actions (bookmark, ignore, substitute, blacklist, explore).
   * Consumer supplies incidence options (`buildUrl`, field keys, CSS classes, feature gates).
   * @param {HTMLElement|JQuery} actionsElement
   * @param {{name: string, type?: string|null}} tag
   * @param {{
   *   className?: string,
   *   iconClass?: string,
   *   ignoreClassName?: string,
   *   normalize?: function(string): string,
   *   bookmark?: {buildUrl: function(string): string, formatLabel?: function(string): string, normalizeUrl?: function(string): string, fieldKey?: string, iconClass?: string}|false,
   *   ignore?: {fieldKey?: string}|false,
   *   substitute?: {fieldKey?: string}|false,
   *   blacklist?: {fieldKey?: string, ensureOptionKey?: string}|false,
   *   explore?: {fieldKey?: string, ensureOptionKey?: string, isEnabled?: function(): boolean}|false,
   *   onAfterToggle?: function(): void,
   * }} [config]
   */
  appendTagAttributeActions(actionsElement, tag, config = {})
  {
    let $actions = $(actionsElement)
    if (!$actions.length || !tag?.name) {
      return
    }
    let normalize = config.normalize ?? ((value) => String(value ?? '').trim())
    let className = config.className ?? 'bv-tag-action-btn'
    let iconClass = config.iconClass ?? 'bv-tag-action-icon'
    let after = config.onAfterToggle
    let buttons = []

    if (config.bookmark !== false && config.bookmark?.buildUrl) {
      let button = this.createTagBookmarkActionButton(tag.name, {
        ...config.bookmark,
        normalize,
        className,
        iconClass: config.bookmark.iconClass ?? iconClass,
        onAfterToggle: after,
      })
      if (button) {
        buttons.push(button)
      }
    }

    let substituted = this._configurationManager.hasTagSubstitutionSubject(
        normalize(tag.name), config.substitute?.fieldKey ?? 'filename-tag-substitutions')

    if (config.ignore !== false) {
      let ignoreKey = config.ignore?.fieldKey ?? 'filename-tag-ignore-list'
      if (this._configurationManager.hasField(ignoreKey) && !substituted) {
        buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
          kind: 'ignore',
          fieldKey: ignoreKey,
          normalize,
          tag,
          className: config.ignoreClassName ?? className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    if (config.substitute !== false) {
      let subKey = config.substitute?.fieldKey ?? 'filename-tag-substitutions'
      if (this.hasTagSubstitutionField(subKey)) {
        buttons.push(this.createTagSubstitutionActionButton(tag.name, {
          fieldKey: subKey,
          normalize,
          className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    if (config.blacklist !== false) {
      let blacklistKey = config.blacklist?.fieldKey ?? FILTER_TAG_BLACKLIST
      if (this._configurationManager.hasField(blacklistKey)) {
        buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
          kind: 'blacklist',
          fieldKey: blacklistKey,
          ensureOptionKey: config.blacklist?.ensureOptionKey,
          normalize,
          tag,
          className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    if (config.explore !== false) {
      let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
      let exploreEnabled = config.explore?.isEnabled ? config.explore.isEnabled() : this._configurationManager.hasField(exploreKey)
      if (exploreEnabled && this._configurationManager.hasField(exploreKey)) {
        buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
          kind: 'explore',
          fieldKey: exploreKey,
          ensureOptionKey: config.explore?.ensureOptionKey,
          normalize,
          tag,
          className,
          iconClass,
          onAfterToggle: after,
        }))
      }
    }

    $actions.append(buttons)
  }

  /**
   * @param {string|string[]} names
   * @param {Function} operation Return truthy or `{haltInit: true}` to skip full init.
   * @return {BrazenFramework}
   */
  addPageOperation(names, operation)
  {
    let pageNames = Array.isArray(names) ? names : [names]
    this._pageOperations.push({pages: pageNames, operation})
    return this
  }

  /**
   * @return {BrazenFramework}
   */
  enableCompliance()
  {
    this._complianceEnabled = true
    return this
  }

  /**
   * @return {BrazenFramework}
   */
  disableCompliance()
  {
    this._complianceEnabled = false
    return this
  }

  /**
   * @return {boolean}
   */
  isComplianceEnabled()
  {
    return this._complianceEnabled
  }

  /**
   * @param {string[]} names Compliance runs only when one of these pages is active.
   * @return {BrazenFramework}
   */
  setCompliancePages(names)
  {
    this._compliancePages = names
    return this
  }

  /**
   * @returns {boolean}
   */
  isUserLoggedIn()
  {
    return this._config.isUserLoggedIn
  }

  registerHighlightStyleClass(styleClass)
  {
    this._highlightClasses += ' ' + styleClass
    return this
  }

  /**
   * @param {{
   *   orientations: string[],
   *   defaultOrientation?: string,
   *   scriptName?: string,
   *   showBranding?: boolean,
   *   onOpenMainPanel?: Function,
   * }} config
   * @return {BrazenFramework}
   */
  configureDock(config)
  {
    let validOrientations = ['left', 'right', 'bottom']
    let orientations = (config.orientations ?? []).filter((orientation) => validOrientations.includes(orientation))
    if (!orientations.length) {
      throw new Error('configureDock() requires at least one valid orientation: left, right, or bottom.')
    }

    let defaultOrientation = config.defaultOrientation ?? orientations[0]
    if (!orientations.includes(defaultOrientation)) {
      defaultOrientation = orientations[0]
    }

    this._dockConfig = {
      orientations,
      defaultOrientation,
      scriptName: config.scriptName ?? '',
      showBranding: config.showBranding === true,
      onOpenMainPanel: config.onOpenMainPanel,
    }
    this._dockOrientation = defaultOrientation

    this._configurationManager.setDockActive(true)
    this._configurationManager.setDockIncludeContext(this)
    this._configurationManager.onDockToggle((field) => {
      this._onSaveSettings()
    })

    if (orientations.length > 1) {
      this._configurationManager.addHeadlessSettingField(OPTION_DOCK_POSITION, defaultOrientation)
    }

    return this
  }

  /**
   * @return {BrazenDownloadManager|null}
   */
  getDownloadManager()
  {
    return this._downloadManager
  }

  /**
   * @param {object} config
   * @return {BrazenFramework}
   */
  configureDownloadManager(config)
  {
    if (!this._dockConfig) {
      throw new Error('configureDownloadManager() requires configureDock() to be called first.')
    }
    this._downloadManager = new BrazenDownloadManager(this, this._configurationManager, config)
    return this
  }

  /**
   * Phase-1 Cloudflare / challenge page: silence queue verification tabs; standalone
   * browsing shows the themed reload dock pane. Requires {@link configureDownloadManager}.
   * @param {{title?: string, message?: string, confirmLabel?: string}} [options]
   * @return {Promise<void>}
   */
  handleMediaCloudflarePage(options = {})
  {
    return this._downloadManager?.handleMediaCloudflarePage(options) ?? Promise.resolve()
  }

  /**
   * @param {{mediaElement: HTMLElement, removeMediaOnSuccess?: boolean, source?: string}} options
   * @return {Promise<void>}
   */
  downloadImmediate(options)
  {
    return this._downloadManager?.downloadImmediate(options) ?? Promise.resolve()
  }

  /**
   * @param {object} context
   * @return {Promise<boolean>}
   */
  enqueueDownload(context)
  {
    return this._downloadManager?.enqueueDownload(context) ?? Promise.resolve(false)
  }

  /**
   * @param {string} itemId
   * @return {Promise<void>}
   */
  dequeueDownload(itemId)
  {
    return this._downloadManager?.dequeueDownload(itemId) ?? Promise.resolve()
  }

  /**
   * @param {string} itemId
   * @return {Promise<boolean>}
   */
  isQueued(itemId)
  {
    return this._downloadManager?.isQueued(itemId) ?? Promise.resolve(false)
  }

  /**
   * @return {Promise<void>}
   */
  confirmTagDiscoveryMappings()
  {
    return this._downloadManager?.confirmTagDiscoveryMappings() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  skipTagDiscoveryInclusion()
  {
    return this._downloadManager?.skipTagDiscoveryInclusion() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  openTagDiscoveryMedia()
  {
    return this._downloadManager?.openTagDiscoveryMedia() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  toggleDownloadManagerPaused()
  {
    return this._downloadManager?.toggleDownloadManagerPaused() ?? Promise.resolve()
  }

  /**
   * @return {Promise<void>}
   */
  clearDownloadQueue()
  {
    return this._downloadManager?.clearDownloadQueue() ?? Promise.resolve()
  }

  /**
   * Wipe all download-ledger ids from IndexedDB and the in-memory Set.
   *
   * @return {Promise<void>}
   */
  clearDownloadDuplicateLedger()
  {
    return this._configurationManager.clearDownloadLedger()
  }

  /**
   * Wipe this script's entire IndexedDB database. Reloads afterward so setup can rebuild it.
   * Prefer calling from a Toolbox confirm button.
   *
   * @return {Promise<void>}
   */
  async clearScriptDatabase()
  {
    await this._configurationManager.clearScriptDatabase()
    location.reload()
  }

  /**
   * @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
   */
  getDownloadManagerProgress()
  {
    return this._downloadManager?.getDownloadManagerProgress() ?? Promise.resolve({
      resolution: {current: 0, total: 0},
      download: {current: 0, total: 0},
    })
  }

  /**
   * @return {Promise<number>}
   */
  getDownloadQueueCount()
  {
    return this._downloadManager?.getDownloadQueueCount() ?? Promise.resolve(0)
  }

  /**
   * @return {Promise<void>}
   */
  toggleTagDiscoveryMode()
  {
    return this._downloadManager?.toggleTagDiscoveryMode() ?? Promise.resolve()
  }

  /**
   * @return {void}
   */
  toggleSelectionMode()
  {
    this._downloadManager?.toggleSelectionMode()
  }

  /**
   * @return {Promise<void>}
   */
  toggleCurrentMediaQueued()
  {
    return this._downloadManager?.toggleCurrentMediaQueued() ?? Promise.resolve()
  }

  /**
   * @param {string} role
   * @return {boolean}
   */
  isDownloadPageRole(role)
  {
    return this._downloadManager?.isDownloadPageRole(role) ?? false
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerEnabled()
  {
    return this._downloadManager?.isDownloadManagerEnabled() ?? false
  }

  /**
   * @return {boolean}
   */
  isDownloadManagerLeaderTab()
  {
    return this._downloadManager?.isDownloadManagerLeaderTab() ?? false
  }
}