Brazen Framework - Configuration Manager

Configuration management for the Brazen user scripts framework

Този скрипт не може да бъде инсталиран директно. Това е библиотека за други скриптове и може да бъде използвана с мета-директива // @require https://update.greasyfork.org/scripts/418665/1875786/Brazen%20Framework%20-%20Configuration%20Manager.js

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey, Greasemonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да инсталирате разширение, като например Tampermonkey .

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Violentmonkey.

За да инсталирате този скрипт, трябва да имате инсталирано разширение като Tampermonkey или Userscripts.

За да инсталирате скрипта, трябва да инсталирате разширение като Tampermonkey.

За да инсталирате този скрипт, трябва да имате инсталиран скриптов мениджър.

(Вече имам скриптов мениджър, искам да го инсталирам!)

Advertisement:

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да инсталирате разширение като Stylus.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

За да инсталирате този стил, трябва да имате инсталиран мениджър на потребителски стилове.

(Вече имам инсталиран мениджър на стиловете, искам да го инсталирам!)

Advertisement:

// ==UserScript==
// @name         Brazen Framework - Configuration Manager
// @namespace    brazenvoid
// @version      4.0.1
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Configuration management for the Brazen user scripts framework
// ==/UserScript==

const CONFIG_BACKUP_VERSION = 2
const CONFIG_TYPE_CHECKBOXES_GROUP = 'checkboxes'
const CONFIG_TYPE_COLOR = 'color'
const CONFIG_TYPE_FLAG = 'flag'
const CONFIG_TYPE_LEDGER = 'ledger'
const CONFIG_TYPE_NUMBER = 'number'
const CONFIG_TYPE_RADIOS_GROUP = 'radios'
const CONFIG_TYPE_RANGE = 'range'
const CONFIG_TYPE_RULESET = 'ruleset'
const CONFIG_TYPE_SELECT = 'select'
const CONFIG_TYPE_TEXT = 'text'
const CONFIG_TYPE_ACTION = 'action'
const CONFIG_TYPE_BOOKMARKS = 'bookmarks'
const LEDGER_BUILTIN_LEGACY_INDEX_FIELDS = ['ids']

class BrazenConfigurationManager
{
  /**
   * @typedef {{key: string, title: string, type: string, element: null|JQuery, value: *, maximum: int, minimum: int, options: string[], helpText: string,
   *            onFormatForUI: ConfigurationManagerRulesetCallback, onTranslateFromUI: ConfigurationManagerRulesetCallback,
   *            onOptimize: ConfigurationManagerRulesetCallback, onMatchRule?: ConfigurationManagerRulesetMatchCallback,
   *            createElement: Function, setFromUserInterface: Function, updateUserInterface: Function,
   *            optimized?: *, persist?: boolean, widget?: *, storage?: string, sortRules?: boolean,
   *            setRules?: function(*): void, findRuleIndex?: function(*): number, hasRule?: function(*): boolean,
   *            addRule?: function(*, *=): void, removeRule?: function(*): void, toggleRule?: function(*, *=): void,
   *            clearRules?: function(): void,
   *            serializeForBackup?: function(): *, applyFromBackup?: function(*): void}} ConfigurationField
   */

  /**
   * @callback ConfigurationManagerRulesetCallback
   * @param {*} values
   */

  /**
   * @callback ConfigurationManagerRulesetMatchCallback
   * @param {*} storedRule
   * @param {*} target
   * @return {boolean}
   */

  /**
   * @callback ExternalConfigurationChangeCallback
   * @param {BrazenConfigurationManager} manager
   */

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

  /**
   * @type {{}}
   * @private
   */
  _config = {}

  /**
   * @type {{local: StorageDriver, gm: StorageDriver}}
   * @private
   */
  _drivers = {
    local: new LocalStorageDriver(),
    gm: new GmStorageDriver(),
  }

  /**
   * @type {{local?: LocalStore|DriverStore, gm?: DriverStore}}
   * @private
   */
  _driverStores = {}

  /**
   * @type {{local?: LocalStore|DriverStore, gm?: DriverStore}}
   * @private
   */
  _driverStoreIds = {}

  /**
   * @type {{local?: *, gm?: *}}
   * @private
   */
  _syncedDriverStoreIds = {}

  /**
   * @type {string|null}
   * @private
   */
  _lastFieldKey = null

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

  /**
   * User-script instance used to evaluate dock `include` callbacks.
   * @type {object|null}
   * @private
   */
  _dockIncludeContext = null

  /**
   * @type {JQuery[]}
   * @private
   */
  _dockElements = []

  /**
   * @type {Function|null}
   * @private
   */
  _onDockToggle = null

  /**
   * @type {string|null}
   * @private
   */
  _legacyScriptPrefix = null

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

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

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

  /**
   * @type {Function|null}
   * @private
   */
  _onMigrationStatusChange = null

  /**
   * @type {ExternalConfigurationChangeCallback|null}
   * @private
   */
  _onExternalConfigurationChange = null

  /**
   * @param {string} scriptPrefix
   * @param {BrazenViewLayer} uiGenerator
   * @param {SearchEnhancerTagSelectorGeneratorCallback} tagSelectorGenerator
   */
  constructor(scriptPrefix, uiGenerator, tagSelectorGenerator)
  {
    this._scriptPrefix = scriptPrefix
    this._tagSelectorGenerator = tagSelectorGenerator
    this._uiGen = uiGenerator
  }

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

