Brazen Framework - Configuration Manager

Configuration management for the Brazen user scripts framework

Hindi dapat direktang i-install ang script na ito. Ito ay isang library para sa iba pang mga script na isasama sa meta directive. // @require https://update.greasyfork.org/scripts/418665/1876579/Brazen%20Framework%20-%20Configuration%20Manager.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!)

Advertisement:

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!)

Advertisement:

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

const CONFIG_BACKUP_VERSION = 3
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'

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 {Map<string, {value: *, optimized: *}>}
   * @private
   */
  _settingCache = new Map()

  /**
   * @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 {Function|null}
   * @private
   */
  _onConfigurationChange = null

  /**
   * @param {string} scriptPrefix
   * @param {BrazenViewLayer} uiGenerator
   * @param {SearchEnhancerTagSelectorGeneratorCallback} tagSelectorGenerator
   */
  constructor(scriptPrefix, uiGenerator, tagSelectorGenerator)
  {
    this._scriptPrefix = scriptPrefix
    this._tagSelectorGenerator = tagSelectorGenerator
    this._uiGen = uiGenerator
    this._repos = new BrazenStorageRepositories(scriptPrefix, (source) => {
      this.notifyConfigurationChange(source, true)
    })
    this._scriptSetupHandler = null
    this._storageReady = false
    this._idbBlocked = false
    this._syncedRevisionId = null
    this._workingSet = null
  }

  // -------------------------------------------------------------------------
  // 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
      this._cacheSetting(fieldKey, value, this._computeFieldOptimized(fieldKey, value))
    } else {
      field = {
        key: fieldKey,
        element: null,
        helpText: helpText,
        title: name,
        type: type,
        value: value,
        createElement: null,
        setFromUserInterface: null,
        updateUserInterface: null,
      }
      this._config[fieldKey] = field
    }
    this._lastFieldKey = fieldKey
    this._cacheSetting(fieldKey, value, this._computeFieldOptimized(fieldKey, value))
    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
      }
      if (field.key) {
        this._cacheSetting(field.key, value, this._computeFieldOptimized(field.key, 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.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
    }
  }

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

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @param {*} optimized
   * @private
   */
  _cacheSetting(fieldKey, value, optimized)
  {
    this._settingCache.set(fieldKey, {value, optimized})
  }

  /**
   * @param {string} fieldKey
   * @return {{value: *, optimized: *}|undefined}
   * @private
   */
  _getCachedSetting(fieldKey)
  {
    return this._settingCache.get(fieldKey)
  }

  /**
   * @param {string} source
   * @param {boolean} local
   * @return {BrazenConfigurationManager}
   */
  notifyConfigurationChange(source = 'all', local = true)
  {
    Utilities.callEventHandler(this._onConfigurationChange, [{
      manager: this,
      source,
      local,
    }])
    return this
  }

  /**
   * @param {string} source
   * @param {boolean} local
   * @return {void}
   * @private
   */
  _notifyConfigurationChange(source = 'all', local = true)
  {
    this.notifyConfigurationChange(source, local)
  }

  /**
   * @param {ConfigurationField} field
   * @return {ConfigurationField}
   * @private
   */
  _overlayFieldFromCache(field)
  {
    if (!field || !this.canPersist()) {
      return field
    }
    let cached = this._getCachedSetting(field.key)
    if (cached) {
      field.value = cached.value
      field.optimized = cached.optimized
    }
    return field
  }

  /**
   * @param {ConfigurationField} field
   * @return {*}
   * @private
   */
  _getEffectiveFieldValue(field)
  {
    if (this.canPersist()) {
      let cached = this._getCachedSetting(field.key)
      if (cached) {
        return cached.value
      }
    }
    return field.value
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _refreshSettingCacheFromIdb()
  {
    if (!this.canPersist()) {
      return
    }
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.persist === false) {
        continue
      }
      if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
        let compiled = await this._repos.tags.getCompiledField(fieldKey)
        if (compiled) {
          this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
        }
        continue
      }
      if (field.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_TEXT || field.type === CONFIG_TYPE_FLAG ||
          field.type === CONFIG_TYPE_NUMBER || field.type === CONFIG_TYPE_RANGE || field.type === CONFIG_TYPE_SELECT ||
          field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_CHECKBOXES_GROUP || field.type === CONFIG_TYPE_COLOR) {
        let stored = await this._repos.settings.getField(fieldKey)
        if (stored) {
          this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
        }
      }
    }
  }

  /**
   * @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('&')) {
        growRuleset(iteratedRuleset, andTag.trim())
      }

      optimizedRuleset = optimizedRuleset.concat(iteratedRuleset)
    }

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

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

  /**
   * @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)
    let cm = this
    field.persist = false
    field.downloadedIds = new Set()

    field._ledgerPrimaryField = () => field.ledgerPrimaryField ?? 'ids'
    field._ledgerIsValidId = () => field.ledgerIsValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)

    field.readIds = (registry) => {
      if (!registry || typeof registry !== 'object') {
        return []
      }
      let primaryField = field._ledgerPrimaryField()
      let ids = []
      let seen = new Set()
      let addValue = (value) => {
        if (value === null || value === undefined) {
          return
        }
        let text = String(value).trim()
        if (!text.length) {
          return
        }
        if (!field._ledgerIsValidId()(text)) {
          let match = text.match(/[?&]id=(\d+)/)
          text = match ? match[1] : null
        }
        if (!text || seen.has(text)) {
          return
        }
        seen.add(text)
        ids.push(text)
      }
      if (Array.isArray(registry[primaryField])) {
        for (let value of registry[primaryField]) {
          addValue(value)
        }
      }
      return ids
    }

    field.reload = async () => {
      field.downloadedIds = new Set()
      if (!cm.canPersist()) {
        return field
      }
      try {
        let rows = await cm._repos.storage.getAll(IDB_STORE_LEDGER)
        field.downloadedIds = new Set(rows.map((row) => String(row.postId).trim()).filter(Boolean))
      } catch (error) {
        console.log('Failed to load ledger from IndexedDB:', error)
      }
      return field
    }

    field.claim = async (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
      }
      if (!cm.canPersist()) {
        return true
      }
      await cm._ensureStorageReady()
      for (let id of ids) {
        if (!isValidId(id)) {
          continue
        }
        if (await cm._repos.ledger.has(id)) {
          return false
        }
      }
      for (let id of ids) {
        if (!isValidId(id)) {
          continue
        }
        await cm._repos.ledger.claim(id)
        field.downloadedIds.add(id)
      }
      return true
    }

    field.has = async (id) => {
      if (id === null || id === undefined) {
        return false
      }
      id = String(id).trim()
      if (!cm.canPersist()) {
        return false
      }
      if (field.downloadedIds.has(id)) {
        return true
      }
      return cm._repos.ledger.has(id)
    }

    field.merge = async (registry) => {
      if (!registry || typeof registry !== 'object' || !cm.canPersist()) {
        return
      }
      let ids = field.readIds(registry)
      if (!ids.length) {
        return
      }
      let rows = ids.map((postId) => ({postId, claimedAt: Date.now()}))
      await cm._repos.ledger.mergeRows(rows)
      await field.reload()
    }

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

    field.applyFromBackup = (data) => {
      void 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)
    let cm = this
    field.persist = false
    field.widget = null
    field.showAddButton = true
    field._bookmarkRows = []

    field._normalizeBookmarks = () => field.normalizeBookmarks ?? ((bookmarks) => bookmarks)

    field.reload = async () => {
      field._bookmarkRows = []
      if (!cm.canPersist()) {
        return field
      }
      try {
        field._bookmarkRows = await cm._repos.bookmarks.listAll()
      } catch (error) {
        console.log('Failed to load bookmarks:', error)
      }
      return field
    }

    field.serializeForBackup = async () => {
      await field.reload()
      return field._bookmarkRows.length ? field._bookmarkRows : null
    }

    field.applyFromBackup = async (data) => {
      if (!Array.isArray(data) || !cm.canPersist()) {
        return
      }
      let rows = field._normalizeBookmarks()(data)
      await cm._repos.bookmarks.replaceAll(rows)
      await field.reload()
      field.widget?.render(field._bookmarkRows)
    }

    field.createElement = () => {
      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
      void field.reload().then(() => {
        field.widget.render(field._bookmarkRows)
        field.widget.checkPageMatch?.()
      })
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {
      if (!field.widget) {
        return
      }
      void field.reload().then(() => {
        field.widget.render(field._bookmarkRows)
        field.widget.checkPageMatch?.()
      })
    }

    return field
  }


  /**
   * @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 $()
    }
    if (this.canPersist()) {
      void this._syncFieldFromIdb(this._formatFieldKey(name)).then(() => {
        this._overlayFieldFromCache(field)
        if (field.element) {
          field.updateUserInterface()
        }
      })
    }
    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 value = this._getEffectiveFieldValue(field)
    let stateClass = this._resolveDockStateClass(field, value)
    let tooltip = this._resolveDockTooltip(field, value)
    let icon = typeof field.dock.icon === 'function' ? field.dock.icon(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, value = null)
  {
    value = value ?? this._getEffectiveFieldValue(field)
    if (field.dock.getState) {
      return field.dock.getState(value, field) ?? ''
    }
    if (field.dock.isActive !== undefined) {
      let active = typeof field.dock.isActive === 'function' ? field.dock.isActive(value, field) : field.dock.isActive
      return active ? 'bv-dock-btn-active' : ''
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      return value ? 'bv-dock-btn-active' : ''
    }
    return ''
  }

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

  /**
   * Release focus from slide-out child buttons so the slot can collapse (CSS uses :focus-within).
   * @private
   */
  _blurDockSlideOutFocus()
  {
    let active = document.activeElement
    if (active instanceof HTMLElement && active.closest('.bv-dock-slot')) {
      active.blur()
    }
  }

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

  /**
   * @param {ConfigurationField} field
   * @private
   */
  async _toggleDockFlagFromIdb(field)
  {
    let current = await this.readSetting(field.key)
    if (current === null || current === undefined) {
      current = !!field.value
    }
    await this.writeSetting(field.key, !current)
    Utilities.callEventHandler(this._onDockToggle, [field])
    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)
  {
    if (!this._onDockToggle) {
      this._onDockToggle = callback
    } else {
      let existing = this._onDockToggle
      this._onDockToggle = (field) => {
        Utilities.callEventHandler(existing, [field])
        Utilities.callEventHandler(callback, [field])
      }
    }
    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
    }
    this._overlayFieldFromCache(field)
    let value = this._getEffectiveFieldValue(field)
    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, value)
    if (stateClass) {
      button.addClass(stateClass)
    }
    let tooltip = this._resolveDockTooltip(field, value)
    if (tooltip) {
      button.attr('title', tooltip)
    }
    let icon = typeof field.dock.icon === 'function' ? field.dock.icon(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)
  {
    let field = this._config[this._formatFieldKey(name)]
    if (field) {
      this._overlayFieldFromCache(field)
    }
    return field
  }

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

  /**
   * @param {string} name
   * @returns {*}
   */
  getValue(name)
  {
    let fieldKey = this._formatFieldKey(name)
    if (this.canPersist()) {
      let cached = this._getCachedSetting(fieldKey)
      if (cached) {
        return cached.value
      }
    }
    return this.getFieldOrFail(name).value
  }

  /**
   * @param {string} name
   * @returns {*}
   */
  getOptimized(name)
  {
    let fieldKey = this._formatFieldKey(name)
    if (this.canPersist()) {
      let cached = this._getCachedSetting(fieldKey)
      if (cached) {
        return cached.optimized
      }
    }
    let field = this.getFieldOrFail(name)
    return field.optimized ?? this._computeFieldOptimized(fieldKey, field.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
  }

  /**
   * @return {Promise<BrazenConfigurationManager>}
   */
  async reloadBookmarkFields()
  {
    let tasks = []
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_BOOKMARKS) {
        continue
      }
      tasks.push(field.reload().then(() => {
        if (field.widget) {
          field.widget.render(field._bookmarkRows)
          field.widget.checkPageMatch?.()
        }
      }))
    }
    await Promise.all(tasks)
    return this
  }

  /**
   * @param {Function} handler
   * @return {BrazenConfigurationManager}
   */
  setScriptSetup(handler)
  {
    this._scriptSetupHandler = handler
    return this
  }

  /**
   * @return {Promise<BrazenConfigurationManager>}
   */
  async initialize()
  {
    if (!this._repos.storage.available) {
      this._idbBlocked = true
      console.warn('[BrazenCM] IndexedDB unavailable — persistence features disabled')
      return this
    }

    try {
      await this._repos.storage.open()
      let existingMeta = await this._repos.meta.get()
      if (existingMeta?.setupInProgress) {
        await this._repos.meta.waitForSetupComplete()
      }
      let healthy = await this._repos.storage.isHealthy()
      if (!healthy) {
        await this._repos.storage.deleteDatabase()
        await this._repos.storage.open()
        await this._runSetupPhase()
      }
      await this._runNormalPhase()
    } catch (error) {
      console.log('[BrazenCM] initialize failed:', error)
      this._idbBlocked = true
    }

    return this
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _runSetupPhase()
  {
    await this._repos.meta.beginSetup()
    await this._repos.storage.put(IDB_STORE_META, await this._repos.storage.createDefaultMeta())
    await this._repos.storage.put(IDB_STORE_SETTINGS, await this._repos.storage.createEmptySettingsDocument())
    await this._repos.storage.put(IDB_STORE_APIS, await this._repos.storage.createEmptyApisDocument())
    await this._repos.storage.put(IDB_STORE_TAG_TYPES, await this._repos.storage.createEmptyTagTypesDocument())
    await this._repos.storage.put(IDB_STORE_TAG_RULE_SETS, await this._repos.storage.createEmptyTagRuleSetsDocument())

    let sources = []
    if (this._legacyDataExists()) {
      let importer = new BrazenLegacyImporter(this._repos, this)
      sources = await importer.run()
    }

    if (this._scriptSetupHandler) {
      await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
    }

    await this._importTagDomainFromLegacySettings()
    await this._recompileAllTagRuleSets()
    await this._repos.meta.completeSetup(sources)
    this._storageReady = true
    await this._syncFieldsFromIdb()
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _runNormalPhase()
  {
    await this._repos.meta.waitForSetupComplete()
    this._storageReady = true
    let meta = await this._repos.meta.get()
    this._syncedRevisionId = meta?.revisionId ?? null
    await this._syncFieldsFromIdb()

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

    $(document).off('visibilitychange.brazenIdb').on('visibilitychange.brazenIdb', async () => {
      if (document.hidden) {
        return
      }
      let revisionId = await this._repos.meta.get().then((m) => m?.revisionId)
      if (this._syncedRevisionId !== revisionId) {
        this._syncedRevisionId = revisionId
        this._workingSet = null
        await this._syncFieldsFromIdb()
        for (let fieldKey in this._config) {
          let field = this._config[fieldKey]
          if (field.type === CONFIG_TYPE_BOOKMARKS) {
            await field.reload()
          }
        }
        this.notifyConfigurationChange('all', false)
      }
    })
  }

  /**
   * @return {boolean}
   * @private
   */
  _legacyDataExists()
  {
    return legacyStorageHasData(this._scriptPrefix, this._legacyScriptPrefix)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _syncFieldsFromIdb()
  {
    await this._refreshSettingCacheFromIdb()
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<void>}
   * @private
   */
  async _syncFieldFromIdb(fieldKey)
  {
    let field = this._config[fieldKey]
    if (!field) {
      return
    }
    if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
      let compiled = await this._repos.tags.getCompiledField(fieldKey)
      if (compiled) {
        this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
        this._overlayFieldFromCache(field)
      }
      return
    }
    if (field.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_TEXT || field.type === CONFIG_TYPE_FLAG ||
        field.type === CONFIG_TYPE_NUMBER || field.type === CONFIG_TYPE_RANGE || field.type === CONFIG_TYPE_SELECT ||
        field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_CHECKBOXES_GROUP || field.type === CONFIG_TYPE_COLOR) {
      let stored = await this._repos.settings.getField(fieldKey)
      if (stored) {
        this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
      }
    }
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @return {*}
   * @private
   */
  _computeFieldOptimized(fieldKey, value)
  {
    let field = this._config[fieldKey]
    if (!field) {
      return null
    }
    return Utilities.callEventHandler(field.onOptimize, [value], value)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _importTagDomainFromLegacySettings()
  {
    let aggregate = readLegacySettingsAggregate(this._scriptPrefix)
    if (!aggregate || typeof aggregate !== 'object') {
      return
    }
    for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
      if (aggregate[fieldKey] === undefined) {
        continue
      }
      await this._importTagFieldFromValue(fieldKey, aggregate[fieldKey])
    }
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @return {Promise<void>}
   * @private
   */
  async _importTagFieldFromValue(fieldKey, value)
  {
    let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
    if (!group) {
      return
    }
    let lines = []
    if (Array.isArray(value)) {
      if (value.length && typeof value[0] === 'object' && value[0]?.subject) {
        for (let entry of value) {
          if (entry?.subject) {
            await this._repos.tags.registerTag(entry.subject)
            lines.push(entry.subject + ' → ' + (entry.replacement ?? ''))
          }
        }
      } else {
        lines = value.map(String)
      }
    }
    let expanded = []
    for (let line of lines) {
      if (line.includes('|')) {
        expanded.push(...expandOrRuleLine(line))
      } else {
        expanded.push(line)
      }
    }
    let sortOrder = 0
    for (let rawLine of expanded) {
      let tagName = String(rawLine).split('//')[0].split('&')[0].trim()
      if (tagName) {
        await this._repos.tags.registerTag(tagName)
      }
      await this._repos.tagRules.addRule(group, {
        sortOrder: sortOrder++,
        rawLine,
        mode: fieldKey.includes('tag') && this._config[fieldKey]?.useSelectors ? 'selectors' : 'tags',
      })
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _recompileAllTagRuleSets()
  {
    for (let fieldKey of Object.keys(TAG_RULESET_PROPERTY_BY_FIELD)) {
      if (!this._config[fieldKey]) {
        continue
      }
      let field = this._config[fieldKey]
      let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
      if (!group) {
        continue
      }
      await this._repos.tags.compileGroup(group, (lines) => this._optimizeTagRuleset(lines, !!field.useSelectors), !!field.useSelectors)
    }
    await this._repos.tags.compilePendingGroups(
        (lines, useSelectors) => this._optimizeTagRuleset(lines, useSelectors),
        {},
    )
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _ensureStorageReady()
  {
    if (!this._storageReady) {
      await this.initialize()
    }
  }

  /**
   * @return {boolean}
   */
  isIdbBlocked()
  {
    return this._idbBlocked
  }

  /**
   * @return {boolean}
   */
  isStorageReady()
  {
    return this._storageReady && !this._idbBlocked
  }

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

  /**
   * @return {boolean}
   */
  canPersist()
  {
    return this._storageReady && !this._idbBlocked
  }

  /**
   * Read a settings field from IndexedDB (source of truth via `_settingCache`).
   * @param {string} fieldKey
   * @return {Promise<*>}
   */
  async readSetting(fieldKey)
  {
    if (!this.canPersist()) {
      return this.getField(fieldKey)?.value ?? null
    }
    await this._ensureStorageReady()
    let stored = await this._repos.settings.getField(fieldKey)
    if (!stored) {
      return this._getCachedSetting(fieldKey)?.value ?? this.getField(fieldKey)?.value ?? null
    }
    this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
    return stored.value
  }

  /**
   * Write a settings field directly to IndexedDB (headless / dock-only fields). `save()` only persists mounted panel fields.
   * @param {string} fieldKey
   * @param {*} value
   * @return {Promise<BrazenConfigurationManager>}
   */
  async writeSetting(fieldKey, value)
  {
    if (!this.canPersist()) {
      return this
    }
    await this._ensureStorageReady()
    let field = this.getFieldOrFail(fieldKey)
    let optimized = Utilities.callEventHandler(field.onOptimize, [value], value)
    await this._repos.settings.putField(fieldKey, value, optimized)
    this._cacheSetting(fieldKey, value, optimized)
    this._overlayFieldFromCache(field)
    await this._repos.meta.bumpRevision()
    this._syncedRevisionId = await this._repos.storage.getRevisionId()
    this.notifyConfigurationChange('settings', true)
    return this
  }

  /**
   * @return {BrazenStorageRepositories}
   */
  getRepos()
  {
    return this._repos
  }

  /**
   * @return {Promise<void>}
   */
  async ensureTagRuleSetsCompiled()
  {
    if (!this.canPersist()) {
      return
    }
    await this._compilePendingTagRuleGroups()
  }

  /**
   * @param {string[]} groups
   * @return {Promise<void>}
   */
  async scheduleTagRuleCompile(groups)
  {
    if (!this.canPersist() || !groups?.length) {
      return
    }
    await this._repos.meta.markCompilePending(groups)
    if (this._tagCompileQueued) {
      return
    }
    this._tagCompileQueued = true
    queueMicrotask(async () => {
      this._tagCompileQueued = false
      try {
        await this._compilePendingTagRuleGroups()
      } catch (error) {
        console.log('[BrazenCM] tag rule compile failed:', error)
      }
    })
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<void>}
   * @private
   */
  async _commitTagFieldChange()
  {
    await this._repos.meta.bumpRevision()
    this._syncedRevisionId = await this._repos.storage.getRevisionId()
    this.notifyConfigurationChange('tags', true)
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName
   * @return {Promise<boolean>}
   */
  async toggleTagRule(fieldKey, tagName)
  {
    let field = this._config[fieldKey]
    let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
    if (!field || !group) {
      return false
    }
    if (!this.canPersist()) {
      field.toggleRule(tagName, tagName)
      return true
    }

    await this._syncFieldFromIdb(fieldKey)
    let normalized = String(tagName)
    if (field.hasRule?.(normalized)) {
      await this._repos.tagRules.removeSoleTagRules(group, normalized)
    } else {
      await this._repos.tags.registerTag(normalized)
      let rules = await this._repos.tagRules.listByGroup(group)
      let sortOrder = rules.length ? Math.max(...rules.map((rule) => rule.sortOrder ?? 0)) + 1 : 0
      await this._repos.tagRules.addRule(group, {
        sortOrder,
        rawLine: normalized,
        mode: field.useSelectors ? 'selectors' : 'tags',
      })
    }
    await this.scheduleTagRuleCompile([group])
    await this._compilePendingTagRuleGroups()
    await this._syncFieldFromIdb(fieldKey)
    await this._commitTagFieldChange()
    return true
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<boolean>}
   */
  async clearTagRules(fieldKey)
  {
    let field = this._config[fieldKey]
    let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
    if (!field || !group) {
      return false
    }
    if (!this.canPersist()) {
      field.clearRules?.()
      return true
    }
    await this._repos.tagRules.removeAllInGroup(group)
    await this.scheduleTagRuleCompile([group])
    await this._compilePendingTagRuleGroups()
    await this._syncFieldFromIdb(fieldKey)
    await this._commitTagFieldChange()
    return true
  }

  /**
   * Persist tag-domain rules programmatically (sidebar / headless writes). Does not read the panel textarea.
   * @param {string} fieldKey
   * @param {string[]} rawLines
   * @return {Promise<BrazenConfigurationManager>}
   */
  async writeTagRuleLines(fieldKey, rawLines)
  {
    if (!this.canPersist()) {
      let field = this.getFieldOrFail(fieldKey)
      field.setRules(Array.isArray(rawLines) ? rawLines : [])
      return this
    }
    let field = this.getFieldOrFail(fieldKey)
    let lines = Array.isArray(rawLines) ? [...rawLines] : []
    if (field.sortRules) {
      lines.sort((left, right) => String(left).localeCompare(String(right), undefined, {sensitivity: 'base'}))
    }
    lines = Utilities.callEventHandler(field.onTranslateFromUI, [lines], lines)
    field.value = this._deduplicateRulesetRules(lines)
    await this._persistTagFieldFromUI(fieldKey, field)
    await this._commitTagFieldChange()
    return this
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _compilePendingTagRuleGroups()
  {
    if (!this.canPersist()) {
      return
    }
    let useSelectorsByGroup = {}
    for (let [fieldKey, group] of Object.entries(TAG_RULE_GROUP_BY_FIELD)) {
      useSelectorsByGroup[group] = !!this._config[fieldKey]?.useSelectors
    }
    await this._repos.tags.compilePendingGroups(
        (lines, useSelectors) => this._optimizeTagRuleset(lines, useSelectors),
        useSelectorsByGroup,
    )
    for (let fieldKey of Object.keys(TAG_RULE_GROUP_BY_FIELD)) {
      if (!this._config[fieldKey]) {
        continue
      }
      let compiled = await this._repos.tags.getCompiledField(fieldKey)
      if (compiled) {
        this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
      }
    }
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  initializeSyncCompat()
  {
    void this.initialize()
    return this
  }

  /**
   * @param {Function} eventHandler
   * @return {BrazenConfigurationManager}
   */
  onConfigurationChange(eventHandler)
  {
    if (this._onConfigurationChange && this._onConfigurationChange !== eventHandler) {
      console.warn('[BrazenCM] onConfigurationChange already registered — replacing handler')
    }
    this._onConfigurationChange = eventHandler
    return this
  }

  /**
   * @param {ExternalConfigurationChangeCallback} eventHandler
   * @return {BrazenConfigurationManager}
   * @deprecated Use Framework `onConfigurationChange` for script hooks; only Framework should register on CM.
   */
  onExternalConfigurationChange(eventHandler)
  {
    return this.onConfigurationChange(eventHandler)
  }

  /**
   * @param {Response} response
   */
  async restore(response)
  {
    try {
      await this._ensureStorageReady()
      let backupConfig
      if (response instanceof Response) {
        let buffer = await response.arrayBuffer()
        if (buffer.byteLength >= 4 && new DataView(buffer).getUint32(0, true) === 0x04034b50) {
          backupConfig = await this._parseZipBackup(buffer)
        } else {
          backupConfig = JSON.parse(new TextDecoder().decode(buffer))
        }
      } else {
        backupConfig = await new Response(response).json()
      }

      if (backupConfig.version === CONFIG_BACKUP_VERSION && (backupConfig.stores || backupConfig.meta)) {
        await this._restoreV3Backup(backupConfig)
      } else if (backupConfig.version === 2 && backupConfig.drivers) {
        await this._importV2BackupToIdb(backupConfig)
        await this._recompileAllTagRuleSets()
      } else {
        await this._importLegacyBackupToIdb(backupConfig)
        await this._recompileAllTagRuleSets()
      }

      await this._syncFieldsFromIdb()
      await this._repos.meta.bumpRevision()
      this.notifyConfigurationChange('all', true)
      alert('Brazen script - Backup restored!')
    } catch (error) {
      console.log('Restore failed:', error)
      alert('Brazen script - The supplied backup file seems to have been corrupted!')
    }
  }

  async revertChanges()
  {
    if (this._idbBlocked) {
      return this
    }
    await this._ensureStorageReady()
    this._workingSet = null
    await this._syncFieldsFromIdb()
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_BOOKMARKS) {
        await field.reload()
      }
    }
    this.notifyConfigurationChange('all', true)
    return this
  }

  refreshMountedFields()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.element) {
        this._overlayFieldFromCache(field)
        field.updateUserInterface()
      }
    }
    return this
  }

  /**
   * @return {Promise<BrazenConfigurationManager>}
   */
  async save()
  {
    if (this._idbBlocked) {
      return this
    }
    await this._ensureStorageReady()
    this.update()
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.persist === false) {
        continue
      }
      if (!field.element) {
        continue
      }
      if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
        await this._persistTagFieldFromUI(fieldKey, field)
        continue
      }
      field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value], field.value)
      await this._repos.settings.putField(fieldKey, field.value, field.optimized)
      this._cacheSetting(fieldKey, field.value, field.optimized)
    }
    await this._compilePendingTagRuleGroups()
    await this._repos.meta.bumpRevision()
    this._syncedRevisionId = await this._repos.storage.getRevisionId()
    this.notifyConfigurationChange('all', true)
    return this
  }

  /**
   * @param {string} fieldKey
   * @param {ConfigurationField} field
   * @return {Promise<void>}
   * @private
   */
  async _persistTagFieldFromUI(fieldKey, field)
  {
    let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
    if (!group) {
      return
    }
    let lines = Array.isArray(field.value) ? field.value : []
    let existing = await this._repos.tagRules.listByGroup(group)
    for (let rule of existing) {
      await this._repos.tagRules.removeRule(rule.entryId, group)
    }
    let sortOrder = 0
    for (let rawLine of lines) {
      let lineText = rawLine
      if (typeof rawLine === 'object' && rawLine !== null) {
        if (field.onFormatForUI) {
          lineText = Utilities.callEventHandler(field.onFormatForUI, [[rawLine]], [String(rawLine)])[0]
        } else {
          lineText = String(rawLine)
        }
      }
      if (String(lineText).includes('|')) {
        for (let expanded of expandOrRuleLine(String(lineText))) {
          await this._importTagRuleLine(group, expanded, sortOrder++, !!field.useSelectors)
        }
      } else {
        await this._importTagRuleLine(group, String(lineText), sortOrder++, !!field.useSelectors)
      }
    }
    await this.scheduleTagRuleCompile([group])
    await this._compilePendingTagRuleGroups()
    let compiled = await this._repos.tags.getCompiledField(fieldKey)
    if (compiled) {
      this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
    }
  }

  /**
   * @param {string} group
   * @param {string} rawLine
   * @param {number} sortOrder
   * @param {boolean} useSelectors
   * @return {Promise<void>}
   * @private
   */
  async _importTagRuleLine(group, rawLine, sortOrder, useSelectors)
  {
    let tagName = rawLine.split('//')[0].split('&')[0].trim()
    if (tagName && !tagName.includes('→')) {
      await this._repos.tags.registerTag(tagName)
    }
    await this._repos.tagRules.addRule(group, {sortOrder, rawLine, mode: useSelectors ? 'selectors' : 'tags'})
  }

  /**
   * @return {Promise<void>}
   */
  async backup()
  {
    await this._ensureStorageReady()
    let meta = await this._repos.meta.get()
    let zip = new BrazenZipWriter()
    zip.addFile('manifest.json', JSON.stringify({
      version: CONFIG_BACKUP_VERSION,
      scriptPrefix: this._scriptPrefix,
      dbName: this._repos.storage.dbName,
      revisionId: meta?.revisionId,
      exportedAt: Date.now(),
      stores: IDB_ALL_STORES,
    }, null, 2))

    zip.addFile('meta.json', JSON.stringify(meta, null, 2))
    zip.addFile('settings.json', JSON.stringify(await this._repos.settings.getDocument(), null, 2))
    zip.addFile('apis.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_APIS, 'apis'), null, 2))
    zip.addFile('tagTypes.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_TAG_TYPES, 'tagTypes'), null, 2))
    zip.addFile('tags.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_TAGS), null, 2))
    zip.addFile('tagRules.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_TAG_RULES), null, 2))
    zip.addFile('tagRuleSets.json', JSON.stringify(await this._repos.tags.getTagRuleSetsDocument(), null, 2))
    zip.addFile('bookmarks.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_BOOKMARKS), null, 2))
    zip.addFile('ledgerEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_LEDGER), null, 2))

    let link = document.createElement('a')
    link.download = this._scriptPrefix + 'backup.zip'
    link.href = URL.createObjectURL(zip.build())
    link.click()
  }

  /**
   * @param {ArrayBuffer} buffer
   * @return {Promise<{version: number, stores: object}>}
   * @private
   */
  async _parseZipBackup(buffer)
  {
    let files = BrazenZipReader.parse(buffer)
    let manifest = JSON.parse(files['manifest.json'] ?? '{}')
    return {
      version: manifest.version ?? CONFIG_BACKUP_VERSION,
      stores: {
        meta: JSON.parse(files['meta.json'] ?? 'null'),
        settings: JSON.parse(files['settings.json'] ?? 'null'),
        apis: JSON.parse(files['apis.json'] ?? 'null'),
        tagTypes: JSON.parse(files['tagTypes.json'] ?? 'null'),
        tags: JSON.parse(files['tags.json'] ?? '[]'),
        tagRules: JSON.parse(files['tagRules.json'] ?? '[]'),
        tagRuleSets: JSON.parse(files['tagRuleSets.json'] ?? 'null'),
        bookmarks: JSON.parse(files['bookmarks.json'] ?? '[]'),
        ledgerEntries: JSON.parse(files['ledgerEntries.json'] ?? '[]'),
      },
    }
  }

  /**
   * @param {Object} backupConfig
   * @return {Promise<void>}
   * @private
   */
  async _importV2BackupToIdb(backupConfig)
  {
    let drivers = backupConfig.drivers ?? {}
    let aggregate = {}
    for (let driverKey in drivers) {
      let section = drivers[driverKey]
      if (section && typeof section === 'object') {
        Object.assign(aggregate, section)
      }
    }
    await this._importFlatBackupFieldsToIdb(aggregate)
    if (backupConfig.id !== undefined) {
      let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
      meta.revisionId = String(backupConfig.id)
      await this._repos.meta.put(meta)
    }
  }

  /**
   * @param {Object} backupConfig
   * @return {Promise<void>}
   * @private
   */
  async _importLegacyBackupToIdb(backupConfig)
  {
    let id = backupConfig.id
    let blob = {...backupConfig}
    delete blob.id
    await this._importFlatBackupFieldsToIdb(blob)
    if (id !== undefined) {
      let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
      meta.revisionId = String(id)
      await this._repos.meta.put(meta)
    }
  }

  /**
   * @param {Object} blob
   * @return {Promise<void>}
   * @private
   */
  async _importFlatBackupFieldsToIdb(blob)
  {
    if (!blob || typeof blob !== 'object') {
      return
    }
    for (let fieldKey in blob) {
      let field = this._config[fieldKey]
      let value = blob[fieldKey]
      if (!field) {
        continue
      }
      if (field.type === CONFIG_TYPE_BOOKMARKS) {
        await field.applyFromBackup(value)
      } else if (field.type === CONFIG_TYPE_LEDGER) {
        await field.merge(value)
      } else if (field.persist === false) {
        field.applyFromBackup?.(value)
      } else if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
        await this._importTagFieldFromValue(fieldKey, value)
      } else {
        let optimized = this._computeFieldOptimized(fieldKey, value)
        await this._repos.settings.putField(fieldKey, value, optimized)
        this._cacheSetting(fieldKey, value, optimized)
      }
    }
  }

  /**
   * @param {{stores: object}} backupConfig
   * @return {Promise<void>}
   * @private
   */
  async _restoreV3Backup(backupConfig)
  {
    let stores = backupConfig.stores ?? backupConfig
    if (stores.meta) {
      await this._repos.storage.put(IDB_STORE_META, stores.meta)
    }
    if (stores.settings) {
      await this._repos.storage.put(IDB_STORE_SETTINGS, stores.settings)
    }
    if (stores.apis) {
      await this._repos.storage.put(IDB_STORE_APIS, stores.apis)
    }
    if (stores.tagTypes) {
      await this._repos.storage.put(IDB_STORE_TAG_TYPES, stores.tagTypes)
    }
    if (stores.tagRuleSets) {
      await this._repos.storage.put(IDB_STORE_TAG_RULE_SETS, stores.tagRuleSets)
    }
    if (stores.tags) {
      await this._repos.storage.clearStore(IDB_STORE_TAGS)
      await this._repos.storage.putMany(IDB_STORE_TAGS, stores.tags)
    }
    if (stores.tagRules) {
      await this._repos.storage.clearStore(IDB_STORE_TAG_RULES)
      await this._repos.storage.putMany(IDB_STORE_TAG_RULES, stores.tagRules)
    }
    if (stores.bookmarks) {
      await this._repos.storage.clearStore(IDB_STORE_BOOKMARKS)
      await this._repos.storage.putMany(IDB_STORE_BOOKMARKS, stores.bookmarks)
    }
    if (stores.ledgerEntries) {
      await this._repos.ledger.mergeRows(stores.ledgerEntries)
    }
    await this._repos.meta.bumpRevision()
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  update()
  {
    let field
    for (let fieldName in this._config) {
      field = this._config[fieldName]
      if (field.element) {
        field.setFromUserInterface()
        if (this.canPersist() && field.persist !== false) {
          this._cacheSetting(field.key, field.value, this._computeFieldOptimized(field.key, field.value))
        }
      }
    }
    return this
  }

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