Brazen Framework - Configuration Manager

Configuration management for the Brazen user scripts framework

이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/418665/1877004/Brazen%20Framework%20-%20Configuration%20Manager.js을(를) 사용하여 포함하는 라이브러리입니다.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

// ==UserScript==
// @name         Brazen Framework - Configuration Manager
// @namespace    brazenvoid
// @version      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
  }
}