Brazen Framework - Configuration Manager

Configuration management for the Brazen user scripts framework

Acest script nu ar trebui instalat direct. Aceasta este o bibliotecă pentru alte scripturi care este inclusă prin directiva meta a // @require https://update.greasyfork.org/scripts/418665/1877004/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.1.1
// @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)
    }, (revisionId) => {
      // Track every local revision bump immediately (including mid-write tag putTag bumps)
      // so visibilitychange never treats our own writes as foreign and wipes unsaved edits.
      this._syncedRevisionId = revisionId
    })
    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.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
    }
    /**
     * Apply a named dock-button recipe, then merge optional overrides into `field.dock`.
     * Framework / Download Manager ship templates on registration; apps only order keys
     * (and fully define custom action docks).
     *
     * @param {string} name
     * @param {object} [args]
     * @return {ConfigurationField}
     */
    field.applyDockTemplate = (name, args = {}) => {
      this._applyDockTemplate(field, name, args)
      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.setDockSlideOutNodes = (getNodes) => {
      field.dock = field.dock ?? {}
      field.dock.getSlideOutNodes = getNodes
      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 () => {
      if (!cm.canPersist()) {
        field.downloadedIds = new Set()
        return field
      }
      try {
        let rows = await cm._repos.storage.getAll(IDB_STORE_LEDGER)
        // Replace atomically — never empty the Set before load completes, or concurrent
        // compliance (hide downloaded) runs against an empty ledger and shows everything.
        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.claim(id))) {
          return false
        }
        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
  }

  /**
   * Settings field persisted via {@link readSetting}/{@link writeSetting} only — no panel UI.
   * @param {string} name
   * @param {*} [defaultValue]
   * @return {ConfigurationField}
   */
  addHeadlessSettingField(name, defaultValue = '')
  {
    let field = this._createField(CONFIG_TYPE_TEXT, name, defaultValue, null)
    field.headless = true
    field.textDefault = defaultValue ?? ''

    field.createElement = () => $()
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {}
    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
  }

  /**
   * @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 || field.dock.getSlideOutNodes) && this._shouldShowDockSlideOut(field)) {
      if (field.dock.getSlideOutNodes) {
        let customNodes = Utilities.callEventHandler(field.dock.getSlideOutNodes, [field], null)
        if (Array.isArray(customNodes)) {
          for (let node of customNodes) {
            if (node?.length) {
              childButtons.push(node)
            }
          }
        }
      }
      for (let childField of childFields) {
        let childNode = this._createDockChildElement(childField)
        if (childNode) {
          childButtons.push(childNode)
        }
      }
      if (childButtons.length) {
        if (field.dock.slideOutInsetGroup) {
          childButtons = [this._uiGen.createDockInsetGroup(childButtons)]
        }
        dockButton = this._uiGen.createDockSlideOut(dockButton, childButtons)
        if (this._isDockSlideOutPinned(field)) {
          dockButton.addClass('bv-dock-slideout-pinned')
        }
      }
    }

    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,
      disabled: this._resolveDockDisabled(field, value),
      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
  }

  /**
   * @param {ConfigurationField} field
   * @param {*} [value]
   * @return {boolean}
   * @private
   */
  _resolveDockDisabled(field, value = null)
  {
    if (field.dock.isDisabled === undefined) {
      return false
    }
    value = value ?? this._getEffectiveFieldValue(field)
    if (typeof field.dock.isDisabled === 'function') {
      if (this._dockIncludeContext) {
        return !!field.dock.isDisabled.call(this._dockIncludeContext, value, field)
      }
      return !!field.dock.isDisabled(value, field)
    }
    return !!field.dock.isDisabled
  }

  /**
   * @param {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _isDockFieldDisabled(field)
  {
    this._overlayFieldFromCache(field)
    return this._resolveDockDisabled(field, this._getEffectiveFieldValue(field))
  }

  /**
   * 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 (this._isDockFieldDisabled(field)) {
      return
    }
    if (field.dock.onClick) {
      void this._finishDockButtonClick(Promise.resolve(Utilities.callEventHandler(field.dock.onClick, [field])))
      return
    }
    if (field.type === CONFIG_TYPE_FLAG) {
      this._blurDockSlideOutFocus()
      void this._toggleDockFlagFromIdb(field)
      return
    }
    if (field.type === CONFIG_TYPE_ACTION && field.onClick) {
      void this._finishDockButtonClick(Promise.resolve(Utilities.callEventHandler(field.onClick, [])))
    }
  }

  /**
   * @param {Promise<*>} promise
   * @return {Promise<void>}
   * @private
   */
  async _finishDockButtonClick(promise)
  {
    try {
      await promise
    } finally {
      this._blurDockSlideOutFocus()
      this.refreshDockButtonStates()
    }
  }

  /**
   * @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.refreshDockButtonStates()
  }

  /**
   * @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) {
          continue
        }
        if (child.dock?.parent !== parentKey) {
          child.dock = {...(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 {boolean} value
   * @return {string}
   * @private
   */
  _dockFlagState(value)
  {
    return value ? 'bv-dock-btn-active' : ''
  }

  /**
   * @param {string} label
   * @param {boolean} value
   * @param {string} whenOn
   * @param {string} whenOff
   * @return {string}
   * @private
   */
  _dockFlagTooltip(label, value, whenOn, whenOff)
  {
    return value ? `${label}: on — ${whenOn}` : `${label}: off — ${whenOff}`
  }

  /**
   * @param {{icon: string, label: string, whenOn: string, whenOff: string, include?: Function}} spec
   * @return {object}
   * @private
   */
  _dockFlagToggleRecipe(spec)
  {
    let recipe = {
      icon: spec.icon,
      getState: (value) => this._dockFlagState(value),
      tooltip: (value) => this._dockFlagTooltip(spec.label, value, spec.whenOn, spec.whenOff),
    }
    if (spec.include !== undefined) {
      recipe.include = spec.include
    }
    return recipe
  }

  /**
   * Named dock-button recipes with baked copy. Consumers call by name only;
   * pass sparse `args` solely when a real override case is added to the API.
   *
   * @param {ConfigurationField} field
   * @param {string} name
   * @param {object} [args]
   * @private
   */
  _applyDockTemplate(field, name, args = {})
  {
    let searchPageInclude = function() {
      return typeof this.isPage === 'function' ? this.isPage('search') : true
    }
    let downloadManagerInclude = function() {
      return typeof this.isDownloadManagerEnabled === 'function' ? this.isDownloadManagerEnabled() : true
    }

    let recipe = null
    switch (name) {
      case 'flagToggle':
        // Generic escape hatch — prefer named templates with baked copy.
        recipe = this._dockFlagToggleRecipe({
          icon: args.icon ?? 'block',
          label: args.label ?? field.title ?? field.key,
          whenOn: args.whenOn ?? 'click to turn off',
          whenOff: args.whenOff ?? 'click to turn on',
          include: args.include,
        })
        break
      case 'tagBlacklist':
        recipe = this._dockFlagToggleRecipe({
          icon: 'block',
          label: 'Tag blacklist',
          whenOn: 'click to stop hiding matching posts',
          whenOff: 'click to hide posts with listed tags',
        })
        break
      case 'exploredTags':
        recipe = this._dockFlagToggleRecipe({
          icon: 'explore',
          label: 'Explored tags',
          whenOn: 'click to stop tracking while paging',
          whenOff: 'click to track while paging',
          include: searchPageInclude,
        })
        break
      case 'autoDownload':
        recipe = this._dockFlagToggleRecipe({
          icon: 'download',
          label: 'Auto-download',
          whenOn: 'click to stop downloading when opening posts',
          whenOff: 'click to download media when opening posts',
          include: searchPageInclude,
        })
        break
      case 'removeMediaOnDownload':
        recipe = this._dockFlagToggleRecipe({
          icon: 'eye-off',
          label: 'Remove media on download',
          whenOn: 'click to keep the picture/player after save',
          whenOff: 'click to remove the picture/player after a successful save',
          include: searchPageInclude,
        })
        break
      case 'autoNextPage':
        recipe = this._dockFlagToggleRecipe({
          icon: 'next',
          label: 'Auto next page',
          whenOn: 'click to stop advancing when page is empty',
          whenOff: 'click to go to next page when all results filtered',
          include: searchPageInclude,
        })
        break
      case 'defaultTags':
        recipe = this._dockFlagToggleRecipe({
          icon: 'inject',
          label: 'Default tags',
          whenOn: 'click to stop injecting into searches',
          whenOff: 'click to inject into every search',
        })
        break
      case 'resolutionFilter':
        recipe = this._dockFlagToggleRecipe({
          icon: 'resolution',
          label: 'Resolution filter',
          whenOn: 'click to stop restricting width / height',
          whenOff: 'click to limit posts to size range',
        })
        break
      case 'hideOlderPosts':
        recipe = this._dockFlagToggleRecipe({
          icon: 'history',
          label: 'Hide older posts',
          whenOn: 'click to show posts below Last ID',
          whenOff: 'click to hide posts below Last ID',
        })
        break
      case 'invertedFiltersMaster':
        recipe = {
          icon: 'filter',
          isActive: (value) => !value,
          slideOutWhen: (value) => !value,
          tooltip: (value) => value ?
              'Filters bypassed — click to re-enable' :
              'Search filters active — hover for sub-filters',
          include: searchPageInclude,
        }
        break
      case 'skipDuplicates':
        recipe = {
          icon: 'skip-duplicate',
          getState: (value) => this._dockFlagState(value),
          tooltip: (value) => value ?
              'Skip duplicates: on — click to allow re-downloads; hover for Hide Downloaded Media' :
              'Skip duplicates: off — click to skip ledger hits; hover for Hide Downloaded Media',
          slideOutWhen: () => true,
          include: downloadManagerInclude,
        }
        break
      case 'hideDownloaded':
        recipe = this._dockFlagToggleRecipe({
          icon: 'eye-off',
          label: 'Hide downloaded',
          whenOn: 'click to show posts already in the download ledger',
          whenOff: 'click to hide posts already in the download ledger',
          include: downloadManagerInclude,
        })
        break
      default:
        console.warn('Unknown dock template:', name)
        return
    }

    // Sparse dock-behavior overrides only (e.g. onClick). Recipe copy stays in the template.
    let {
      icon: _icon,
      label: _label,
      whenOn: _whenOn,
      whenOff: _whenOff,
      ...dockOverrides
    } = args
    field.dock = {...(field.dock ?? {}), ...recipe, ...dockOverrides}
  }

  /**
   * @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 {ConfigurationField} field
   * @return {boolean}
   * @private
   */
  _isDockSlideOutPinned(field)
  {
    if (field.dock.slideOutPinnedWhen) {
      return !!Utilities.callEventHandler(field.dock.slideOutPinnedWhen, [field.value, field])
    }
    return false
  }

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

  /**
   * @return {boolean}
   */
  isDockActive()
  {
    return this._dockActive
  }

  /**
   * @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
    }
    if (field.dock.getSlideOutNodes) {
      let nodes = Utilities.callEventHandler(field.dock.getSlideOutNodes, [field], null)
      if (Array.isArray(nodes) && nodes.some((node) => node?.length)) {
        return true
      }
    }
    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 {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)
  }

  /**
   * @param {string} name
   * @return {boolean}
   */
  evaluateDockInclude(name)
  {
    let field = this.getField(name)
    if (!field?.dock) {
      return false
    }
    return this._evaluateDockInclude(field)
  }

  /**
   * Detaches all mounted dock button nodes from the DOM and clears element references.
   * @return {BrazenConfigurationManager}
   */
  clearDockElements()
  {
    for (let key in this._config) {
      let field = this._config[key]
      if (field.dockElement?.length) {
        field.dockElement.remove()
        field.dockElement = null
        field._dockShowsSlideOut = false
      }
    }
    return this
  }

  /**
   * @return {BrazenConfigurationManager}
   */
  refreshDockButtonStates()
  {
    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 icon = typeof field.dock.icon === 'function' ? field.dock.icon(value, field) : field.dock.icon
    let disabled = this._resolveDockDisabled(field, value)
    this._uiGen.updateDockFieldButton(field, {
      stateClass: disabled ? '' : this._resolveDockStateClass(field, value),
      tooltip: this._resolveDockTooltip(field, value),
      icon: typeof icon === 'string' ? icon : undefined,
      disabled: disabled,
    })
    if (field.dockElement.hasClass('bv-dock-slot')) {
      field.dockElement.toggleClass('bv-dock-slideout-pinned', this._isDockSlideOutPinned(field))
    }
  }

  /**
   * @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()
    await this._repos.tagRuntime.warmCache()
  }

  /**
   * @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()
    await this._repos.tagRuntime.warmCache()

    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()
        await this._repos.tagRuntime.warmCache()
        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)) {
      if (TAG_FIELD_SPEC[fieldKey]) {
        let spec = TAG_FIELD_SPEC[fieldKey]
        let rawLines = await this._repos.tags._projectTaggingFieldLines(fieldKey, spec.group, spec)
        let compiled = await this._repos.tags.getCompiledField(fieldKey)
        this._cacheSetting(fieldKey, rawLines, compiled?.optimized ?? null)
        this._overlayFieldFromCache(field)
        return
      }
      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) {
            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)
      }
    }
    if (TAG_FIELD_SPEC[fieldKey]) {
      await this._repos.tagRuntime.persistTaggingFieldLines(fieldKey, expanded)
      if (TAG_FIELD_SPEC[fieldKey].usesCombos) {
        await this.scheduleTagRuleCompile([group])
      } else {
        await this._repos.tags.compileGroup(group, () => null, false)
      }
      return
    }
    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
  }

  /**
   * Reload in-memory tag ruleset fields from compiled IDB stores (tagRuleSets).
   * Use after tag-registry–only writes so filter rules stay active without a full save.
   * @return {Promise<void>}
   */
  async reloadTagRuleFieldsFromStorage()
  {
    if (!this.canPersist()) {
      return
    }
    await this._ensureStorageReady()
    for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
      if (this._config[fieldKey]) {
        await this._syncFieldFromIdb(fieldKey)
      }
    }
  }

  /**
   * Wipe the download duplicate ledger (all `ledgerEntries` rows) and reload the in-memory Set.
   * @return {Promise<void>}
   */
  async clearDownloadLedger()
  {
    if (!this.canPersist()) {
      return
    }
    await this._ensureStorageReady()
    await this._repos.ledger.replaceAll([])
    let field = this.getField('download-ledger')
    if (field?.reload) {
      await field.reload()
    }
  }

  /**
   * Wipe this script's entire IndexedDB database (settings, bookmarks, ledger, tags,
   * download queues, etc.). Caller should reload the page afterward so setup can run again.
   * @return {Promise<void>}
   */
  async clearScriptDatabase()
  {
    if (!this._repos?.storage) {
      return
    }
    this._storageReady = false
    this._idbBlocked = false
    await this._repos.storage.deleteDatabase()
  }

  /**
   * Wipe tag registry, raw rules, and compiled rule sets (testing helper).
   * Does not read or re-import panel fields.
   * @return {Promise<void>}
   */
  async clearTagDomain()
  {
    if (!this.canPersist()) {
      return
    }
    await this._ensureStorageReady()
    await this._repos.tags.clearAll()
    await this._repos.tagRules.clearAll()
    await this._repos.tags.resetCompiledRuleSets()
    this._tagComplianceSpecs = {}
    await this._repos.tagRuntime.warmCache()
    for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
      if (this._config[fieldKey]) {
        await this._syncFieldFromIdb(fieldKey)
      }
    }
    await this._commitTagFieldChange()
  }

  /**
   * Wipe tag registry, raw rules, and compiled rule sets; rebuild from current panel fields.
   * Temporary testing helper — keeps UI ruleset text as source of truth.
   * @return {Promise<void>}
   */
  async rebuildTagDomainFromPanel()
  {
    if (!this.canPersist()) {
      return
    }
    await this._ensureStorageReady()
    this.update()
    await this._repos.tags.clearAll()
    await this._repos.tagRules.clearAll()
    await this._repos.tags.resetCompiledRuleSets()
    for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
      let field = this._config[fieldKey]
      if (!field) {
        continue
      }
      await this._persistTagFieldFromUI(fieldKey, field)
    }
    await this._repos.tagRuntime.warmCache()
    await this._commitTagFieldChange()
  }

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

  /**
   * Cached compliance specs keyed by field (`soleAttribute` + `combos`).
   * @type {Record<string, {soleAttribute: {root: string, key: string}|null, combos: {tagEntryIds: number[]}[]}>}
   * @private
   */
  _tagComplianceSpecs = {}

  /**
   * @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.notifyConfigurationChange('settings', true)
    return this
  }

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

  /**
   * @return {TagRuntime|null}
   */
  getTagRuntime()
  {
    return this._repos?.tagRuntime ?? null
  }

  /**
   * @param {string} fieldKey
   * @return {boolean}
   */
  isTagRegistryField(fieldKey)
  {
    return !!TAG_FIELD_SPEC[fieldKey]?.usesTaggingRegistry
  }

  /**
   * @param {string} tagName Normalized tag name.
   * @return {*|null}
   */
  getCachedTagEntry(tagName)
  {
    return this.getTagRuntime()?.getCachedByName(tagName) ?? null
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName Normalized tag name.
   * @return {boolean}
   */
  hasTagSoleAttribute(fieldKey, tagName)
  {
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (!spec?.soleAttribute) {
      return false
    }
    if (this.canPersist()) {
      let entry = this.getCachedTagEntry(tagName)
      return entry ? readTagSoleAttribute(entry, spec.soleAttribute) : false
    }
    return this.getField(fieldKey)?.hasRule(tagName) ?? false
  }

  /**
   * @param {string} tagName Normalized tag name.
   * @param {string} [fieldKey='filename-tag-substitutions']
   * @return {boolean}
   */
  hasTagSubstitutionSubject(tagName, fieldKey = 'filename-tag-substitutions')
  {
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (spec?.substitution && this.canPersist()) {
      return !!this.getCachedTagEntry(tagName)?.download?.substitution
    }
    return this.getField(fieldKey)?.hasRule(tagName) ?? false
  }

  /**
   * Persist a filename-tag substitution (`subject` → `replacement`). Names should already be normalized by the caller.
   * @param {string} subject
   * @param {string} replacement
   * @param {string} [fieldKey='filename-tag-substitutions']
   * @return {Promise<boolean>}
   */
  async setTagSubstitution(subject, replacement, fieldKey = 'filename-tag-substitutions')
  {
    let field = this.getField(fieldKey)
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (!field || !spec?.substitution) {
      return false
    }
    let subjectName = String(subject ?? '').trim()
    let replacementName = String(replacement ?? '').trim()
    if (!subjectName || !replacementName) {
      return false
    }
    if (this.canPersist()) {
      await this._repos.tagRuntime.patchAttributes(subjectName, {
        download: {substitution: {replacementName}},
      }, {replacementName})
      let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
      if (group) {
        await this._repos.tags.compileGroup(group, () => null, false)
      }
      await this._repos.tagRuntime.warmCache()
      await this._syncFieldFromIdb(fieldKey)
      await this._commitTagFieldChange()
      return true
    }
    if (field.hasRule?.(subjectName)) {
      field.removeRule(subjectName)
    }
    field.addRule({subject: subjectName, replacement: replacementName}, subjectName)
    await this.save()
    return true
  }

  /**
   * Remove a filename-tag substitution subject. Name should already be normalized by the caller.
   * @param {string} subject
   * @param {string} [fieldKey='filename-tag-substitutions']
   * @return {Promise<boolean>}
   */
  async clearTagSubstitution(subject, fieldKey = 'filename-tag-substitutions')
  {
    let field = this.getField(fieldKey)
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (!field || !spec?.substitution) {
      return false
    }
    let subjectName = String(subject ?? '').trim()
    if (!subjectName) {
      return false
    }
    if (this.canPersist()) {
      await this._repos.tagRuntime.patchAttributes(subjectName, {
        download: {substitution: null},
      })
      let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
      if (group) {
        await this._repos.tags.compileGroup(group, () => null, false)
      }
      await this._repos.tagRuntime.warmCache()
      await this._syncFieldFromIdb(fieldKey)
      await this._commitTagFieldChange()
      return true
    }
    field.removeRule(subjectName)
    await this.save()
    return true
  }

  /**
   * @param {string} fieldKey
   * @return {boolean}
   */
  hasTagComplianceRules(fieldKey)
  {
    if (this.isTagRegistryField(fieldKey) && this.canPersist()) {
      let field = this.getField(fieldKey)
      if (field?.value?.length) {
        return true
      }
      return !!(field?.optimized?.combos?.length)
    }
    let field = this.getField(fieldKey)
    let optimized = field?.optimized
    return !!(field?.value?.length || (Array.isArray(optimized) && optimized.length))
  }

  /**
   * @param {string[]} [fieldKeys] Compliance registry fields; defaults to all in `TAG_FIELD_SPEC` with compliance attributes.
   * @return {Promise<void>}
   */
  async refreshTagComplianceSpecs(fieldKeys = null)
  {
    let tagRuntime = this.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    let keys = fieldKeys ?? Object.keys(TAG_FIELD_SPEC).filter((fieldKey) =>
        TAG_FIELD_SPEC[fieldKey].soleAttribute?.root === 'compliance')
    for (let fieldKey of keys) {
      this._tagComplianceSpecs[fieldKey] = await tagRuntime.getComplianceSpecForField(fieldKey)
    }
  }

  /**
   * @param {string} fieldKey
   * @return {{soleAttribute: {root: string, key: string}|null, combos: {tagEntryIds: number[]}[]}|null}
   */
  getTagComplianceSpec(fieldKey)
  {
    return this._tagComplianceSpecs[fieldKey] ?? null
  }

  /**
   * @param {string[]} itemTagNames
   * @param {string} fieldKey
   * @return {{complies: boolean, rule?: string}}
   */
  evaluateTagCompliance(itemTagNames, fieldKey)
  {
    let spec = this.getTagComplianceSpec(fieldKey)
    let tagRuntime = this.getTagRuntime()
    if (!spec || !tagRuntime?._warmed) {
      return {complies: true}
    }
    return tagRuntime.evaluateComplianceSync(itemTagNames, spec)
  }

  /**
   * @param {{typeName?: string|null, typeEntryId?: number|null, replacementName?: string|null, source?: string|null}} [options]
   * @return {{typeName?: string, typeEntryId?: number, replacementName?: string, source?: string}}
   */
  createTagContext(options = {})
  {
    let context = {}
    if (options.typeName) {
      context.typeName = options.typeName
    }
    if (options.typeEntryId != null) {
      context.typeEntryId = options.typeEntryId
    }
    if (options.replacementName) {
      context.replacementName = options.replacementName
    }
    if (options.source) {
      context.source = options.source
    }
    return context
  }

  /**
   * Register-on-seen: ensure each tag in typed groups exists in the registry.
   * @param {Record<string, string[]>} groups Map of type name → tag names.
   * @param {string} [source='media']
   * @return {Promise<void>}
   */
  async registerTypedTagGroups(groups, source = 'media')
  {
    let tagRuntime = this.getTagRuntime()
    if (!tagRuntime || !this.canPersist()) {
      return
    }
    for (let [typeName, names] of Object.entries(groups)) {
      if (!Array.isArray(names)) {
        continue
      }
      for (let name of names) {
        if (name) {
          await tagRuntime.ensureTag(name, {typeName, source})
        }
      }
    }
  }

  /**
   * Compile tag rules, warm registry cache, and refresh compliance specs.
   * @return {Promise<void>}
   */
  async prepareTagComplianceRuntime()
  {
    if (!this.canPersist()) {
      return
    }
    await this.ensureTagRuleSetsCompiled()
    await this.getTagRuntime()?.warmCache()
    await this.refreshTagComplianceSpecs()
  }

  /**
   * When IndexedDB tagging is active, route download path policy through `TagRuntime`.
   * @param {object} resolver Base resolver (chips, tagTypes, ignore Set, substitutions Map, etc.).
   * @return {object}
   */
  applyTagRegistryDownloadResolver(resolver)
  {
    let tagRuntime = this.getTagRuntime()
    if (this.canPersist() && tagRuntime) {
      return {
        ...resolver,
        tagRuntime,
        ignore: null,
        substitutions: null,
      }
    }
    return resolver
  }

  /**
   * @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()
    // UI readers (`hasTagSubstitutionSubject`, `hasTagSoleAttribute`) use the warm cache.
    await this._repos.tagRuntime?.warmCache()
    this.notifyConfigurationChange('tags', true)
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName
   * @return {Promise<boolean>}
   */
  /**
   * @param {string} fieldKey
   * @param {string} tagName
   * @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null}|null} [context]
   * @return {Promise<boolean>}
   */
  async toggleTagRule(fieldKey, tagName, context = null)
  {
    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
    }

    let spec = TAG_FIELD_SPEC[fieldKey]
    if (spec?.soleAttribute) {
      let normalized = String(tagName)
      let entry = await this._repos.tagRuntime.getTag(normalized)
      let current = entry ? readTagSoleAttribute(entry, spec.soleAttribute) : false
      let patch = spec.soleAttribute.root === 'compliance' ?
          {compliance: {[spec.soleAttribute.key]: !current}} :
          {download: {[spec.soleAttribute.key]: !current}}
      await this._repos.tagRuntime.patchAttributes(normalized, patch, context)
      if (spec.usesCombos) {
        await this.scheduleTagRuleCompile([group])
        await this._compilePendingTagRuleGroups()
      } else {
        await this._repos.tags.compileGroup(group, () => null, false)
      }
      await this._repos.tagRuntime.warmCache()
      if (spec.soleAttribute?.root === 'compliance') {
        await this.refreshTagComplianceSpecs([fieldKey])
      }
      await this._syncFieldFromIdb(fieldKey)
      await this._commitTagFieldChange()
      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
    }
    if (TAG_FIELD_SPEC[fieldKey]) {
      await this._repos.tagRuntime.persistTaggingFieldLines(fieldKey, [])
      if (TAG_FIELD_SPEC[fieldKey].usesCombos) {
        await this.scheduleTagRuleCompile([group])
      } else {
        await this._repos.tags.compileGroup(group, () => null, false)
      }
      await this._compilePendingTagRuleGroups()
      await this._repos.tagRuntime.warmCache()
      if (TAG_FIELD_SPEC[fieldKey].soleAttribute?.root === 'compliance') {
        await this.refreshTagComplianceSpecs([fieldKey])
      }
      await this._syncFieldFromIdb(fieldKey)
      await this._commitTagFieldChange()
      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.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 : []
    if (TAG_FIELD_SPEC[fieldKey]) {
      await this._repos.tagRuntime.persistTaggingFieldLines(fieldKey, lines)
      if (TAG_FIELD_SPEC[fieldKey].usesCombos) {
        await this.scheduleTagRuleCompile([group])
      } else {
        await this._repos.tags.compileGroup(group, () => null, false)
      }
      await this._compilePendingTagRuleGroups()
      await this._repos.tagRuntime.warmCache()
      await this._syncFieldFromIdb(fieldKey)
      return
    }
    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.refreshDockButtonStates()
    }
    return this
  }
}