  /**
   * @param {string} type
   * @param {string} name
   * @param {*} value
   * @param {string|null} helpText
   * @return ConfigurationField
   * @private
   */
  _createField(type, name, value, helpText)
  {
    let fieldKey = this._formatFieldKey(name)
    let field = this._config[fieldKey]
    if (field) {
      field.key = field.key ?? fieldKey
      if (helpText) {
        field.helpText = helpText
      }
      field.value = value
    } else {
      field = {
        key: fieldKey,
        element: null,
        helpText: helpText,
        title: name,
        type: type,
        value: value,
        storage: STORAGE_DRIVER_LOCAL,
        createElement: null,
        setFromUserInterface: null,
        updateUserInterface: null,
      }
      this._config[fieldKey] = field
    }
    this._lastFieldKey = fieldKey
    this._wireDefaultFieldHooks(field)
    this._wireFieldBuilder(field)
    return field
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _wireFieldBuilder(field)
  {
    field.setTitle = (title) => {
      field.title = title
      return field
    }
    field.setHelpText = (helpText) => {
      field.helpText = helpText
      return field
    }
    field.setDefault = (value) => {
      field.value = value
      if (field.type === CONFIG_TYPE_TEXT) {
        field.textDefault = value
      }
      return field
    }
    field.setRows = (rows) => {
      field.rows = rows
      return field
    }
    field.setMinimum = (minimum) => {
      field.minimum = minimum
      return field
    }
    field.setMaximum = (maximum) => {
      field.maximum = maximum
      return field
    }
    field.setOptions = (keyValuePairs) => {
      field.options = keyValuePairs
      if (field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_SELECT) {
        field.value = keyValuePairs[0][1]
      }
      return field
    }
    field.setTranslateFromUI = (callback) => {
      field.onTranslateFromUI = callback
      return field
    }
    field.setFormatForUI = (callback) => {
      field.onFormatForUI = callback
      return field
    }
    field.setOptimize = (callback) => {
      field.onOptimize = callback
      return field
    }
    field.setSortRules = (sortRules = true) => {
      field.sortRules = sortRules
      return field
    }
    field.setFormatter = (formatter) => {
      field.formatter = formatter
      return field
    }
    field.setStorageKey = (storageKey) => {
      field.customStorageKey = storageKey
      return field
    }
    field.setLegacyStorageKeys = (legacyStorageKeys) => {
      field.legacyStorageKeys = legacyStorageKeys
      return field
    }
    field.setStorage = (driverKey) => {
      field.storage = driverKey
      this._ensureDriverStore(driverKey)
      return field
    }
    field.setPrimaryField = (primaryField) => {
      field.ledgerPrimaryField = primaryField
      return field
    }
    field.setMaxEntries = (maxEntries) => {
      field.ledgerMaxEntries = maxEntries
      return field
    }
    field.setLegacyIdFields = (legacyIdFields) => {
      field.ledgerLegacyIdFields = legacyIdFields
      return field
    }
    field.setLedgerLegacyStorageKeys = (legacyStorageKeys) => {
      field.ledgerLegacyStorageKeys = legacyStorageKeys
      return field
    }
    field.setIsValidId = (isValidId) => {
      field.ledgerIsValidId = isValidId
      return field
    }
    field.setNormalizeBookmarks = (normalizeBookmarks) => {
      field.normalizeBookmarks = normalizeBookmarks
      return field
    }
    field.setShowAddButton = (showAddButton) => {
      field.showAddButton = showAddButton
      return field
    }
    field.setPageMatch = (pageMatch) => {
      field.pageMatch = pageMatch
      return field
    }
    field.setOnAdd = (onAdd) => {
      field.onAdd = onAdd
      return field
    }
    field.setOnSort = (onSort) => {
      field.onSort = onSort
      return field
    }
    field.setOnRemove = (onRemove) => {
      field.onRemove = onRemove
      return field
    }
    field.setOnNavigate = (onNavigate) => {
      field.onNavigate = onNavigate
      return field
    }
    field.setGetRowActions = (getRowActions) => {
      field.getRowActions = getRowActions
      return field
    }
    field.setDockButton = (dockOptions) => {
      field.dock = {...(field.dock ?? {}), ...dockOptions}
      return field
    }
    field.setDockOrder = (order) => {
      field.dockOrder = order
      return field
    }
    field.setDockSlideOut = (childNames) => {
      field.dockSlideOutChildren = childNames
      for (let childName of childNames) {
        let childField = this.getField(childName)
        if (childField) {
          childField.dock = {...(childField.dock ?? {}), parent: field.key}
        }
      }
      return field
    }
    field.setOnClick = (onClick) => {
      field.onClick = onClick
      return field
    }
    field.setAction = (onClick) => {
      field.onClick = onClick
      return field
    }
    field.attachStorage = (driverKey) => {
      field.storage = driverKey
      this._ensureDriverStore(driverKey)
      return field
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _wireDefaultFieldHooks(field)
  {
    if (!field.serializeForBackup) {
      field.serializeForBackup = () => field.value
    }
    if (!field.applyFromBackup) {
      field.applyFromBackup = (blob) => {
        field.value = blob
        if (field.type === CONFIG_TYPE_RULESET) {
          field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
        }
      }
    }
  }

  /**
   * @return {string[]}
   * @private
   */
  _getPersistedStoreDrivers()
  {
    let drivers = new Set()
    for (let key in this._config) {
      let field = this._config[key]
      if (field.persist === false) {
        continue
      }
      drivers.add(field.storage ?? STORAGE_DRIVER_LOCAL)
    }
    return [...drivers]
  }

  /**
   * @param {string} driverKey
   * @private
   */
  _ensureDriverStore(driverKey)
  {
    if (this._driverStores[driverKey]) {
      return
    }
    if (!this._getPersistedStoreDrivers().includes(driverKey)) {
      return
    }

    let settingsKey = this._scriptPrefix + 'settings'
    let settingsIdKey = this._scriptPrefix + 'settings-id'

    if (driverKey === STORAGE_DRIVER_LOCAL) {
      this._driverStores.local = new LocalStore(settingsKey, this._toStoreObject(STORAGE_DRIVER_LOCAL))
      this._driverStoreIds.local = new LocalStore(settingsIdKey, {id: Utilities.generateId()})
      this._driverStores.local.onChange(() => {
        if (this._suppressStoreNotifications) {
          return
        }
        this.updateInterface()
      })
      return
    }

    let driver = this._drivers[driverKey]
    if (!driver) {
      throw new Error('Unknown storage driver "' + driverKey + '"')
    }
    this._driverStores[driverKey] = new DriverStore(driver, settingsKey, this._toStoreObject(driverKey))
    this._driverStoreIds[driverKey] = new DriverStore(driver, settingsIdKey, {id: Utilities.generateId()})
    this._driverStores[driverKey].onChange(() => {
      if (this._suppressStoreNotifications) {
        return
      }
      this.updateInterface()
    })
  }

  /**
   * @return {string[]}
   * @private
   */
  _getUsedDrivers()
  {
    let drivers = new Set()
    for (let key in this._config) {
      drivers.add(this._config[key].storage ?? STORAGE_DRIVER_LOCAL)
    }
    return [...drivers]
  }

  /**
   * @param {string} name
   * @return {string}
   * @private
   */
  _formatFieldKey(name)
  {
    return Utilities.toKebabCase(name)
  }

  /**
   * @param {string[]} rules
   * @return {string[]}
   * @private
   */
  _deduplicateRulesetRules(rules)
  {
    if (!Array.isArray(rules) || !rules.length) {
      return Array.isArray(rules) ? rules : []
    }

    let seen = new Set()
    let deduplicated = []
    for (let rule of rules) {
      if (!seen.has(rule)) {
        seen.add(rule)
        deduplicated.push(rule)
      }
    }
    return deduplicated
  }

  /**
   * @param {Array} rules
   * @param {boolean} useSelectors
   * @return {JQuery.Selector[]}
   * @private
   */
  _optimizeTagRuleset(rules, useSelectors)
  {
    let orTags, iteratedRuleset
    let optimizedRuleset = []

    let expandRuleset = (ruleset, tags) => {

      let grownRuleset = []
      for (let tag of tags) {
        let cleanedTag = tag.trim()
        for (let rule of ruleset) {
          grownRuleset.push([...rule, useSelectors ? this._tagSelectorGenerator(cleanedTag) : cleanedTag])
        }
      }
      return grownRuleset
    }

    let growRuleset = (ruleset, tagToAdd) => {

      if (ruleset.length) {

        tagToAdd = tagToAdd.trim()

        for (let rule of ruleset) {
          rule.push(useSelectors ? this._tagSelectorGenerator(tagToAdd) : tagToAdd)
        }

      } else {

        let tags = typeof tagToAdd === 'string' ? [tagToAdd] : tagToAdd

        for (let tag of tags) {
          let cleanedTag = tag.trim()
          ruleset.push([useSelectors ? this._tagSelectorGenerator(cleanedTag) : cleanedTag])
        }
      }
    }

    for (let rule of rules) {

      iteratedRuleset = []

      for (let andTag of rule.split(' // ')[0].split('&')) {

        orTags = andTag.split('|')
        if (orTags.length === 1) {
          growRuleset(iteratedRuleset, andTag)
        } else if (iteratedRuleset.length) {
          iteratedRuleset = expandRuleset(iteratedRuleset, orTags)
        } else {
          growRuleset(iteratedRuleset, orTags)
        }
      }

      optimizedRuleset = optimizedRuleset.concat(iteratedRuleset)
    }

    return optimizedRuleset.sort((a, b) => a.length - b.length)
  }

  /**
   * @param {string|null} driverKey
   * @private
   */
  _syncDriverStores(driverKey = null)
  {
    let drivers = driverKey ? [driverKey] : this._getPersistedStoreDrivers()

    for (let currentDriverKey of drivers) {
      if (currentDriverKey === STORAGE_DRIVER_LOCAL && !this._driverStores.local) {
        continue
      }
      if (!this._driverStores[currentDriverKey]) {
        continue
      }

      let storeObject = this._driverStores[currentDriverKey].get()

      for (let key in this._config) {
        let field = this._config[key]
        if ((field.storage ?? STORAGE_DRIVER_LOCAL) !== currentDriverKey) {
          continue
        }
        if (field.persist === false) {
          continue
        }
        if (storeObject[key] !== undefined) {
          field.value = storeObject[key]
          if (field.type === CONFIG_TYPE_RULESET) {
            field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
          }
        }
      }
    }
    this.updateInterface()
  }

  /**
   * @param {string|null} driverKey
   * @return {{}}
   * @private
   */
  _toStoreObject(driverKey = null)
  {
    let storeObject = {}
    for (let key in this._config) {
      let field = this._config[key]
      if (field.persist === false) {
        continue
      }
      if (driverKey !== null && (field.storage ?? STORAGE_DRIVER_LOCAL) !== driverKey) {
        continue
      }
      storeObject[key] = field.value
    }
    return storeObject
  }

  /**
   * @param {string} driverKey
   * @param {*} id
   * @private
   */
  _updateDriverStoreId(driverKey, id = null)
  {
    if (id === null) {
      id = Utilities.generateId()
    }
    this._ensureDriverStore(driverKey)
    this._driverStoreIds[driverKey].save({id: id})
    this._syncedDriverStoreIds[driverKey] = id
  }

  /**
   * @param {Object} backupConfig
   * @private
   */
  _restoreVersionedBackup(backupConfig)
  {
    let id = backupConfig.id
    let drivers = backupConfig.drivers ?? {}

    for (let driverKey in drivers) {
      let section = drivers[driverKey]
      if (!section || typeof section !== 'object') {
        continue
      }

      let aggregate = {}
      for (let fieldKey in section) {
        let field = this._config[fieldKey]
        if (!field) {
          continue
        }
        if (field.persist === false) {
          field.applyFromBackup(section[fieldKey])
          continue
        }
        aggregate[fieldKey] = section[fieldKey]
      }

      if (Object.keys(aggregate).length) {
        this._ensureDriverStore(driverKey)
        let currentStore = this._driverStores[driverKey].get()
        this._driverStores[driverKey].save({...currentStore, ...aggregate})
      }
    }

    this._syncDriverStores()
    if (id !== undefined) {
      this._updateDriverStoreId(STORAGE_DRIVER_LOCAL, id)
    }
  }

  /**
   * @param {Object} backupConfig
   * @private
   */
  _restoreLegacyBackup(backupConfig)
  {
    let id = backupConfig.id
    delete backupConfig.id

    this._ensureDriverStore(STORAGE_DRIVER_LOCAL)
    this._driverStores.local.save(backupConfig)
    this._syncDriverStores(STORAGE_DRIVER_LOCAL)
    this._updateDriverStoreId(STORAGE_DRIVER_LOCAL, id)
  }

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

  /**
   * @param {string} driverKey
   * @returns {BrazenConfigurationManager}
   */
  attachStorage(driverKey)
  {
    if (!this._lastFieldKey) {
      throw new Error('attachStorage() must follow an add*Field() call')
    }
    let field = this._config[this._lastFieldKey]
    field.storage = driverKey
    this._ensureDriverStore(driverKey)
    return this
  }

  /**
   * @param {string} name
   * @param {array} keyValuePairs
   * @return {ConfigurationField}
   */
  addCheckboxesGroup(name, keyValuePairs)
  {
    let field = this._createField(CONFIG_TYPE_CHECKBOXES_GROUP, name, [], null)

    field.options = keyValuePairs

    field.createElement = () => {
      field.element = this._uiGen.createFormCheckBoxesGroupSection(field.title, field.options, field.helpText)
      return field.element
    }
    field.setFromUserInterface = () => {
      field.value = []
      field.element.find('input:checked').each((index, element) => {
        field.value.push($(element).attr('data-value'))
      })
    }
    field.updateUserInterface = () => {
      let elements = field.element.find('input')
      for (let key of field.value) {
        elements.filter('[data-value="' + key + '"]').prop('checked', true)
      }
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addColorField(name)
  {
    let field = this._createField(CONFIG_TYPE_COLOR, name, false, null)

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'color', field.helpText)
      field.element = inputGroup.find('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.val()
    }
    field.updateUserInterface = () => {
      field.element.val(field.value)
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addFlagField(name)
  {
    let field = this._createField(CONFIG_TYPE_FLAG, name, false, null)

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'checkbox', field.helpText)
      field.element = inputGroup.find('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.prop('checked')
    }
    field.updateUserInterface = () => {
      if (field.element) {
        field.element.prop('checked', field.value)
      }
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addLedgerField(name)
  {
    let field = this._createField(CONFIG_TYPE_LEDGER, name, null, null)
    field.persist = false
    field.storage = STORAGE_DRIVER_GM

    field.downloadedIds = new Set()
    field.entryKeysBackfilled = false
    field.pendingFlushIds = new Set()
    field._storageMigrated = false

    field._ledgerStorageKey = () => field.customStorageKey ?? this._scriptPrefix + 'download-ledger'
    field._ledgerPrimaryField = () => field.ledgerPrimaryField ?? 'ids'
    field._ledgerMaxEntries = () => field.ledgerMaxEntries ?? 25000
    field._ledgerLegacyIdFields = () => field.ledgerLegacyIdFields ?? []
    field._ledgerIsValidId = () => field.ledgerIsValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)
    field._ledgerDriver = () => this._drivers[field.storage]

    field._ledgerLegacyStorageKeys = () => field.ledgerLegacyStorageKeys ?? []

    field._purgeLegacyLedgerKeys = async (ids, onProgress) => {
      let driver = field._ledgerDriver()
      let canonicalKey = field._ledgerStorageKey()
      let legacyKeys = [...field._ledgerLegacyStorageKeys()].filter((legacyKey) => legacyKey && legacyKey !== canonicalKey)
      if (!legacyKeys.length) {
        return
      }
      let legacyEntryKeys = []
      if (typeof GM_listValues === 'function') {
        for (let storeKey of GM_listValues()) {
          for (let legacyKey of legacyKeys) {
            let prefix = legacyKey + '/e/'
            if (storeKey.startsWith(prefix)) {
              legacyEntryKeys.push(storeKey)
            }
          }
        }
      } else {
        for (let legacyKey of legacyKeys) {
          for (let id of ids) {
            legacyEntryKeys.push(legacyKey + '/e/' + encodeURIComponent(String(id).trim()))
          }
        }
      }
      let total = legacyKeys.length + legacyEntryKeys.length
      let done = 0
      const BATCH = 40
      for (let legacyKey of legacyKeys) {
        this._safeRemoveDriverKey(driver, legacyKey)
        done++
        onProgress?.(`Cleaning legacy registry (${done}/${total})…`)
        await this._yieldToBrowser()
      }
      for (let index = 0; index < legacyEntryKeys.length; index += BATCH) {
        let batch = legacyEntryKeys.slice(index, index + BATCH)
        for (let entryKey of batch) {
          this._safeRemoveDriverKey(driver, entryKey)
        }
        done += batch.length
        onProgress?.(`Cleaning legacy registry (${Math.min(done, total)}/${total})…`)
        await this._yieldToBrowser()
      }
    }

    field._hasLegacyLedgerEntryKeys = () => {
      let canonicalKey = field._ledgerStorageKey()
      for (let legacyKey of field._ledgerLegacyStorageKeys()) {
        if (!legacyKey || legacyKey === canonicalKey) {
          continue
        }
        let prefix = legacyKey + '/e/'
        if (typeof GM_listValues === 'function') {
          for (let storeKey of GM_listValues()) {
            if (storeKey.startsWith(prefix)) {
              return true
            }
          }
        } else {
          let driver = field._ledgerDriver()
          let registry = driver.read(canonicalKey, null)
          let ids = registry && typeof registry === 'object' ? field._collectRegistryIds(registry) : []
          for (let id of ids) {
            if (driver.read(legacyKey + '/e/' + encodeURIComponent(String(id).trim()), null) !== null) {
              return true
            }
          }
        }
      }
      return false
    }

    field._migrateLegacyLedgerEntryKeys = async (ids, onProgress) => {
      let driver = field._ledgerDriver()
      let canonicalKey = field._ledgerStorageKey()
      let legacyKeys = [...field._ledgerLegacyStorageKeys()].filter((legacyKey) => legacyKey && legacyKey !== canonicalKey)
      if (!legacyKeys.length) {
        return
      }
      let migrations = []
      if (typeof GM_listValues === 'function') {
        for (let storeKey of GM_listValues()) {
          for (let legacyKey of legacyKeys) {
            let prefix = legacyKey + '/e/'
            if (!storeKey.startsWith(prefix)) {
              continue
            }
            let suffix = storeKey.slice(prefix.length)
            migrations.push({
              legacyEntryKey: storeKey,
              canonicalEntryKey: canonicalKey + '/e/' + suffix,
            })
          }
        }
      } else {
        for (let legacyKey of legacyKeys) {
          for (let id of ids) {
            let encoded = encodeURIComponent(String(id).trim())
            migrations.push({
              legacyEntryKey: legacyKey + '/e/' + encoded,
              canonicalEntryKey: canonicalKey + '/e/' + encoded,
            })
          }
        }
      }
      const BATCH = 40
      for (let index = 0; index < migrations.length; index += BATCH) {
        let batch = migrations.slice(index, index + BATCH)
        for (let {legacyEntryKey, canonicalEntryKey} of batch) {
          if (driver.read(canonicalEntryKey, null) !== null) {
            continue
          }
          let value = driver.read(legacyEntryKey, null)
          if (value === null) {
            continue
          }
          try {
            driver.write(canonicalEntryKey, value)
          } catch (error) {
            console.log('Failed to migrate ledger entry:', legacyEntryKey, error)
          }
        }
        onProgress?.(`Migrating download registry entries (${Math.min(index + batch.length, migrations.length)}/${migrations.length})…`)
        await this._yieldToBrowser()
      }
    }

    field._hasLegacyLedgerStorage = () => {
      let driver = field._ledgerDriver()
      let canonicalKey = field._ledgerStorageKey()
      for (let legacyKey of field._ledgerLegacyStorageKeys()) {
        if (!legacyKey || legacyKey === canonicalKey) {
          continue
        }
        let legacyRegistry = driver.read(legacyKey, null)
        if (legacyRegistry && typeof legacyRegistry === 'object' && field._collectRegistryIds(legacyRegistry).length) {
          return true
        }
      }
      return false
    }

    field._loadCanonicalLedger = () => {
      field.entryKeysBackfilled = false
      let driver = field._ledgerDriver()
      let canonicalKey = field._ledgerStorageKey()
      let registry = driver.read(canonicalKey, null)
      if (registry && typeof registry === 'object') {
        registry = field._migrateRegistryIfNeeded(registry)
        field.applyRegistry(registry)
        field.backfillEntryKeys()
      } else {
        field.downloadedIds = new Set()
      }
      return field
    }

    field._migrateLedgerToCanonical = async (onProgress) => {
      if (field._storageMigrated && !field._hasLegacyLedgerEntryKeys?.()) {
        return field._loadCanonicalLedger()
      }
      let driver = field._ledgerDriver()
      let canonicalKey = field._ledgerStorageKey()
      let registry = driver.read(canonicalKey, null)
      let ids = registry && typeof registry === 'object' ? field._collectRegistryIds(registry) : []
      let migratedFromLegacy = false
      let hadLegacyEntryKeys = field._hasLegacyLedgerEntryKeys?.()

      if (!ids.length) {
        for (let legacyKey of field._ledgerLegacyStorageKeys()) {
          if (!legacyKey || legacyKey === canonicalKey) {
            continue
          }
          let legacyRegistry = driver.read(legacyKey, null)
          if (legacyRegistry && typeof legacyRegistry === 'object' && field._collectRegistryIds(legacyRegistry).length) {
            registry = legacyRegistry
            migratedFromLegacy = true
            onProgress?.('Migrating download registry…')
            break
          }
        }
      }

      if (registry && typeof registry === 'object') {
        registry = field._migrateRegistryIfNeeded(registry)
        ids = field.readIds(registry)
        try {
          driver.write(canonicalKey, field._normalizeRegistryBlob(ids))
        } catch (error) {
          console.log('Failed to persist ledger registry:', error)
        }
        let verified = driver.read(canonicalKey, null)
        let verifiedIds = verified && typeof verified === 'object' ? field._collectRegistryIds(verified) : []
        if (!verifiedIds.length && ids.length) {
          field.applyRegistry(registry)
          return field
        }
        field.applyRegistry(registry)
        if (hadLegacyEntryKeys) {
          await field._migrateLegacyLedgerEntryKeys(ids, onProgress)
        }
        const BATCH = 40
        for (let index = 0; index < ids.length; index += BATCH) {
          let batch = ids.slice(index, index + BATCH)
          for (let id of batch) {
            if (!field.isEntryStored(id)) {
              try {
                field.setEntry(id)
              } catch (error) {
                console.log('Failed to backfill ledger entry during migration:', id, error)
              }
            }
          }
          onProgress?.(`Migrating download registry (${Math.min(index + batch.length, ids.length)}/${ids.length})…`)
          await this._yieldToBrowser()
        }
        field.entryKeysBackfilled = true
        if (ids.length || migratedFromLegacy || hadLegacyEntryKeys) {
          await field._purgeLegacyLedgerKeys(ids, onProgress)
          field._storageMigrated = true
        }
      } else if (hadLegacyEntryKeys) {
        await field._migrateLegacyLedgerEntryKeys(ids, onProgress)
        await field._purgeLegacyLedgerKeys(ids, onProgress)
        field._storageMigrated = true
      } else {
        field.downloadedIds = new Set()
        if (!field._hasLegacyLedgerStorage()) {
          field._storageMigrated = true
        }
      }
      return field
    }

    field.getEntryKey = (id) => field._ledgerStorageKey() + '/e/' + encodeURIComponent(String(id).trim())

    field.isEntryStored = (id) => {
      try {
        return field._ledgerDriver().read(field.getEntryKey(id), null) !== null
      } catch (error) {
        console.log('Failed to read ledger entry:', error)
        return false
      }
    }

    field.setEntry = (id) => {
      field._ledgerDriver().write(field.getEntryKey(id), Date.now())
    }

    field.deleteEntry = (id) => {
      field._ledgerDriver().remove(field.getEntryKey(id))
    }

    field._ledgerIndexFieldNames = () => {
      let names = new Set([field._ledgerPrimaryField(), ...field._ledgerLegacyIdFields()])
      for (let legacyField of LEDGER_BUILTIN_LEGACY_INDEX_FIELDS) {
        if (legacyField !== field._ledgerPrimaryField()) {
          names.add(legacyField)
        }
      }
      return [...names]
    }

    field._extractIdFromLegacyValue = (value) => {
      if (value === null || value === undefined) {
        return null
      }
      let text = String(value).trim()
      if (!text.length) {
        return null
      }
      if (field._ledgerIsValidId()(text)) {
        return text
      }
      let match = text.match(/[?&]id=(\d+)/)
      return match ? match[1] : null
    }

    field._collectRegistryIds = (registry) => {
      let ids = []
      let seen = new Set()
      let primaryField = field._ledgerPrimaryField()
      let addValue = (value) => {
        let id = field._extractIdFromLegacyValue(value)
        if (!id || seen.has(id)) {
          return
        }
        seen.add(id)
        ids.push(id)
      }
      if (Array.isArray(registry[primaryField])) {
        for (let value of registry[primaryField]) {
          addValue(value)
        }
      }
      for (let indexField of field._ledgerIndexFieldNames()) {
        if (indexField === primaryField || !Array.isArray(registry[indexField])) {
          continue
        }
        for (let value of registry[indexField]) {
          addValue(value)
        }
      }
      return ids
    }

    field.readIds = (registry) => field._collectRegistryIds(registry)

    field._normalizeRegistryBlob = (ids) => ({[field._ledgerPrimaryField()]: ids})

    field._registryHasLegacyIndexFields = (registry) => {
      let primaryField = field._ledgerPrimaryField()
      return Object.keys(registry).some((key) => key !== primaryField && Array.isArray(registry[key]))
    }

    field._registryPrimaryMatchesIds = (registry, ids) => {
      let primaryField = field._ledgerPrimaryField()
      if (!Array.isArray(registry[primaryField])) {
        return !ids.length
      }
      if (registry[primaryField].length !== ids.length) {
        return false
      }
      for (let index = 0; index < ids.length; index++) {
        if (String(registry[primaryField][index]).trim() !== ids[index]) {
          return false
        }
      }
      return true
    }

    field._trimRegistryIdsFifo = (ids) => {
      let maxEntries = field._ledgerMaxEntries()
      if (ids.length <= maxEntries) {
        return ids
      }
      for (let droppedId of ids.slice(0, ids.length - maxEntries)) {
        try {
          field.deleteEntry(droppedId)
        } catch (error) {
          console.log('Failed to delete trimmed ledger entry:', droppedId, error)
        }
      }
      return ids.slice(-maxEntries)
    }

    field._migrateRegistryIfNeeded = (registry) => {
      let ids = field._trimRegistryIdsFifo(field._collectRegistryIds(registry))
      let normalizedRegistry = field._normalizeRegistryBlob(ids)
      let needsBlobRewrite = field._registryHasLegacyIndexFields(registry) || !field._registryPrimaryMatchesIds(registry, ids)
      let needsEntryBackfill = ids.some((id) => !field.isEntryStored(id))
      if (needsEntryBackfill) {
        for (let id of ids) {
          if (field.isEntryStored(id)) {
            continue
          }
          try {
            field.setEntry(id)
          } catch (error) {
            console.log('Failed to backfill ledger entry during migration:', id, error)
          }
        }
      }
      if (needsBlobRewrite) {
        try {
          field._ledgerDriver().write(field._ledgerStorageKey(), normalizedRegistry)
        } catch (error) {
          console.log('Failed to persist migrated ledger registry:', error)
        }
      }
      return normalizedRegistry
    }

    field.backfillEntryKeys = () => {
      if (field.entryKeysBackfilled) {
        return
      }
      field.entryKeysBackfilled = true
      for (let id of field.downloadedIds) {
        if (!field.isEntryStored(id)) {
          try {
            field.setEntry(id)
          } catch (error) {
            console.log('Failed to backfill ledger entry:', id, error)
          }
        }
      }
    }

    field.applyRegistry = (registry) => {
      field.downloadedIds = new Set(field.readIds(registry))
    }

    field.reload = () => {
      try {
        field._loadCanonicalLedger()
        if (field.downloadedIds.size) {
          return field
        }
        if (!field._hasLegacyLedgerStorage?.()) {
          return field
        }
        let driver = field._ledgerDriver()
        let canonicalKey = field._ledgerStorageKey()
        for (let legacyKey of field._ledgerLegacyStorageKeys()) {
          if (!legacyKey || legacyKey === canonicalKey) {
            continue
          }
          let legacyRegistry = driver.read(legacyKey, null)
          if (legacyRegistry && typeof legacyRegistry === 'object' && field._collectRegistryIds(legacyRegistry).length) {
            let registry = field._migrateRegistryIfNeeded(legacyRegistry)
            let ids = field.readIds(registry)
            try {
              driver.write(canonicalKey, field._normalizeRegistryBlob(ids))
            } catch (error) {
              console.log('Failed to promote legacy ledger registry:', error)
            }
            let verified = driver.read(canonicalKey, null)
            if (verified && typeof verified === 'object' && field._collectRegistryIds(verified).length) {
              field.applyRegistry(registry)
              field.backfillEntryKeys()
            }
            break
          }
        }
      } catch (error) {
        console.log('Failed to load ledger:', error)
      }
      return field
    }

    field.has = (id) => {
      if (id === null || id === undefined) {
        return false
      }
      id = String(id).trim()
      if (field.isEntryStored(id)) {
        return true
      }
      return field.downloadedIds.has(id)
    }

    field.appendIndexId = (id) => {
      let isValidId = field._ledgerIsValidId()
      if (!isValidId(id)) {
        return
      }
      id = String(id).trim()
      field.downloadedIds.add(id)
      try {
        let storageKey = field._ledgerStorageKey()
        let primaryField = field._ledgerPrimaryField()
        let maxEntries = field._ledgerMaxEntries()
        let registry = field._ledgerDriver().read(storageKey, null)
        if (!registry || typeof registry !== 'object') {
          registry = {}
        }
        let ids = Array.isArray(registry[primaryField])
            ? registry[primaryField].map((value) => String(value).trim()).filter(Boolean)
            : []
        if (ids.includes(id)) {
          return
        }
        ids.push(id)
        if (ids.length > maxEntries) {
          for (let droppedId of ids.slice(0, ids.length - maxEntries)) {
            field.deleteEntry(droppedId)
          }
          ids = ids.slice(-maxEntries)
        }
        registry[primaryField] = ids
        field._ledgerDriver().write(storageKey, registry)
      } catch (error) {
        console.log('Failed to append ledger index id:', error)
      }
    }

    field.add = (id) => {
      if (!field._ledgerIsValidId()(id)) {
        return
      }
      id = String(id).trim()
      try {
        field.setEntry(id)
      } catch (error) {
        console.log('Failed to set ledger entry:', id, error)
      }
      field.appendIndexId(id)
    }

    field.claim = (downloadIds) => {
      let isValidId = field._ledgerIsValidId()
      let ids = [...new Set(downloadIds
          .filter((id) => id !== null && id !== undefined && String(id).length)
          .map((id) => String(id).trim()))]
      if (!ids.length) {
        return true
      }
      field.reload()
      for (let id of ids) {
        if (!isValidId(id)) {
          continue
        }
        if (field.has(id)) {
          return false
        }
      }
      for (let id of ids) {
        if (!isValidId(id)) {
          continue
        }
        field.add(id)
        field.pendingFlushIds.add(id)
      }
      return true
    }

    field.flushPending = () => {
      for (let id of field.pendingFlushIds) {
        field.appendIndexId(id)
      }
      field.pendingFlushIds.clear()
    }

    field.merge = (registry) => {
      if (!registry || typeof registry !== 'object') {
        return
      }
      for (let id of field.readIds(registry)) {
        field.add(id)
      }
      field.reload()
    }

    field.serializeForBackup = () => {
      field.reload()
      let ids = [...field.downloadedIds]
      return ids.length ? {[field._ledgerPrimaryField()]: ids} : null
    }

    field.applyFromBackup = (data) => {
      field.merge(data)
    }

    field.createElement = () => {
      field.element = $('<div></div>')
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {}

    return field
  }

  /**
   * @param {string} name
   * @param {int} minimum
   * @param {int} maximum
   * @return {ConfigurationField}
   */
  addNumberField(name, minimum, maximum)
  {
    let field = this._createField(CONFIG_TYPE_NUMBER, name, minimum, null)

    field.minimum = minimum
    field.maximum = maximum

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'number', field.helpText).
          attr('min', field.minimum).
          attr('max', field.maximum)
      field.element = inputGroup.find('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = Number.parseInt(field.element.val().toString())
    }
    field.updateUserInterface = () => {
      field.element.val(field.value)
    }
    return field
  }

  /**
   * @param {string} name
   * @param {array} keyValuePairs
   * @return {ConfigurationField}
   */
  addRadiosGroup(name, keyValuePairs)
  {
    let field = this._createField(CONFIG_TYPE_RADIOS_GROUP, name, keyValuePairs[0][1], null)

    field.options = keyValuePairs

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, field.helpText)
      field.element = inputGroup
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.find('input:checked').attr('data-value')
    }
    field.updateUserInterface = () => {
      field.element.find('input[data-value="' + field.value + '"]').prop('checked', true).trigger('change')
    }
    return field
  }

  /**
   * @param {string} name
   * @param {int} minimum
   * @param {int} maximum
   * @return {ConfigurationField}
   */
  addRangeField(name, minimum, maximum)
  {
    let field = this._createField(CONFIG_TYPE_RANGE, name, {minimum: minimum, maximum: minimum}, null)

    field.minimum = minimum
    field.maximum = maximum

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormRangeInputGroup(field.title, 'number', field.minimum, field.maximum,
          field.helpText)
      field.element = inputGroup.find('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = {
        minimum: field.element.first().val(),
        maximum: field.element.last().val(),
      }
    }
    field.updateUserInterface = () => {
      field.element.first().val(field.value.minimum)
      field.element.last().val(field.value.maximum)
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addRulesetField(name)
  {
    let field = this._createField(CONFIG_TYPE_RULESET, name, [], null)

    field.rows = 3
    field.optimized = null
    field.onTranslateFromUI = field.onTranslateFromUI ?? null
    field.onFormatForUI = field.onFormatForUI ?? null
    field.onOptimize = field.onOptimize ?? null
    field.sortRules = false
    field.onMatchRule = null

    field.setRules = (lines) => {
      if (field.onTranslateFromUI) {
        lines = Utilities.callEventHandler(field.onTranslateFromUI, [lines], lines)
      }
      if (field.sortRules) {
        let sortKey = (rule) => {
          if (typeof rule === 'string') {
            return rule
          }
          let formatted = Utilities.callEventHandler(field.onFormatForUI, [[rule]], [])
          return formatted[0] ?? ''
        }
        lines = lines.sort((left, right) =>
            sortKey(left).localeCompare(sortKey(right), undefined, {sensitivity: 'base'}))
      }
      field.value = lines
      field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
      if (field.element) {
        field.updateUserInterface()
      }
    }
    field.findRuleIndex = (target) => {
      let rules = field.value ?? []
      for (let index = 0; index < rules.length; index++) {
        let matches = field.onMatchRule
            ? Utilities.callEventHandler(field.onMatchRule, [rules[index], target])
            : rules[index] === target
        if (matches) {
          return index
        }
      }
      return -1
    }
    field.hasRule = (target) => field.findRuleIndex(target) >= 0
    field.addRule = (rule, target = rule) => {
      let lines = (field.value ?? []).filter((storedRule) => {
        return field.onMatchRule
            ? !Utilities.callEventHandler(field.onMatchRule, [storedRule, target])
            : storedRule !== target
      })
      lines.push(rule)
      field.setRules(lines)
    }
    field.removeRule = (target) => {
      let lines = (field.value ?? []).filter((storedRule) => {
        return field.onMatchRule
            ? !Utilities.callEventHandler(field.onMatchRule, [storedRule, target])
            : storedRule !== target
      })
      field.setRules(lines)
    }
    field.toggleRule = (rule, target = rule) => {
      if (field.hasRule(target)) {
        field.removeRule(target)
      } else {
        field.addRule(rule, target)
      }
    }
    field.clearRules = () => {
      field.setRules([])
    }

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormTextAreaGroup(field.title, field.rows, field.helpText)
      field.element = inputGroup.find('textarea')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let value = Utilities.trimAndKeepNonEmptyStrings(field.element.val().split(REGEX_LINE_BREAK))
      if (field.sortRules) {
        value = value.sort((a, b) => a.localeCompare(b, undefined, {sensitivity: "base"}))
      }
      value = Utilities.callEventHandler(field.onTranslateFromUI, [value], value)
      let deduplicated = this._deduplicateRulesetRules(value)
      field.value = deduplicated
      field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
      if (deduplicated.length !== value.length) {
        field.updateUserInterface()
      }
    }
    field.updateUserInterface = () => {
      field.element.val(Utilities.callEventHandler(field.onFormatForUI, [field.value], field.value).join('\n'))
    }
    field.applyFromBackup = (blob) => {
      field.value = blob
      field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
    }
    return field
  }

  /**
   * @param {string} name
   * @param {array} keyValuePairs
   * @return {ConfigurationField}
   */
  addSelectField(name, keyValuePairs)
  {
    let field = this._createField(CONFIG_TYPE_SELECT, name, keyValuePairs[0][1], null)

    field.options = keyValuePairs

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, field.helpText)
      field.element = inputGroup.find('select')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.val()
    }
    field.updateUserInterface = () => {
      field.element.val(field.value).trigger('change')
    }
    return field
  }

  /**
   * @param {string} key
   * @param {boolean} useSelectors
   * @return {ConfigurationField}
   */
  addTagRulesetField(key, useSelectors)
  {
    let field = this.addRulesetField(key)
    field.useSelectors = useSelectors
    field.setOptimize((rules) => {
      if (field.formatter) {
        rules = field.formatter(rules)
      }
      return this._optimizeTagRuleset(rules, useSelectors)
    })
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addTextField(name)
  {
    let field = this._createField(CONFIG_TYPE_TEXT, name, '', null)
    field.textDefault = ''

    field.createElement = () => {
      let inputGroup = this._uiGen.createFormInputGroup(field.title, 'text', field.helpText)
      field.element = inputGroup.find('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let value = field.element.val()
      field.value = value === '' ? field.textDefault : value
    }
    field.updateUserInterface = () => {
      field.element.val(field.value)
    }
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addActionField(name)
  {
    let field = this._createField(CONFIG_TYPE_ACTION, name, null, null)
    field.persist = false

    field.createElement = () => {
      if (this._dockActive && field.dock) {
        field.element = null
        return $()
      }
      field.element = this._uiGen.createFormButton(field.title, field.helpText ?? '', () => {
        Utilities.callEventHandler(field.onClick, [])
      })
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {}
    return field
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addBookmarksField(name)
  {
    let field = this._createField(CONFIG_TYPE_BOOKMARKS, name, [], null)
    field.persist = false
    field.storage = field.storage ?? STORAGE_DRIVER_GM
    field.widget = null
    field.showAddButton = true
    field._storageMigrated = false

    field._bookmarksStorageKey = () => field.customStorageKey ?? this._scriptPrefix + 'bookmarks'
    field._bookmarksDriver = () => this._drivers[field.storage]
    field._normalizeBookmarks = () => field.normalizeBookmarks ?? ((bookmarks) => bookmarks)

    field._bookmarksFieldKey = () => field.key

    field._bookmarksLegacyStorageKeys = () => {
      let keys = [...(field.legacyStorageKeys ?? [])]
      if (this._legacyScriptPrefix && this._legacyScriptPrefix !== this._scriptPrefix) {
        let defaultLegacyKey = this._legacyScriptPrefix + 'bookmarks'
        if (!keys.includes(defaultLegacyKey)) {
          keys.unshift(defaultLegacyKey)
        }
      }
      return keys
    }

    field._collectLegacyBookmarkData = () => {
      let driver = field._bookmarksDriver()
      for (let legacyKey of field._bookmarksLegacyStorageKeys()) {
        let legacyStored = this._coerceBookmarkArray(driver.read(legacyKey, null))
        if (legacyStored.length) {
          return legacyStored
        }
      }
      if (this._legacyScriptPrefix) {
        let fieldKey = field._bookmarksFieldKey()
        let legacySettings = driver.read(this._legacyScriptPrefix + 'settings', null)
        let legacyBookmarks = this._coerceBookmarkArray(legacySettings?.[fieldKey])
        if (legacyBookmarks.length) {
          return legacyBookmarks
        }
        let currentSettings = driver.read(this._scriptPrefix + 'settings', null)
        let currentBookmarks = this._coerceBookmarkArray(currentSettings?.[fieldKey])
        if (currentBookmarks.length) {
          return currentBookmarks
        }
      }
      return null
    }

    field._hasLegacyBookmarkStorage = () => !!field._collectLegacyBookmarkData()

    field._purgeBookmarkLegacyStorage = () => {
      let driver = field._bookmarksDriver()
      let driverKey = field.storage ?? STORAGE_DRIVER_GM
      let canonicalKey = field._bookmarksStorageKey()
      for (let legacyKey of field._bookmarksLegacyStorageKeys()) {
        if (!legacyKey || legacyKey === canonicalKey) {
          continue
        }
        this._safeRemoveDriverKey(driver, legacyKey)
      }
      let fieldKey = field._bookmarksFieldKey()
      for (let prefix of [this._scriptPrefix, this._legacyScriptPrefix]) {
        if (!prefix) {
          continue
        }
        let settingsKey = prefix + 'settings'
        let settings = driver.read(settingsKey, null)
        if (!settings || !(fieldKey in settings)) {
          continue
        }
        delete settings[fieldKey]
        driver.write(settingsKey, settings)
      }
    }

    field._loadCanonicalBookmarks = () => {
      let storageKey = field._bookmarksStorageKey()
      let stored = field._bookmarksDriver().read(storageKey, null)
      field.value = field._normalizeBookmarks()(this._coerceBookmarkArray(stored))
      return field.value
    }

    field._migrateBookmarksToCanonical = () => {
      if (field._storageMigrated) {
        field._loadCanonicalBookmarks()
        if (field.value.length || !field._hasLegacyBookmarkStorage?.()) {
          return field.value
        }
        field._storageMigrated = false
      }
      let storageKey = field._bookmarksStorageKey()
      let driver = field._bookmarksDriver()
      let stored = driver.read(storageKey, null)
      let canonical = field._normalizeBookmarks()(this._coerceBookmarkArray(stored))
      let migrated = canonical.length > 0

      if (!canonical.length) {
        let legacyData = field._collectLegacyBookmarkData()
        if (legacyData) {
          canonical = field._normalizeBookmarks()(legacyData)
          if (canonical.length) {
            driver.write(storageKey, canonical)
            let verified = driver.read(storageKey, null)
            migrated = field._normalizeBookmarks()(this._coerceBookmarkArray(verified)).length > 0
          }
        }
      }

      field.value = canonical
      if (migrated) {
        field._purgeBookmarkLegacyStorage()
        field._storageMigrated = true
        console.log('Brazen bookmarks migrated:', canonical.length, '→', storageKey)
      }
      return canonical
    }

    field._applyBookmarkValues = (stored) => {
      let normalized = field._normalizeBookmarks()(this._coerceBookmarkArray(stored))
      field.value = normalized
      return normalized
    }

    field.reload = () => {
      try {
        if (field._storageMigrated && !field.value.length && field._hasLegacyBookmarkStorage?.()) {
          field._storageMigrated = false
        }
        field._loadCanonicalBookmarks()
        if (!field.value.length) {
          field._migrateBookmarksToCanonical()
        }
        if (!field.value.length && field._storageMigrated) {
          field._storageMigrated = false
        }
      } catch (error) {
        console.log('Failed to load bookmarks:', error)
      }
      return field
    }

    field.save = () => {
      try {
        field._bookmarksDriver().write(field._bookmarksStorageKey(), field.value)
      } catch (error) {
        console.log('Failed to save bookmarks:', error)
      }
      return field
    }

    field.serializeForBackup = () => {
      field.reload()
      return field.value.length ? field.value : null
    }

    field.applyFromBackup = (data) => {
      if (!Array.isArray(data)) {
        return
      }
      field.value = field._normalizeBookmarks()(data)
      field.save()
      field.widget?.render(field.value)
    }

    field.createElement = () => {
      field.reload()
      field.widget = this._uiGen.createBookmarksPanel({
        addHelpText: field.helpText ?? '',
        showAddButton: field.showAddButton !== false,
        pageMatch: field.pageMatch,
        onAdd: () => Utilities.callEventHandler(field.onAdd, []),
        onSort: () => Utilities.callEventHandler(field.onSort, []),
        onRemove: (id) => Utilities.callEventHandler(field.onRemove, [id]),
        onNavigate: (url) => {
          if (field.onNavigate) {
            field.onNavigate(url)
          } else {
            location.href = url
          }
        },
        getRowActions: (bookmark) => Utilities.callEventHandler(field.getRowActions, [bookmark], []),
      })
      field.element = field.widget.element
      field.widget.render(field.value)
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {
      if (!field.widget) {
        return
      }
      field.reload()
      field.widget.render(field.value)
      field.widget.checkPageMatch?.()
    }

    return field
  }

  /**
   * @returns {string}
   */
  backup()
  {
    let drivers = {}
    for (let driverKey of this._getUsedDrivers()) {
      let section = {}
      for (let fieldKey in this._config) {
        let field = this._config[fieldKey]
        if ((field.storage ?? STORAGE_DRIVER_LOCAL) !== driverKey) {
          continue
        }
        if (field.persist === false && !field.serializeForBackup) {
          continue
        }
        let blob = field.serializeForBackup()
        if (blob !== null && blob !== undefined) {
          section[fieldKey] = blob
        }
      }
      if (Object.keys(section).length) {
        drivers[driverKey] = section
      }
    }

    let backupConfig = {
      version: CONFIG_BACKUP_VERSION,
      id: this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL],
      drivers,
    }
    backupConfig = JSON.stringify(backupConfig)

    let link = document.createElement('a')
    link.download = this._scriptPrefix + 'backup.json'
    link.href = URL.createObjectURL(new Blob([backupConfig], {
      type: 'application/json',
    }))
    link.click()
  }

  /**
   * @return {ConfigurationField[]}
   */
  getDockRootFields()
  {
    let fields = []
    for (let key in this._config) {
      let field = this._config[key]
      if (field.dock && !field.dock.parent) {
        fields.push(field)
      }
    }
    return fields.sort((left, right) => (left.dockOrder ?? 100) - (right.dockOrder ?? 100))
  }

  /**
   * @param {string} name
   * @returns {JQuery}
   */
  createElement(name)
  {
    let field = this.getFieldOrFail(name)
    if (this._dockActive && field.dock) {
      field.element = null
      return $()
    }
    return field.createElement()
  }

  /**
   * @param {string} name
   * @return {JQuery|null}
   */
  createDockElement(name)
  {
    let field = this.getFieldOrFail(name)
    if (!field.dock) {
      return null
    }
    if (field.dock.parent) {
      return null
    }
    if (!this._evaluateDockInclude(field)) {
      return null
    }

    let dockButton = this._buildDockButtonNode(field)
    let childFields = this._getDockChildFields(field.key)
    let childButtons = []
    if (childFields.length && this._shouldShowDockSlideOut(field)) {
      for (let childField of childFields) {
        let childNode = this._createDockChildElement(childField)
        if (childNode) {
          childButtons.push(childNode)
        }
      }
      if (childButtons.length) {
        dockButton = this._uiGen.createDockSlideOut(dockButton, childButtons)
      }
    }

    field._dockShowsSlideOut = childButtons.length > 0
    field.dockElement = dockButton
    return dockButton
  }

  /**
   * @param {ConfigurationField} childField
   * @return {JQuery|null}
   * @private
   */
  _createDockChildElement(childField)
  {
    if (!childField.dock) {
      return null
    }
    if (!this._evaluateDockInclude(childField)) {
      return null
    }
    let childButton = this._buildDockButtonNode(childField)
    childField.dockElement = childButton
    return childButton
  }

  /**
   * @param {ConfigurationField} field
   * @return {JQuery}
   * @private
   */
  _buildDockButtonNode(field)
  {
    let stateClass = this._resolveDockStateClass(field)
    let tooltip = this._resolveDockTooltip(field)
    let icon = typeof field.dock.icon === 'function' ? field.dock.icon(field.value, field) : field.dock.icon
    let button = this._uiGen.createDockButton({
      icon: icon,
      tooltip: tooltip,
      stateClass: stateClass,
      onClick: () => this._handleDockButtonClick(field),
    })
    if (field.dock.buttonClass) {
      button.addClass(field.dock.buttonClass)
    }
    return button
  }

  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _resolveDockStateClass(field)
  {
    if (field.dock.getState) {
      return field.dock.getState(field.value, field) ?? ''
    }
    if (field.dock.isActive !== undefined) {
      let active = typeof field.dock.isActive === 'function' ? field.dock.isActive(field.value, field) : field.dock.isActive
      return active ? 'bv-dock-btn-active' : ''
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      return field.value ? 'bv-dock-btn-active' : ''
    }
    return ''
  }

  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _resolveDockTooltip(field)
  {
    if (typeof field.dock.tooltip === 'function') {
      return field.dock.tooltip(field.value, field) ?? field.title
    }
    return field.dock.tooltip ?? field.helpText ?? field.title
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _handleDockButtonClick(field)
  {
    if (field.dock.onClick) {
      Utilities.callEventHandler(field.dock.onClick, [field])
      this.updateDockInterface()
      return
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      field.value = !field.value
      Utilities.callEventHandler(this._onDockToggle, [field])
      this.updateDockInterface()
      return
    }
    if (field.type === CONFIG_TYPE_ACTION && field.onClick) {
      Utilities.callEventHandler(field.onClick, [])
      this.updateDockInterface()
    }
  }

  /**
   * @param {string} parentTitle
   * @return {ConfigurationField[]}
   * @private
   */
  _getDockChildFields(parentKey)
  {
    let parentField = this.getField(parentKey)
    let children = []
    if (parentField?.dockSlideOutChildren) {
      for (let childName of parentField.dockSlideOutChildren) {
        let child = this.getField(childName)
        if (child && child.dock?.parent === parentKey) {
          children.push(child)
        }
      }
    }
    for (let key in this._config) {
      let field = this._config[key]
      if (field.dock?.parent === parentKey && !children.includes(field)) {
        children.push(field)
      }
    }
    return children
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _shouldShowDockSlideOut(field)
  {
    if (field.dock.slideOutWhen) {
      return !!Utilities.callEventHandler(field.dock.slideOutWhen, [field.value, field])
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      return !!field.value
    }
    return true
  }

  /**
   * @param {boolean} active
   * @return {BrazenConfigurationManager}
   */
  setDockActive(active)
  {
    this._dockActive = active
    return this
  }

  /**
   * @param {object|null} context
   * @return {BrazenConfigurationManager}
   */
  setDockIncludeContext(context)
  {
    this._dockIncludeContext = context ?? null
    return this
  }

  /**
   * @param {Function} callback
   * @return {BrazenConfigurationManager}
   */
  onDockToggle(callback)
  {
    this._onDockToggle = callback
    return this
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _computeDockShowsSlideOut(field)
  {
    if (!this._shouldShowDockSlideOut(field)) {
      return false
    }
    for (let childField of this._getDockChildFields(field.key)) {
      if (this._evaluateDockInclude(childField)) {
        return true
      }
    }
    return false
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _evaluateDockInclude(field)
  {
    if (field.dock?.include === undefined) {
      return true
    }
    if (typeof field.dock.include === 'function') {
      if (this._dockIncludeContext) {
        return !!field.dock.include.call(this._dockIncludeContext)
      }
      return !!field.dock.include()
    }
    return !!field.dock.include
  }

  /**
   * @param {JQuery} railBody
   * @private
   */
  _syncDockRootIncludes(railBody)
  {
    if (!railBody?.length || !this._dockIncludeContext) {
      return
    }

    let fields = this.getDockRootFields()
    for (let field of fields) {
      let shouldShow = this._evaluateDockInclude(field)
      if (shouldShow && !field.dockElement?.length) {
        let node = this.createDockElement(field.key)
        if (node?.length) {
          this._insertDockRootNode(railBody, node, field, fields)
        }
        continue
      }
      if (!shouldShow && field.dockElement?.length) {
        field.dockElement.remove()
        field.dockElement = null
        field._dockShowsSlideOut = false
      }
    }
  }

  /**
   * @param {JQuery} railBody
   * @param {JQuery} node
   * @param {ConfigurationField} field
   * @param {ConfigurationField[]} fields
   * @private
   */
  _insertDockRootNode(railBody, node, field, fields)
  {
    let order = field.dockOrder ?? 100
    let insertBefore = null
    for (let other of fields) {
      if ((other.dockOrder ?? 100) <= order || other === field || !other.dockElement?.length) {
        continue
      }
      insertBefore = other.dockElement[0]
      break
    }
    if (insertBefore) {
      $(insertBefore).before(node)
      return
    }
    railBody.append(node)
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _refreshDockRootSlot(field)
  {
    let oldNode = field.dockElement
    if (!oldNode?.length) {
      return
    }
    let newNode = this.createDockElement(field.key)
    if (!newNode?.length) {
      oldNode.remove()
      field.dockElement = null
      field._dockShowsSlideOut = false
      return
    }
    oldNode.replaceWith(newNode)
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  updateDockInterface(railBody)
  {
    railBody = railBody ?? $('.bv-dock .bv-dock-rail-body').first()
    if (railBody?.length) {
      this._syncDockRootIncludes(railBody)
    }
    for (let field of this.getDockRootFields()) {
      let showsSlideOut = this._computeDockShowsSlideOut(field)
      if (field.dockElement && field._dockShowsSlideOut !== showsSlideOut) {
        this._refreshDockRootSlot(field)
      }
      field._dockShowsSlideOut = showsSlideOut
    }
    for (let key in this._config) {
      let field = this._config[key]
      if (!field.dock || !field.dockElement) {
        continue
      }
      this._updateDockFieldButton(field)
    }
    return this
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _updateDockFieldButton(field)
  {
    if (!field.dockElement) {
      return
    }
    let button = field.dockElement.hasClass('bv-dock-slot') ? field.dockElement.find('> .bv-dock-btn').first() : field.dockElement
    if (!button.length) {
      return
    }
    button.removeClass('bv-dock-btn-active')
    let stateClass = this._resolveDockStateClass(field)
    if (stateClass) {
      button.addClass(stateClass)
    }
    let tooltip = this._resolveDockTooltip(field)
    if (tooltip) {
      button.attr('title', tooltip)
    }
    let icon = typeof field.dock.icon === 'function' ? field.dock.icon(field.value, field) : field.dock.icon
    if (typeof icon === 'string') {
      button.empty().append(BrazenViewLayer.getDockIcon(icon))
    }
  }

  /**
   * @param {string} configKey
   * @returns {function(*): boolean}
   */
  generateValidationCallback(configKey)
  {
    let validationCallback
    switch (this.getField(configKey).type) {
      case CONFIG_TYPE_FLAG:
      case CONFIG_TYPE_RADIOS_GROUP:
      case CONFIG_TYPE_SELECT:
        validationCallback = (value) => value
        break
      case CONFIG_TYPE_CHECKBOXES_GROUP:
        validationCallback = (valueKeys) => valueKeys.length
        break
      case CONFIG_TYPE_NUMBER:
        validationCallback = (value) => value > 0
        break
      case CONFIG_TYPE_RANGE:
        validationCallback = (range) => range.minimum > 0 || range.maximum > 0
        break
      case CONFIG_TYPE_RULESET:
        validationCallback = (rules) => rules.length
        break
      case CONFIG_TYPE_TEXT:
        validationCallback = (value) => value.length
        break
      default:
        throw new Error('Associated config type requires explicit validation callback definition.')
    }
    return validationCallback
  }

  /**
   * @param {string} name
   * @return {ConfigurationField|null}
   */
  getField(name)
  {
    return this._config[this._formatFieldKey(name)]
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  getFieldOrFail(name)
  {
    let field = this._config[this._formatFieldKey(name)]
    if (field) {
      return field
    }
    throw new Error('Field named "' + name + '" could not be found')
  }

  /**
   * @param {string} name
   * @returns {*}
   */
  getValue(name)
  {
    return this.getFieldOrFail(name).value
  }

  /**
   * @param {string} name
   * @return {boolean}
   */
  hasField(name)
  {
    return this.getField(name) !== undefined
  }

  /**
   * @param {string} legacyScriptPrefix
   * @return {BrazenConfigurationManager}
   */
  setLegacyScriptPrefix(legacyScriptPrefix)
  {
    this._legacyScriptPrefix = legacyScriptPrefix
    return this
  }

  /**
   * @param {*} stored
   * @return {Array}
   * @private
   */
  _coerceBookmarkArray(stored)
  {
    return Utilities.coerceBookmarkArray(stored)
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _finalizeBookmarkMigrationState(field)
  {
    if (field.type !== CONFIG_TYPE_BOOKMARKS || field._storageMigrated) {
      return
    }
    field._loadCanonicalBookmarks()
    if (field.value.length) {
      field._storageMigrated = true
      return
    }
    if (!field._hasLegacyBookmarkStorage?.()) {
      let storageKey = field._bookmarksStorageKey()
      let stored = field._bookmarksDriver().read(storageKey, null)
      if (stored === null || stored === undefined) {
        field._storageMigrated = true
      }
    }
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  reloadBookmarkFields()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_BOOKMARKS) {
        continue
      }
      field.reload()
      if (field.widget) {
        field.widget.render(field.value)
        field.widget.checkPageMatch?.()
      }
    }
    return this
  }

  /**
   * @param {*} driver
   * @param {string} storeKey
   * @private
   */
  _safeRemoveDriverKey(driver, storeKey)
  {
    try {
      driver.remove(storeKey)
    } catch (error) {
      console.log('Failed to remove storage key:', storeKey, error)
    }
  }

  /**
   * @param {*} store
   * @param {Object} data
   * @private
   */
  _saveDriverStoreSilent(store, data)
  {
    this._suppressStoreNotifications = true
    try {
      store.save(data)
    } finally {
      this._suppressStoreNotifications = false
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  _yieldToBrowser()
  {
    return new Promise((resolve) => {
      window.setTimeout(resolve, 0)
    })
  }

  /**
   * @param {string|null} message
   * @private
   */
  _setMigrationStatus(message)
  {
    Utilities.callEventHandler(this._onMigrationStatusChange, [message])
  }

  /**
   * @param {Function} eventHandler
   * @return {BrazenConfigurationManager}
   */
  onMigrationStatusChange(eventHandler)
  {
    this._onMigrationStatusChange = eventHandler
    return this
  }

  /**
   * @return {boolean}
   */
  needsStorageMigration()
  {
    if (this._hasLegacyPrefixStorage()) {
      return true
    }
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_LEDGER && !field._storageMigrated && (field._hasLegacyLedgerStorage?.() || field._hasLegacyLedgerEntryKeys?.())) {
        return true
      }
      if (field.type === CONFIG_TYPE_BOOKMARKS && !field._storageMigrated && field._hasLegacyBookmarkStorage?.()) {
        return true
      }
    }
    return false
  }

  /**
   * @return {Promise<void>}
   */
  async runStorageMigrationAsync()
  {
    if (this._storageMigrationComplete || this._storageMigrationRunning) {
      return
    }
    if (!this.needsStorageMigration()) {
      this._storageMigrationComplete = true
      for (let fieldKey in this._config) {
        let field = this._config[fieldKey]
        if (field.type === CONFIG_TYPE_LEDGER && !field._hasLegacyLedgerStorage?.() && !field._hasLegacyLedgerEntryKeys?.()) {
          field._storageMigrated = true
        }
        if (field.type === CONFIG_TYPE_BOOKMARKS) {
          this._finalizeBookmarkMigrationState(field)
        }
      }
      return
    }

    this._storageMigrationRunning = true
    this._suppressStoreNotifications = true
    this._setMigrationStatus('Migrating storage…')

    try {
      this._recoverPrefixSettingsFromLegacy()

      for (let fieldKey in this._config) {
        let field = this._config[fieldKey]
        if (field.type === CONFIG_TYPE_BOOKMARKS && !field._storageMigrated && field._hasLegacyBookmarkStorage?.()) {
          this._setMigrationStatus('Migrating bookmarks…')
          await this._yieldToBrowser()
          field._migrateBookmarksToCanonical()
        }
      }

      let ledgerMigrated = true
      for (let fieldKey in this._config) {
        let field = this._config[fieldKey]
        if (field.type === CONFIG_TYPE_LEDGER && !field._storageMigrated && (field._hasLegacyLedgerStorage?.() || field._hasLegacyLedgerEntryKeys?.())) {
          await field._migrateLedgerToCanonical((message) => this._setMigrationStatus(message))
          if (!field._storageMigrated) {
            ledgerMigrated = false
          }
        } else if (field.type === CONFIG_TYPE_LEDGER && !field._storageMigrated && !field._hasLegacyLedgerEntryKeys?.()) {
          field._storageMigrated = true
        }
      }

      if (ledgerMigrated) {
        this._migrateLegacyScriptPrefixStores(true)
        this._storageMigrationComplete = true
        for (let fieldKey in this._config) {
          let field = this._config[fieldKey]
          if (field.type === CONFIG_TYPE_LEDGER) {
            field._storageMigrated = true
          }
          if (field.type === CONFIG_TYPE_BOOKMARKS) {
            this._finalizeBookmarkMigrationState(field)
          }
        }
        this._syncDriverStores()
      }
    } catch (error) {
      console.log('Storage migration failed:', error)
    } finally {
      this.reloadBookmarkFields()
      this._suppressStoreNotifications = false
      this._storageMigrationRunning = false
      this._setMigrationStatus(null)
    }
  }

  /**
   * @param {*} legacy
   * @param {*} current
   * @return {Object}
   * @private
   */
  _mergePrefixSettings(legacy, current)
  {
    let legacyObj = legacy && typeof legacy === 'object' && !Array.isArray(legacy) ? legacy : {}
    let currentObj = current && typeof current === 'object' && !Array.isArray(current) ? current : {}
    if (!Object.keys(legacyObj).length) {
      return {...currentObj}
    }
    if (!Object.keys(currentObj).length) {
      return {...legacyObj}
    }
    return {...legacyObj, ...currentObj}
  }

  /**
   * @param {*} settings
   * @return {boolean}
   * @private
   */
  _settingsHasUserData(settings)
  {
    return !!(settings && typeof settings === 'object' && !Array.isArray(settings) && Object.keys(settings).length)
  }

  /**
   * @return {boolean}
   * @private
   */
  _hasLegacyPrefixStorage()
  {
    let legacyPrefix = this._legacyScriptPrefix
    if (!legacyPrefix || legacyPrefix === this._scriptPrefix) {
      return false
    }
    for (let driverKey of [STORAGE_DRIVER_LOCAL, STORAGE_DRIVER_GM]) {
      let driver = this._drivers[driverKey]
      if (!driver) {
        continue
      }
      if (driver.read(legacyPrefix + 'settings', null) !== null) {
        return true
      }
      if (driver.read(legacyPrefix + 'settings-id', null) !== null) {
        return true
      }
    }
    return false
  }

  /**
   * @param {StorageDriver} driver
   * @return {boolean}
   * @private
   */
  _migrateSettingsKeyFormatForDriver(driver)
  {
    if (!driver) {
      return false
    }

    let settingsKey = this._scriptPrefix + 'settings'
    let settings = driver.read(settingsKey, null)
    if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
      return false
    }

    let registeredKeys = new Set(Object.keys(this._config))
    let changed = false

    for (let storedKey of Object.keys(settings)) {
      let normalizedKey = Utilities.toKebabCase(storedKey)
      if (normalizedKey === storedKey || !registeredKeys.has(normalizedKey) || settings[normalizedKey] !== undefined) {
        continue
      }
      settings[normalizedKey] = settings[storedKey]
      delete settings[storedKey]
      changed = true
    }

    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.persist === false) {
        continue
      }
      if (settings[field.key] !== undefined || !field.title || field.title === field.key || settings[field.title] === undefined) {
        continue
      }
      settings[field.key] = settings[field.title]
      delete settings[field.title]
      changed = true
    }

    if (changed) {
      driver.write(settingsKey, settings)
    }
    return changed
  }

  /**
   * @private
   */
  _migrateSettingsKeyFormat()
  {
    for (let driverKey of this._getPersistedStoreDrivers()) {
      this._migrateSettingsKeyFormatForDriver(this._drivers[driverKey])
    }
  }

  /**
   * Merge legacy prefix settings into the canonical prefix without deleting legacy copies.
   * @private
   */
  _recoverPrefixSettingsFromLegacy()
  {
    this._migrateLegacyScriptPrefixStores(false)
    this._migrateSettingsKeyFormat()
  }

  /**
   * Aggregate settings use per-driver canonical blobs (`<prefix>settings`). Each driver is
   * independent — legacy prefix data is renamed/merged only on the driver where it exists.
   * @param {boolean} [deleteLegacy]
   * @private
   */
  _migrateLegacyScriptPrefixStores(deleteLegacy = false)
  {
    let legacyPrefix = this._legacyScriptPrefix
    if (!legacyPrefix || legacyPrefix === this._scriptPrefix) {
      return
    }

    let localDriver = this._drivers[STORAGE_DRIVER_LOCAL]
    let gmDriver = this._drivers[STORAGE_DRIVER_GM]
    let newSettingsKey = this._scriptPrefix + 'settings'
    let legacySettingsKey = legacyPrefix + 'settings'
    let legacyIdKey = legacyPrefix + 'settings-id'
    let newIdKey = this._scriptPrefix + 'settings-id'

    for (let driver of [localDriver, gmDriver]) {
      let legacySettings = driver.read(legacySettingsKey, null)
      if (legacySettings !== null) {
        let current = driver.read(newSettingsKey, null)
        driver.write(newSettingsKey, this._mergePrefixSettings(legacySettings, current))
      }

      let legacyId = driver.read(legacyIdKey, null)
      if (legacyId !== null && legacyId !== undefined) {
        let currentId = driver.read(newIdKey, null)
        if (currentId === null || currentId === undefined) {
          driver.write(newIdKey, legacyId)
        }
      }

      if (deleteLegacy) {
        if (legacySettings !== null) {
          let merged = driver.read(newSettingsKey, null)
          if (this._settingsHasUserData(merged)) {
            this._safeRemoveDriverKey(driver, legacySettingsKey)
          }
        }
        if (legacyId !== null && legacyId !== undefined) {
          let mergedId = driver.read(newIdKey, null)
          if (mergedId !== null && mergedId !== undefined) {
            this._safeRemoveDriverKey(driver, legacyIdKey)
          }
        }
      }
    }
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  initialize()
  {
    this._recoverPrefixSettingsFromLegacy()
    for (let driverKey of this._getPersistedStoreDrivers()) {
      this._ensureDriverStore(driverKey)
    }
    if (!this._driverStores[STORAGE_DRIVER_LOCAL]) {
      this._ensureDriverStore(STORAGE_DRIVER_LOCAL)
    }
    this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL] = this._driverStoreIds.local.get().id

    this._syncDriverStores()

    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_BOOKMARKS) {
        field.reload()
      }
    }

    $(document).on('visibilitychange', () => {
      if (document.hidden) {
        return
      }
      let localId = this._driverStoreIds.local.get().id
      if (this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL] !== localId) {
        this._syncDriverStores(STORAGE_DRIVER_LOCAL)
        this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL] = localId
        Utilities.callEventHandler(this._onExternalConfigurationChange, [this])
      }
    })
    return this
  }

  /**
   * @param {ExternalConfigurationChangeCallback} eventHandler
   * @return {BrazenConfigurationManager}
   */
  onExternalConfigurationChange(eventHandler)
  {
    this._onExternalConfigurationChange = eventHandler
    return this
  }

  /**
   * @param {Response} response
   */
  async restore(response)
  {
    try {
      let backupConfig = await response.json()
      if (backupConfig.version === CONFIG_BACKUP_VERSION && backupConfig.drivers) {
        this._restoreVersionedBackup(backupConfig)
      } else {
        this._restoreLegacyBackup(backupConfig)
      }

      alert('Brazen script - Backup restored!')
    } catch {
      alert('Brazen script - The supplied backup file seems to have been corrupted!')
    }
  }

  revertChanges()
  {
    this._syncDriverStores()
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  save()
  {
    this.update()
    for (let driverKey of this._getUsedDrivers()) {
      if (this._driverStores[driverKey]) {
        this._driverStores[driverKey].save(this._toStoreObject(driverKey))
        this._updateDriverStoreId(driverKey)
      }
    }

    return this
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  update()
  {
    let field
    for (let fieldName in this._config) {
      field = this._config[fieldName]
      if (field.element) {
        field.setFromUserInterface()
      }
    }
    return this
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  updateInterface()
  {
    let field
    for (let fieldName in this._config) {
      field = this._config[fieldName]
      if (field.element) {
        field.updateUserInterface()
      }
    }
    if (this._dockActive) {
      this.updateDockInterface()
    }
    return this
  }
}