Brazen Framework - Configuration Manager

Configuration management for the Brazen user scripts framework

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

const CONFIG_BACKUP_VERSION = 5
/** Legacy-compatible zip shape for pre-migration safety exports (downgrade + restore path). */
const PRE_MIGRATION_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_DEFAULTS = {
  flag: false,
  color: false,
  text: '',
  number: null,
  range: null,
  checkboxes: [],
  select: null,
  radios: null,
}
const RULESET_USER_CONFIG_KEYS = ['autoSort', 'sortMode', 'groupingEnabled', 'hideTagTypes']
const DEFAULT_RULESET_USER_CONFIG = {
  autoSort: true,
  sortMode: 'natural-asc',
  groupingEnabled: false,
  hideTagTypes: false,
}

/**
 * @param {string} subject
 * @return {string}
 */
function pluralizeRulesetSubject(subject)
{
  let word = String(subject ?? 'rule').trim().toLowerCase() || 'rule'
  if (word.endsWith('s')) {
    return word
  }
  if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) {
    return word.slice(0, -1) + 'ies'
  }
  if (/([sxz]|ch|sh)$/.test(word)) {
    return word + 'es'
  }
  return word + 's'
}

/**
 * @param {string} subject
 * @return {string}
 */
function titleCaseRulesetSubject(subject)
{
  let word = String(subject ?? '').trim()
  if (!word) {
    return ''
  }
  return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
}

/** @type {Function[]} */
const RULESET_MUTATION_LISTENERS = []

/**
 * Simple pub/sub for ruleset row mutations (sidebar toggles, cross-tab sync, etc.).
 */
class RulesetMutationBus
{
  /**
   * @param {Function} listener
   */
  static subscribe(listener)
  {
    if (typeof listener === 'function') {
      RULESET_MUTATION_LISTENERS.push(listener)
    }
  }

  /**
   * @param {{fieldKeys?: string[], tagNames?: string[], entryIds?: number[]}} detail
   */
  static notify(detail = {})
  {
    for (let listener of RULESET_MUTATION_LISTENERS) {
      try {
        listener(detail)
      } catch (error) {
        console.log('[RulesetMutationBus]', error)
      }
    }
  }
}

class BrazenConfigurationManager
{
  /**
   * @typedef {{key: string, title: string, type: string, element: null|HTMLElement, value: *, maximum: int, minimum: int, options: string[], help: *,
   *            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 {HTMLElement[]}
   * @private
   */
  _dockElements = []

  /**
   * @type {Error|null}
   * @private
   */
  _migrationError = null

  /**
   * @type {function(*): (void|Promise<void>)|null}
   * @private
   */
  _reportMigrationProgress = null

  /**
   * @return {Error|null}
   */
  getMigrationError()
  {
    return this._migrationError
  }

  /**
   * @param {function(MigrationProgress|string): void|Promise<void>|null|undefined} callback
   * @return {function(MigrationProgress|string): Promise<void>}
   * @private
   */
  _bindMigrationProgress(callback)
  {
    if (typeof callback !== 'function') {
      return async () => {}
    }
    return async (progress) => {
      let normalized = typeof progress === 'string'
          ? {phase: 'generic', label: progress, indeterminate: true}
          : progress
      await callback(normalized)
    }
  }

  /**
   * @param {object|null} meta
   * @return {Promise<boolean>}
   * @private
   */
  async _isMigrationWorkNeeded(meta = null)
  {
    meta = meta ?? await this._repos.meta.get()
    if (meta?.setupInProgress) {
      return true
    }
    let localWork = await this._getLocalMigrationWork(meta)
    return localWork.hasLocalWork
  }

  /**
   * @param {object|null} meta
   * @return {Promise<{schemaRebuild: boolean, backfill: boolean, rulesetMigration: boolean, hasLocalWork: boolean}>}
   * @private
   */
  async _getLocalMigrationWork(meta = null)
  {
    meta = meta ?? await this._repos.meta.get()
    let schemaRebuild = !await this._repos.storage.isHealthy()
    let backfill = !!meta?.pendingIsDiscoveredBackfill
    let rulesetConfigDefaultsV9 = !!meta?.pendingRulesetConfigDefaultsV9
    let rulesetHideTagTypesV10 = !!meta?.pendingRulesetHideTagTypesResetV10
    let rulesetMigration = !meta?.rulesetMigrated
    let bookmarksMigration = !meta?.bookmarksMigrated
    return {
      schemaRebuild,
      backfill,
      rulesetConfigDefaultsV9,
      rulesetHideTagTypesV10,
      rulesetMigration,
      bookmarksMigration,
      hasLocalWork: schemaRebuild || backfill || rulesetConfigDefaultsV9 ||
          rulesetHideTagTypesV10 || rulesetMigration || bookmarksMigration,
    }
  }

  /**
   * @return {Promise<{installed: number, supported: number}|null>}
   */
  async getSchemaVersionConflict()
  {
    if (!this._repos.storage.available) {
      return null
    }
    return this._repos.storage.getSchemaVersionConflict()
  }

  /**
   * @param {Error|*} error
   * @return {Promise<{installed: number, supported: number}|null>}
   */
  async resolveSchemaVersionConflictFromError(error)
  {
    if (error?.code === 'SCHEMA_TOO_NEW' && error.schemaConflict) {
      return error.schemaConflict
    }
    if (this._repos.storage.isVersionError?.(error)) {
      return this.getSchemaVersionConflict()
    }
    return null
  }

  /**
   * @return {Promise<{consentRequired: boolean, peerWaitOnly: boolean, schemaTooNew: boolean, installedSchemaVersion: number|null, supportedSchemaVersion: number|null, steps: string[], backupFilename: string|null}>}
   */
  async getPendingMigrationPlan()
  {
    let emptyPlan = {
      consentRequired: false,
      peerWaitOnly: false,
      schemaTooNew: false,
      idbUnavailable: false,
      installedSchemaVersion: null,
      supportedSchemaVersion: null,
      steps: [],
      backupFilename: null,
    }
    if (!this._repos.storage.available) {
      return {...emptyPlan, idbUnavailable: true}
    }
    let wipeFlagKey = this._scriptPrefix + 'pending-idb-wipe'
    try {
      if (sessionStorage.getItem(wipeFlagKey) === '1') {
        return emptyPlan
      }
    } catch (e) {
    }
    let schemaConflict = await this.getSchemaVersionConflict()
    if (schemaConflict) {
      return {
        ...emptyPlan,
        schemaTooNew: true,
        installedSchemaVersion: schemaConflict.installed,
        supportedSchemaVersion: schemaConflict.supported,
      }
    }
    try {
      await this._repos.storage.open()
    } catch (error) {
      let conflict = await this.resolveSchemaVersionConflictFromError(error)
      if (conflict) {
        return {
          ...emptyPlan,
          schemaTooNew: true,
          installedSchemaVersion: conflict.installed,
          supportedSchemaVersion: conflict.supported,
        }
      }
      throw error
    }
    let meta = await this._repos.meta.get()
    if (!(await this._isMigrationWorkNeeded(meta))) {
      return emptyPlan
    }
    let localWork = await this._getLocalMigrationWork(meta)
    if (meta?.setupInProgress && !localWork.hasLocalWork) {
      return {...emptyPlan, peerWaitOnly: true}
    }
    let backupFilename = this._scriptPrefix + 'pre-migration-backup.zip'
    let steps = [
      'Download a safety backup (' + backupFilename + ') to your Downloads folder.',
    ]
    let migrationParts = []
    if (localWork.schemaRebuild) {
      migrationParts.push('rebuild the database schema')
    }
    if (localWork.backfill) {
      migrationParts.push('backfill tag metadata')
    }
    if (localWork.rulesetConfigDefaultsV9) {
      migrationParts.push('apply ruleset configuration defaults')
    }
    if (localWork.rulesetMigration) {
      migrationParts.push('migrate tag rules')
    }
    steps.push(
        migrationParts.length
            ? 'Update the local database (' + migrationParts.join(', ') + ').'
            : 'Update the local database (settings, tags, bookmarks, and download history).',
    )
    steps.push('Finish loading the script.')
    return {
      ...emptyPlan,
      consentRequired: true,
      steps,
      backupFilename,
    }
  }

  /**
   * @param {function(MigrationProgress|string): Promise<void>} report
   * @return {Promise<void>}
   * @private
   */
  async _waitPeerSetupWithProgress(report)
  {
    let started = Date.now()
    while (true) {
      let meta = await this._repos.meta.get()
      if (meta?.setupComplete && !meta?.setupInProgress) {
        return
      }
      if (!meta?.setupInProgress) {
        return
      }
      let elapsedSec = Math.floor((Date.now() - started) / 1000)
      let detail = elapsedSec >= 120
          ? 'Still waiting… you can Retry or Reset database.'
          : elapsedSec + 's elapsed'
      await report({
        phase: 'peer-setup',
        label: 'Waiting for another tab to finish setup…',
        detail,
        indeterminate: true,
      })
      if (Date.now() - started > 120000) {
        throw new Error('Timed out waiting for setup to complete')
      }
      await Utilities.sleep(500)
    }
  }

  /**
   * @param {string} storeName
   * @return {Promise<*|null>}
   * @private
   */
  async _readLegacyStoreForBackup(storeName)
  {
    await this._repos.storage.open()
    let db = this._repos.storage._db
    if (!db?.objectStoreNames.contains(storeName)) {
      return null
    }
    if (storeName === 'tagRuleSets') {
      return this._repos.storage.get(storeName, 'tagRuleSets')
    }
    return this._repos.storage.getAll(storeName)
  }

  /**
   * @param {BrazenZipWriter} zip
   * @param {string} filename
   * @return {void}
   * @private
   */
  _triggerBackupZipDownload(zip, filename)
  {
    let link = document.createElement('a')
    link.download = filename
    let objectUrl = URL.createObjectURL(zip.build())
    link.href = objectUrl
    link.click()
    setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
  }

  /**
   * Shared zip assembly for user backup and pre-migration safety export.
   * @param {{ purpose: 'preMigrationSafety'|'userBackup', stores?: string[], onProgress?: function(MigrationProgress|string): void|Promise<void>|null }} options
   * @return {Promise<{zip: BrazenZipWriter, filename: string, meta: object|null}>}
   * @private
   */
  async _buildBackupZip({purpose, stores = null, onProgress = null})
  {
    await this._repos.storage.open()
    let meta = await this._repos.meta.get()
    let zip = new BrazenZipWriter()

    if (purpose === 'preMigrationSafety') {
      let manifestStores = [...(stores ?? [
        'meta', 'settings', 'apis', 'tagTypes', 'tags', 'bookmarks', 'ledgerEntries',
      ])]
      let tagRules = await this._readLegacyStoreForBackup('tagRules')
      if (tagRules != null) {
        manifestStores.push('tagRules')
      }
      let tagRuleSets = await this._readLegacyStoreForBackup('tagRuleSets')
      if (tagRuleSets != null) {
        manifestStores.push('tagRuleSets')
      }
      let tagPartCount = await addStoreJsonPartsToZip(
          zip, this._repos.storage, IDB_STORE_TAGS, BACKUP_JSON_PART_SIZE, onProgress,
          {phase: 'safety-backup', label: 'Creating safety backup…'},
      )
      let bookmarkPartCount = await addStoreJsonPartsToZip(
          zip, this._repos.storage, IDB_STORE_BOOKMARKS, BACKUP_JSON_PART_SIZE, onProgress,
          {phase: 'safety-backup', label: 'Creating safety backup…'},
      )
      let ledgerPartCount = await addStoreJsonPartsToZip(
          zip, this._repos.storage, IDB_STORE_LEDGER, BACKUP_JSON_PART_SIZE, onProgress,
          {phase: 'safety-backup', label: 'Creating safety backup…'},
      )
      zip.addFile('manifest.json', JSON.stringify({
        version: PRE_MIGRATION_BACKUP_VERSION,
        purpose: 'preMigrationSafety',
        scriptPrefix: this._scriptPrefix,
        dbName: this._repos.storage.dbName,
        revisionId: meta?.revisionId,
        exportedAt: Date.now(),
        stores: manifestStores,
        chunkedStores: {
          tags: tagPartCount,
          bookmarks: bookmarkPartCount,
          ledgerEntries: ledgerPartCount,
        },
      }, 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))
      if (tagRules != null) {
        zip.addFile('tagRules.json', JSON.stringify(tagRules, null, 2))
      }
      if (tagRuleSets != null) {
        zip.addFile('tagRuleSets.json', JSON.stringify(tagRuleSets, null, 2))
      }
      return {
        zip,
        filename: this._scriptPrefix + 'pre-migration-backup.zip',
        meta,
        safetyBackupCounts: {
          tags: tagPartCount,
          bookmarks: bookmarkPartCount,
          ledgerEntries: ledgerPartCount,
        },
      }
    }

    let manifestStores = stores ?? IDB_ALL_STORES
    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: manifestStores,
    }, 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('rulesetFields.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_RULESET_FIELDS), null, 2))
    zip.addFile('rulesetEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_RULESET_ENTRIES), null, 2))
    zip.addFile('bookmarks.json', JSON.stringify(await this._exportBookmarksBackupRows(), null, 2))
    zip.addFile('ledgerEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_LEDGER), null, 2))
    return {
      zip,
      filename: this._scriptPrefix + 'backup.zip',
      meta,
    }
  }

  /**
   * Auto-download a legacy-compatible safety backup before destructive migration.
   * @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
   * @return {Promise<string>} Download filename
   */
  async createPreMigrationSafetyBackup(onProgress = null)
  {
    let {zip, filename, meta, safetyBackupCounts} = await this._buildBackupZip({
      purpose: 'preMigrationSafety',
      onProgress,
    })
    this._triggerBackupZipDownload(zip, filename)
    meta = meta ?? await this._repos.storage.createDefaultMeta()
    meta.migrationSafetyBackupAt = Date.now()
    meta.migrationSafetyBackupRevisionId = meta.revisionId ?? null
    meta.migrationSafetyBackupCounts = safetyBackupCounts
    await this._repos.meta.put(meta)
    try {
      sessionStorage.setItem(this._scriptPrefix + 'pre-migration-backup-file', filename)
    } catch (error) {
    }
    return filename
  }

  /**
   * @return {string|null}
   */
  getPreMigrationBackupFilenameHint()
  {
    try {
      return sessionStorage.getItem(this._scriptPrefix + 'pre-migration-backup-file')
    } catch (error) {
      return null
    }
  }

  /**
   * @type {((event: PageTransitionEvent) => void)|null}
   * @private
   */
  _idbPageshowHandler = null

  /**
   * @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._ledgerImportRevisionPending = false
    this._syncedRevisionId = null
    /** @type {string|null} revisionId after last ensureRulesetFieldsCompiled boot pass */
    this._bootRulesetCompiledRevisionId = null
    this._workingSet = null
    /** @type {Map<string, *>} */
    this._fieldSeeds = new Map()
    RulesetMutationBus.subscribe((detail) => {
      void this._handleRulesetMutation(detail)
    })
  }

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

  /**
   * @param {ConfigurationField} field
   * @return {*|undefined}
   * @private
   */
  _deriveStructuralDefault(field)
  {
    if (field.type === CONFIG_TYPE_SELECT || field.type === CONFIG_TYPE_RADIOS_GROUP) {
      return field.options?.[0]?.[1] ?? null
    }
    if (field.type === CONFIG_TYPE_NUMBER) {
      return field.minimum ?? null
    }
    if (field.type === CONFIG_TYPE_RANGE) {
      return field.minimum == null ? null : {minimum: field.minimum, maximum: field.minimum}
    }
    return undefined
  }

  /**
   * @param {string} fieldKey
   * @return {*|undefined}
   * @private
   */
  _resolveSeedDefault(fieldKey)
  {
    let field = this._config[fieldKey]
    let seed = this._fieldSeeds.get(fieldKey)
    if (seed !== undefined) {
      return (seed && typeof seed === 'object' && 'default' in seed) ? seed.default : seed
    }
    let structural = field ? this._deriveStructuralDefault(field) : undefined
    if (structural !== undefined) {
      return structural
    }
    return field ? CONFIG_TYPE_DEFAULTS[field.type] : undefined
  }

  /**
   * @param {string} key
   * @return {void}
   * @private
   */
  _reresolveField(key)
  {
    // Seeds feed _seedAllFields / migration only — never push defaults into field.value at registration.
  }

  /**
   * @param {string} key
   * @param {*} spec
   * @return {BrazenConfigurationManager}
   */
  registerFieldSeed(key, spec)
  {
    this._fieldSeeds.set(key, spec)
    let field = this._config[key]
    if (field?.type === CONFIG_TYPE_RULESET) {
      if (!field.templateId && spec?.templateId) {
        field.templateId = spec.templateId
      }
      this._syncRulesetSubjectFromTemplate(field)
      field.widget?.updatePanelCopy?.(this._resolveRulesetPanelCopy(field))
    }
    return this
  }

  /**
   * @param {object} map
   * @return {BrazenConfigurationManager}
   */
  registerFieldSeeds(map)
  {
    for (let [key, spec] of Object.entries(map ?? {})) {
      this.registerFieldSeed(key, spec)
    }
    return this
  }

  /**
   * @return {Map<string, *>}
   */
  getFieldSeeds()
  {
    return this._fieldSeeds
  }

  /**
   * @param {ConfigurationField} field
   * @return {object}
   * @private
   */
  _sanitizeRulesetUserConfig(config = {})
  {
    let out = {}
    for (let key of RULESET_USER_CONFIG_KEYS) {
      if (config[key] !== undefined) {
        out[key] = config[key]
      }
    }
    return out
  }

  /**
   * Merge stored ruleset user config into the field object without replacing the reference
   * (ruleset panel widgets hold the same `field.config` object).
   * @param {ConfigurationField} field
   * @param {object|null|undefined} storedConfig
   * @return {void}
   * @private
   */
  _applyRulesetUserConfig(field, storedConfig)
  {
    if (!field.config || typeof field.config !== 'object') {
      field.config = {}
    }
    let sanitized = this._sanitizeRulesetUserConfig(storedConfig ?? {})
    for (let key of Object.keys(field.config)) {
      if (!(key in sanitized)) {
        delete field.config[key]
      }
    }
    Object.assign(field.config, sanitized)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _seedAllFields()
  {
    for (let field of Object.values(this._config)) {
      if (field.persist === false && field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      if (field.type === CONFIG_TYPE_RULESET) {
        let spec = this._fieldSeeds.get(field.key)
        if (!spec || !spec.templateId) {
          continue
        }
        let existing = await this._repos.rulesetFields.get(field.key)
        let serializedTemplateConfig = this._serializeRulesetTemplateConfig(spec.templateConfig ?? {})
        if (!existing) {
          await this._repos.rulesetFields.upsert({
            fieldKey: field.key,
            templateId: spec.templateId,
            templateConfig: serializedTemplateConfig,
            config: {...DEFAULT_RULESET_USER_CONFIG, ...(spec.config ?? {})},
          })
        } else {
          let templateIdChanged = existing.templateId !== spec.templateId
          let templateConfigChanged = JSON.stringify(existing.templateConfig ?? {}) !==
              JSON.stringify(serializedTemplateConfig)
          if (templateIdChanged || templateConfigChanged) {
            await this._repos.rulesetFields.upsert({
              ...existing,
              templateId: spec.templateId,
              templateConfig: serializedTemplateConfig,
            })
          }
        }
        continue
      }
      let current = await this._repos.settings.getField(field.key)
      if (current === undefined) {
        await this._repos.settings.putField(field.key, this._resolveSeedDefault(field.key))
      }
    }
  }

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

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _wireFieldBuilder(field)
  {
    field.setTitle = (title) => {
      field.title = title
      return field
    }
    field.setHelp = (help) => {
      field.help = help
      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
      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.setReadOnly = (readOnly = true) => {
      field.readOnly = !!readOnly
      return field
    }
    field.setSubstitutionComposer = (options = {}) => {
      field.substitutionComposer = typeof options === 'function'
          ? {normalize: options}
          : {...options}
      field.readOnly = true
      return field
    }
    field.setFormatter = (formatter) => {
      field.formatter = formatter
      return field
    }
    field.setPrimaryField = (primaryField) => {
      field.ledgerPrimaryField = primaryField
      return field
    }
    field.setIsValidId = (isValidId) => {
      field.ledgerIsValidId = isValidId
      return field
    }
    field.setShowAddButton = (showAddButton) => {
      field.showAddButton = showAddButton
      return field
    }
    field.setSortHelpText = (sortHelpText) => {
      field.sortHelpText = sortHelpText
      return field
    }
    field.setRulesetSubject = (rulesetSubject) => {
      field.rulesetSubject = rulesetSubject
      field._rulesetSubjectExplicit = true
      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.setOnBeforeRender = (onBeforeRender) => {
      field.onBeforeRender = onBeforeRender
      return field
    }
    field.setAttributeActionsConfig = (config) => {
      field.attributeActionsConfig = config ?? null
      field.rebuildRulesetTagIndex?.()
      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
    }
    field.setDetailFormTemplate = (factory) => {
      field.detailFormTemplate = factory
      return field
    }
    field.openDetailForm = (mode, ctx = {}) => {
      if (typeof field.detailFormTemplate === 'function' && mode !== 'help') {
        let content = field.detailFormTemplate(mode, {
          ...ctx,
          field,
          fieldKey: field.key,
          help: field.help,
          repos: this._repos,
        })
        let title = ctx.title
        if (title == null && field.type === CONFIG_TYPE_RULESET) {
          title = this._resolveRulesetDetailTitle(field, mode)
        } else if (title == null) {
          title = mode === 'create' ? 'Add rule' : 'Edit rule'
        }
        BrazenViewLayer.openSettingsDetailPane({
          title,
          content,
          onClose: ctx.onClose ?? null,
        })
        return field
      }
      if (field.help != null && field.help !== '') {
        let content = typeof field.help === 'string' ?
            Utilities.makeEl('div', {class: 'bv-field-help-detail', html: field.help}) :
            field.help
        BrazenViewLayer.openSettingsDetailPane({
          title: ctx.title ?? field.title ?? '',
          content,
          onClose: ctx.onClose ?? null,
        })
      }
      return field
    }
    field.setTemplate = (templateId) => {
      field.templateId = templateId
      this._syncRulesetSubjectFromTemplate(field)
      field.widget?.updatePanelCopy?.(this._resolveRulesetPanelCopy(field))
      return field
    }
    field.setTemplateConfig = (config) => {
      field.templateConfig = {...(field.templateConfig ?? {}), ...(config ?? {})}
      return field
    }
    field.setConfigOptionHelp = (partial) => {
      field.configOptionHelp = {...(field.configOptionHelp ?? {}), ...(partial ?? {})}
      return field
    }
    field.setGroupingAvailable = (available) => {
      field.capabilities = {...(field.capabilities ?? {}), grouping: !!available}
      return field
    }
    field.setSortModes = (modes) => {
      field.sortModes = modes
      return field
    }
    field.setCrudOptions = (options) => {
      field.crudOptions = {...(field.crudOptions ?? {}), ...(options ?? {})}
      return field
    }
    field.setAlwaysRefresh = (alwaysRefresh = true) => {
      field.alwaysRefresh = !!alwaysRefresh
      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.getOptimized) {
          void field.getOptimized()
          return
        }
        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|string[]|null|undefined} tags
   * @param {string|string[]|null|undefined} fieldKeys
   * @return {{tags: string[], fieldKeys: string[]}|null}
   */
  createTagsChangeDetail(tags = null, fieldKeys = null)
  {
    let tagList = []
    let seenTags = new Set()
    for (let tag of Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])) {
      let name = String(tag ?? '').trim()
      if (!name || seenTags.has(name)) {
        continue
      }
      seenTags.add(name)
      tagList.push(name)
    }
    let keyList = []
    let seenKeys = new Set()
    for (let key of Array.isArray(fieldKeys) ? fieldKeys : (fieldKeys != null && fieldKeys !== '' ? [fieldKeys] : [])) {
      let fieldKey = String(key ?? '').trim()
      if (!fieldKey || seenKeys.has(fieldKey)) {
        continue
      }
      seenKeys.add(fieldKey)
      keyList.push(fieldKey)
    }
    if (!tagList.length && !keyList.length) {
      return null
    }
    return {tags: tagList, fieldKeys: keyList}
  }

  /**
   * @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} left
   * @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} right
   * @return {{tags: string[], fieldKeys: string[]}|null}
   */
  mergeChangeDetails(left, right)
  {
    if (!left) {
      return right ? this.createTagsChangeDetail(right.tags, right.fieldKeys) : null
    }
    if (!right) {
      return this.createTagsChangeDetail(left.tags, left.fieldKeys)
    }
    return this.createTagsChangeDetail(
        [...(left.tags ?? []), ...(right.tags ?? [])],
        [...(left.fieldKeys ?? []), ...(right.fieldKeys ?? [])],
    )
  }

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

  /**
   * @param {string} source
   * @param {boolean} local
   * @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
   * @return {void}
   * @private
   */
  _notifyConfigurationChange(source = 'all', local = true, detail = null)
  {
    this.notifyConfigurationChange(source, local, detail)
  }

  /**
   * @param {ConfigurationField} field
   * @return {ConfigurationField}
   * @private
   */
  _overlayFieldFromCache(field)
  {
    if (!field || !this._storageReady) {
      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._storageReady) {
      let cached = this._getCachedSetting(field.key)
      if (cached) {
        return cached.value
      }
    }
    return field.value
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _refreshSettingCacheFromIdb()
  {
    if (!this._storageReady) {
      return
    }
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET) {
        let compiled = await this._compileRulesetFieldFromIdb(fieldKey)
        this._cacheSetting(fieldKey, compiled?.rawLines ?? [], compiled?.optimized ?? null)
        continue
      }
      if (field.persist === false) {
        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) {
        if (field.type === CONFIG_TYPE_RULESET) {
          continue
        }
        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 {ConfigurationField} field
   * @return {{help: *}}
   * @private
   */
  _fieldHelpLabelOptions(field)
  {
    return {
      help: field.help ?? '',
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _warnIfMissingHelp(field)
  {
    if (field.help != null && field.help !== '') {
      return
    }
    console.warn('[BrazenCM] Missing help for field:', field.key)
  }

  /**
   * @param {string[]} rules
   * @return {string[]}
   * @private
   */
  _deduplicateRulesetRules(rules)
  {
    if (!Array.isArray(rules)) {
      return rules
    }
    if (!rules.length) {
      return 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 {ConfigurationField} field
   * @return {string}
   * @private
   */
  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _resolveRulesetTemplateId(field)
  {
    let seed = this._fieldSeeds.get(field.key)
    if (seed?.templateId) {
      return seed.templateId
    }
    let spec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
        RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
    if (spec?.templateId) {
      return spec.templateId
    }
    if (field.templateId) {
      return field.templateId
    }
    return field._rulesetMain?.templateId ?? ''
  }

  /**
   * @param {ConfigurationField} field
   * @return {void}
   * @private
   */
  _syncRulesetSubjectFromTemplate(field)
  {
    if (field._rulesetSubjectExplicit) {
      return
    }
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (template?.subject) {
      field.rulesetSubject = template.subject
      return
    }
    let spec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
        RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
    if (spec?.rulesetSubject) {
      field.rulesetSubject = spec.rulesetSubject
      return
    }
    delete field.rulesetSubject
  }

  /**
   * @param {ConfigurationField} field
   * @return {string}
   * @private
   */
  _resolveRulesetSubject(field)
  {
    if (field._rulesetSubjectExplicit && field.rulesetSubject) {
      return String(field.rulesetSubject).trim().toLowerCase() || 'rule'
    }
    this._syncRulesetSubjectFromTemplate(field)
    if (field.rulesetSubject) {
      return String(field.rulesetSubject).trim().toLowerCase() || 'rule'
    }
    return 'rule'
  }

  /**
   * @param {ConfigurationField} field
   * @return {{subject: string, plural: string, emptyMessage: string, noMatchMessage: string, searchPlaceholder: string, sortHelpText: string, addEntryCaption: string, submitCaption: string}}
   * @private
   */
  _resolveRulesetPanelCopy(field)
  {
    let subject = this._resolveRulesetSubject(field)
    let plural = pluralizeRulesetSubject(subject)
    let subjectTitle = titleCaseRulesetSubject(subject)
    let pluralTitle = titleCaseRulesetSubject(plural)
    return {
      subject,
      plural,
      emptyMessage: `No ${plural} yet.`,
      noMatchMessage: `No matching ${plural}.`,
      searchPlaceholder: `Search ${plural}…`,
      sortHelpText: field.sortHelpText ?? `Sort ${plural}`,
      addEntryCaption: `Add ${subjectTitle}`,
      createDetailTitle: `${field.title ? `${field.title} - ` : ''}Add ${pluralTitle}`,
      submitCaption: `Add ${pluralTitle}`,
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {'create'|'edit'} mode
   * @return {string}
   * @private
   */
  _resolveRulesetDetailTitle(field, mode)
  {
    let copy = this._resolveRulesetPanelCopy(field)
    let prefix = field.title ? `${field.title} - ` : ''
    if (mode === 'edit') {
      return `${prefix}Edit ${titleCaseRulesetSubject(copy.subject)}`
    }
    return `${prefix}Add ${titleCaseRulesetSubject(copy.plural)}`
  }

  /**
   * @param {ConfigurationField} field
   * @return {{fieldKey: string, templateId: string, templateConfig: object, config: object, repos: *}}
   * @private
   */
  _buildRulesetTemplateCtx(field)
  {
    let storedConfig = field._rulesetMain?.config ?? field.config ?? {}
    return {
      fieldKey: field.key,
      templateId: field.templateId ?? field._rulesetMain?.templateId ?? '',
      templateConfig: field.templateConfig ?? field._rulesetMain?.templateConfig ?? {},
      capabilities: {...(field.capabilities ?? {})},
      config: this._sanitizeRulesetUserConfig(storedConfig),
      repos: this._repos,
      rulesetSubject: this._resolveRulesetSubject(field),
    }
  }

  /**
   * IDB-safe subset of template config (runtime callbacks such as `normalizeLine` stay in RAM only).
   * @param {object|null|undefined} config
   * @return {object}
   * @private
   */
  _serializeRulesetTemplateConfig(config)
  {
    let out = {}
    for (let [key, value] of Object.entries(config ?? {})) {
      if (typeof value === 'function') {
        continue
      }
      out[key] = value
    }
    return out
  }

  /**
   * Merge stored IDB template config with in-memory runtime hooks from app registration.
   * @param {object|null|undefined} stored
   * @param {object|null|undefined} runtime
   * @return {object}
   * @private
   */
  _mergeRulesetTemplateConfig(stored, runtime)
  {
    return {...(stored ?? {}), ...(runtime ?? {})}
  }

  /**
   * @param {string} templateId
   * @param {string} tagName
   * @return {*}
   * @private
   */
  _rulesetTemplateRequiresTagEntryId(templateId)
  {
    return templateId === 'tag-blacklist' || templateId === 'explored-tags' ||
        templateId === 'tag-sole-ignore'
  }

  /**
   * @param {string} templateId
   * @param {string} tagName
   * @return {*}
   * @private
   */
  _rulesetMatchTargetForTag(templateId, tagName)
  {
    if (templateId === 'tag-sole-ignore') {
      return {tagName}
    }
    if (templateId === 'substitution') {
      return {subjectName: tagName}
    }
    if (this._rulesetTemplateRequiresTagEntryId(templateId)) {
      console.warn('[BrazenCM] _rulesetMatchTargetForTag: use _rulesetPayloadForTag with a resolved tag entry for', templateId, tagName)
    }
    return {variant: 'sole', tagName: String(tagName ?? '').trim()}
  }

  /**
   * @param {string} templateId
   * @param {*} tag
   * @return {*}
   * @private
   */
  _rulesetPayloadForTag(templateId, tag)
  {
    if (tag?.entryId == null) {
      console.warn('[BrazenCM] _rulesetPayloadForTag: tag entry missing entryId', templateId, tag)
    }
    if (templateId === 'tag-sole-ignore') {
      return {tagName: tag.name, tagEntryId: tag.entryId}
    }
    return {variant: 'sole', tagName: tag.name, tagEntryId: tag.entryId}
  }

  /**
   * Repair combo-template rows that were persisted before tag entry ids were hydrated.
   * @param {ConfigurationField} field
   * @param {*[]} rows
   * @return {Promise<void>}
   * @private
   */
  async _repairRulesetRowTagEntryIds(field, rows)
  {
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template) {
      return
    }
    if (templateId === 'tag-sole-ignore') {
      for (let row of rows) {
        let payload = row?.payload
        if (!payload?.tagName) {
          continue
        }
        let tag = await this._repos.tagRuntime.ensureTag(payload.tagName)
        if (tag.entryId == null) {
          continue
        }
        let needsRepair = payload.tagEntryId == null || payload.tagName !== tag.name
        if (!needsRepair) {
          continue
        }
        let repaired = {...payload, tagEntryId: tag.entryId, tagName: tag.name}
        let formatted = template.formatForUI(repaired)
        let updated = {
          ...row,
          payload: repaired,
          rawLine: formatted.rawLine ?? row.rawLine,
        }
        await this._repos.rulesetEntries.update(updated)
        row.payload = repaired
        row.rawLine = updated.rawLine
      }
      return
    }
    if (!this._rulesetTemplateRequiresTagEntryId(templateId)) {
      return
    }
    for (let row of rows) {
      let payload = row?.payload
      if (!payload) {
        continue
      }
      let needsSoleRepair = payload.variant === 'sole' && payload.tagName && payload.tagEntryId == null
      let needsComboRepair = payload.variant === 'combo' && payload.tagNames?.length && !payload.tagEntryIds?.length
      if (!needsSoleRepair && !needsComboRepair) {
        continue
      }
      console.warn('[BrazenCM] repairing ruleset row missing tag entry ids', field.key, row.entryId, payload)
      let repaired = {...payload}
      if (needsSoleRepair) {
        let tag = await this._repos.tagRuntime.ensureTag(repaired.tagName)
        repaired.tagEntryId = tag.entryId
        repaired.tagName = tag.name
      }
      if (needsComboRepair) {
        repaired.tagEntryIds = []
        repaired.tagNames = []
        for (let tagName of payload.tagNames) {
          let tag = await this._repos.tagRuntime.ensureTag(tagName)
          repaired.tagEntryIds.push(tag.entryId)
          repaired.tagNames.push(tag.name)
        }
        if (!repaired.rawLine) {
          repaired.rawLine = repaired.tagNames.join(' & ')
        }
      }
      let formatted = template.formatForUI(repaired)
      let updated = {
        ...row,
        payload: repaired,
        rawLine: formatted.rawLine ?? row.rawLine,
      }
      await this._repos.rulesetEntries.update(updated)
      row.payload = repaired
      row.rawLine = updated.rawLine
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {*} matchTarget
   * @return {*|null}
   * @private
   */
  _findRulesetRowForTarget(field, matchTarget)
  {
    let templateId = field.templateId ?? field._rulesetMain?.templateId
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template) {
      return null
    }
    for (let row of field._rulesetRows ?? []) {
      if (template.matchRule(row.payload, matchTarget)) {
        return row
      }
    }
    return null
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName
   * @param {*} [context]
   * @return {Promise<boolean>}
   * @private
   */
  async _toggleRulesetTagRule(fieldKey, tagName, context = null)
  {
    let field = this.getField(fieldKey)
    let main = await this._repos.rulesetFields.get(fieldKey)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(main?.templateId) : null
    if (!field || !main?.templateId || !template) {
      return false
    }
    let normalized = String(tagName ?? '').trim()
    if (!normalized) {
      return false
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    this._repos.tagRuntime?.primeOptimisticEntry?.(normalized)
    let currentlyActive = this.hasTagSoleAttribute(fieldKey, normalized)
    let tagDetail = this.createTagsChangeDetail(normalized, fieldKey)
    let previousRows = (field._rulesetRows ?? []).slice()
    let previousOptimized = field.optimized
    let optimisticEntryId = null
    let syncRemovedEntryId = null

    if (currentlyActive) {
      let syncExisting = this._findSoleAttributeRowForToggle(field, main.templateId, normalized)
      if (syncExisting?.entryId != null) {
        syncRemovedEntryId = syncExisting.entryId
        field.removeRow(syncExisting.entryId)
      }
      this._patchOptimisticIgnoreCompiled(field, main.templateId, normalized, false)
    } else {
      optimisticEntryId = typeof crypto !== 'undefined' && crypto.randomUUID ?
          crypto.randomUUID() : `optimistic-${Date.now()}`
      let formatted = template.formatForUI?.({tagName: normalized}) ?? {rawLine: normalized}
      field.patchRow({
        entryId: optimisticEntryId,
        payload: {tagName: normalized, tagEntryId: null, _optimistic: true},
        rawLine: formatted.rawLine ?? normalized,
        sortOrder: Date.now(),
        _optimistic: true,
      })
      this._patchOptimisticIgnoreCompiled(field, main.templateId, normalized, true)
    }
    this.notifyConfigurationChange('tags', true, tagDetail)

    try {
      let tag = await this._repos.tagRuntime.ensureTag(normalized, context)
      if (tag.entryId == null) {
        console.warn('[BrazenCM] _toggleRulesetTagRule: ensureTag returned no entryId', fieldKey, normalized)
        throw new Error('_toggleRulesetTagRule: ensureTag returned no entryId')
      }
      let matchTarget = this._rulesetPayloadForTag(main.templateId, tag)
      let existing = this._findRulesetRowForTarget(field, matchTarget)
      if (!existing || existing._optimistic) {
        let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
        existing = rows.find((row) => template.matchRule(row.payload, matchTarget)) ?? null
      }
      let mutationEntryIds = []
      let sorted = false
      if (currentlyActive) {
        if (existing?.entryId != null && !existing._optimistic) {
          await this._repos.rulesetEntries.remove(existing.entryId)
          field.removeRow(existing.entryId)
          mutationEntryIds = [existing.entryId]
        }
      } else {
        if (optimisticEntryId != null) {
          field._rulesetRows = (field._rulesetRows ?? []).filter((entry) => entry.entryId !== optimisticEntryId)
        }
        if (!existing || existing._optimistic) {
          let row = await template.persist(matchTarget, ctx)
          field.patchRow(row)
          if (row?.entryId != null) {
            mutationEntryIds = [row.entryId]
          }
          sorted = await this._applyRulesetAutoSortIfEnabled(field)
        }
      }
      if (!sorted && typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, fieldKey)
      }
      if (!sorted) {
        await field.getOptimized()
      }
      if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
        await this.refreshTagComplianceSpecs([fieldKey])
      }
      this._refreshRulesetFieldUiIfClean(field)
      RulesetMutationBus.notify({
        fieldKeys: [fieldKey],
        entryIds: mutationEntryIds.length ? mutationEntryIds : (syncRemovedEntryId != null ? [syncRemovedEntryId] : []),
        tagNames: [normalized],
      })
      await this._commitTagFieldChange(tagDetail)
      return true
    } catch (error) {
      field._rulesetRows = previousRows
      field.optimized = previousOptimized
      this.notifyConfigurationChange('tags', true, tagDetail)
      console.log('[BrazenCM] _toggleRulesetTagRule failed:', error)
      throw error
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {'create'|'edit'} mode
   * @param {*} [entry]
   * @param {HTMLFormElement} form
   * @return {Promise<void>}
   * @private
   */
  async _submitRulesetDetailForm(field, mode, entry, form)
  {
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template) {
      return
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    let values = Object.fromEntries(new FormData(form).entries())
    let payload = template.translateFromUI(values, ctx)
    if (!payload) {
      return
    }
    let groupLabel = String(values.groupLabel ?? '').trim() || null
    let row = await template.persist(payload, ctx, {
      entryId: mode === 'edit' ? entry?.entryId : null,
      comment: String(values.comment ?? '').trim(),
      groupLabel,
    })
    field.patchRow(row)
    if (!await this._applyRulesetAutoSortIfEnabled(field)) {
      if (typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, field.key)
      }
      await field.getOptimized()
    }
    if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([field.key])
    }
    RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [row.entryId]})
    if (this.isTagRegistryField(field.key)) {
      let tagNames = this._collectRulesetEntryTagNames(field, row)
      if (mode === 'edit' && entry) {
        tagNames = [...this._collectRulesetEntryTagNames(field, entry), ...tagNames]
      }
      await this._commitTagFieldChange(this.createTagsChangeDetail(tagNames, field.key))
    } else {
      this.notifyConfigurationChange(field.key, true)
    }
    BrazenViewLayer.closeSettingsDetailPane()
    field.updateUserInterface()
  }

  /**
   * @param {ConfigurationField} field
   * @param {HTMLFormElement[]} forms
   * @return {Promise<void>}
   * @private
   */
  async _submitRulesetDetailForms(field, forms)
  {
    let templateId = this._resolveRulesetTemplateId(field)
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(templateId) : null
    if (!template || !Array.isArray(forms) || !forms.length) {
      return
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    let pending = []
    for (let form of forms) {
      let values = Object.fromEntries(new FormData(form).entries())
      let payload = template.translateFromUI(values, ctx)
      if (!payload) {
        continue
      }
      pending.push({
        payload,
        groupLabel: String(values.groupLabel ?? '').trim() || null,
        comment: String(values.comment ?? '').trim(),
      })
    }
    if (!pending.length) {
      return
    }
    let useAutoSort = !!field.config?.autoSort
    let maxSortOrder = Math.max(0, ...(field._rulesetRows ?? []).map((row) => row.sortOrder ?? 0))
    let sortOrder = maxSortOrder + 1
    let rows = []
    for (let item of pending) {
      let persistMeta = {
        comment: item.comment,
        groupLabel: item.groupLabel,
      }
      if (!useAutoSort) {
        persistMeta.sortOrder = sortOrder++
      }
      let row = await template.persist(item.payload, ctx, persistMeta)
      field.patchRow(row)
      rows.push(row)
    }
    if (useAutoSort) {
      await this._applyRulesetAutoSortIfEnabled(field)
    } else if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, field.key)
    }
    if (!useAutoSort) {
      await field.getOptimized()
    }
    if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([field.key])
    }
    RulesetMutationBus.notify({
      fieldKeys: [field.key],
      entryIds: rows.map((row) => row.entryId).filter((entryId) => entryId != null),
    })
    if (this.isTagRegistryField(field.key)) {
      let tagNames = rows.flatMap((row) => this._collectRulesetEntryTagNames(field, row))
      await this._commitTagFieldChange(this.createTagsChangeDetail(tagNames, field.key))
    } else {
      this.notifyConfigurationChange(field.key, true)
    }
    BrazenViewLayer.closeSettingsDetailPane()
    field.updateUserInterface()
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{rawLines: string[], optimized: *}>}
   * @private
   */
  async _compileRulesetFieldFromIdb(fieldKey)
  {
    try {
      let main = await this._repos.rulesetFields.get(fieldKey)
      if (main?.templateId) {
        let compiled = await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos)
        if (compiled) {
          return compiled
        }
      }
    } catch (error) {
      console.log('[BrazenCM] compileRulesetField failed:', error)
    }
    let stored = await this._repos.settings.getField(fieldKey)
    let field = this._config[fieldKey]
    let value = stored?.value ?? field?.value ?? []
    let rawLines = Array.isArray(value) ? value.map(String) : []
    let optimized = stored?.optimized ?? this._computeFieldOptimized(fieldKey, rawLines)
    return {rawLines, optimized}
  }

  /**
   * Collect tag names from visible ruleset rows for TagRuntime warm-before-paint.
   * @param {ConfigurationField} field
   * @param {string} templateId
   * @param {*[]} entries
   * @return {string[]}
   * @private
   */
  _collectRulesetTagNamesForRender(field, templateId, entries = [])
  {
    let names = new Set()
    let ctx = {
      ...this._buildRulesetTemplateCtx(field),
      tokenize: field.templateConfig?.tokenize ?? field.attributeActionsConfig?.tokenize ?? null,
      normalizeTag: field.templateConfig?.normalizeTag ?? field.attributeActionsConfig?.normalize ?? null,
    }
    for (let entry of entries ?? []) {
      if (typeof collectRulesetWarmTagNames !== 'function') {
        continue
      }
      for (let name of collectRulesetWarmTagNames(entry, templateId, ctx)) {
        if (name) {
          names.add(name)
        }
      }
    }
    return [...names]
  }

  /**
   * Tag names implicated by a ruleset row (sidebar / discovery chrome refresh).
   * @param {ConfigurationField} field
   * @param {*} entry
   * @return {string[]}
   * @private
   */
  _collectRulesetEntryTagNames(field, entry)
  {
    if (!field || !entry) {
      return []
    }
    return this._collectRulesetTagNamesForRender(field, this._resolveRulesetTemplateId(field), [entry])
  }

  /**
   * @param {string} fieldKey
   * @param {{query?: string, groupQuery?: string|null, cursor?: number|null}} pageOptions
   * @return {Promise<{entries: *[], nextCursor: *}>}
   * @private
   */
  async _loadRulesetPage(fieldKey, pageOptions = {})
  {
    let query = String(pageOptions.query ?? '').trim()
    let groupQuery = String(pageOptions.groupQuery ?? '').trim()
    let cursor = pageOptions.cursor ?? null
    if (query || groupQuery) {
      return this._repos.rulesetEntries.search(fieldKey, query, {
        groupLabel: groupQuery || null,
        cursor,
        limit: 50,
      })
    }
    if (cursor != null) {
      return this._repos.rulesetEntries.search(fieldKey, '', {cursor, limit: 50})
    }
    return this._repos.rulesetEntries.listInitial(fieldKey, 50)
  }

  /**
   * @param {ConfigurationField} field
   * @param {number} entryId
   * @return {Promise<void>}
   * @private
   */
  async _removeRulesetEntry(field, entryId)
  {
    let removedEntry = (field._rulesetRows ?? []).find((entry) => entry.entryId === entryId) ?? null
    await this._repos.rulesetEntries.remove(entryId)
    field.removeRow(entryId)
    if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([field.key])
    }
    await field.getOptimized()
    field.updateUserInterface?.()
    RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [entryId]})
    if (this.isTagRegistryField(field.key)) {
      await this._commitTagFieldChange(this.createTagsChangeDetail(
          this._collectRulesetEntryTagNames(field, removedEntry), field.key))
    } else {
      this.notifyConfigurationChange(field.key, true)
    }
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} groupLabel
   * @return {Promise<void>}
   * @private
   */
  async _removeRulesetGroup(field, groupLabel)
  {
    await this._repos.rulesetEntries.removeGroup(field.key, groupLabel)
    await field.reload()
    field.updateUserInterface()
    RulesetMutationBus.notify({fieldKeys: [field.key]})
    this.notifyConfigurationChange(field.key, true)
  }

  /**
   * @param {string} fieldKey
   * @param {string} ruleLabel
   * @param {function(string): string} normalizeRuleLine
   * @return {boolean}
   */
  matchesTagRuleLabel(fieldKey, ruleLabel, normalizeRuleLine)
  {
    let field = this.getField(fieldKey)
    if (!field || typeof normalizeRuleLine !== 'function') {
      return false
    }
    let lines = Array.isArray(field.value) ? field.value : []
    return lines.some((line) => this._tagRuleLabelMatchesLine(ruleLabel, line, normalizeRuleLine))
  }

  /**
   * @param {string} ruleLabel
   * @param {string} line
   * @param {function(string): string} normalizeRuleLine
   * @return {boolean}
   * @private
   */
  _tagRuleLabelMatchesLine(ruleLabel, line, normalizeRuleLine)
  {
    let normalizedLabel = normalizeRuleLine(ruleLabel)
    let rulePart = line
    let commentIdx = line.indexOf(' // ')
    if (commentIdx >= 0) {
      rulePart = line.slice(0, commentIdx)
    }
    let normalizedRulePart = normalizeRuleLine(rulePart)
    if (normalizedRulePart === normalizedLabel) {
      return true
    }
    if (normalizedRulePart.includes(' | ')) {
      return normalizedRulePart.split(/\s*\|\s*/).includes(normalizedLabel)
    }
    return false
  }

  /**
   * Remove a ruleset row matching an Active Hide Rules label.
   * @param {string} fieldKey
   * @param {string} ruleLabel
   * @param {function(string): string} normalizeRuleLine
   * @return {Promise<boolean>}
   */
  async removeTagRuleByLabel(fieldKey, ruleLabel, normalizeRuleLine)
  {
    let field = this.getField(fieldKey)
    if (!field || typeof normalizeRuleLine !== 'function') {
      return false
    }
    await field.getOptimized?.()
    let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
    let match = rows.find((row) => this._tagRuleLabelMatchesLine(ruleLabel, row.rawLine ?? '', normalizeRuleLine))
    if (!match?.entryId) {
      return false
    }
    await this._removeRulesetEntry(field, match.entryId)
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, fieldKey)
    }
    if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([fieldKey])
    }
    await this._commitTagFieldChange(this.createTagsChangeDetail(null, fieldKey))
    return true
  }

  /**
   * Compile all registered ruleset fields (replaces legacy tagRuleSets compile).
   * @param {string[]} [fieldKeys]
   * @return {Promise<void>}
   */
  async ensureRulesetFieldsCompiled(fieldKeys = null, onProgress = null)
  {
    let keys = fieldKeys ?? RULESET_MIGRATION_FIELD_SPECS.map((spec) => spec.fieldKey)
    let compileKeys = keys.filter((fieldKey) => !!this._config[fieldKey])
    for (let index = 0; index < compileKeys.length; index++) {
      let fieldKey = compileKeys[index]
      if (typeof onProgress === 'function') {
        await onProgress({
          phase: 'ruleset-compile',
          label: 'Compiling rules…',
          detail: fieldKey,
          current: index,
          total: compileKeys.length,
        })
      }
      await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos)
      let field = this._config[fieldKey]
      if (field?.getOptimized) {
        await field.getOptimized()
      }
    }
    let meta = await this._repos.meta.get()
    this._bootRulesetCompiledRevisionId = meta?.revisionId ?? null
  }

  /**
   * @param {ConfigurationField} field
   * @param {object} partial
   * @return {Promise<void>}
   * @private
   */
  async _updateRulesetFieldConfig(field, partial)
  {
    let sanitizedPartial = {}
    for (let key of RULESET_USER_CONFIG_KEYS) {
      if (partial?.[key] !== undefined) {
        sanitizedPartial[key] = partial[key]
      }
    }
    let previousConfig = {...(field.config ?? {})}
    this._applyRulesetUserConfig(field, {...(field.config ?? {}), ...sanitizedPartial})
    let main = field._rulesetMain ?? await this._repos.rulesetFields.get(field.key)
    await this._repos.rulesetFields.upsert({
      fieldKey: field.key,
      templateId: field.templateId ?? main?.templateId ?? '',
      templateConfig: this._serializeRulesetTemplateConfig(field.templateConfig ?? main?.templateConfig ?? {}),
      config: field.config,
    })
    let shouldResort = field.config.autoSort &&
        (sanitizedPartial.autoSort === true ||
            (sanitizedPartial.sortMode !== undefined && previousConfig.sortMode !== field.config.sortMode))
    if (shouldResort) {
      await this._applyRulesetAutoSortIfEnabled(field)
    }
    field.updateUserInterface()
    this.notifyConfigurationChange(field.key, true)
  }

  /**
   * @param {ConfigurationField} field
   * @return {Promise<boolean>}
   * @private
   */
  async _applyRulesetAutoSortIfEnabled(field)
  {
    if (!field?.config?.autoSort) {
      return false
    }
    if (typeof applyRulesetFieldSort !== 'function') {
      return false
    }
    await applyRulesetFieldSort(this._repos, field.key, field.config.sortMode ?? 'natural-asc')
    await field.reload({skipAutoSortRepair: true})
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, field.key)
    }
    await field.getOptimized()
    return true
  }

  /**
   * @param {{fieldKeys?: string[], tagNames?: string[], entryIds?: number[]}} detail
   * @return {Promise<void>}
   * @private
   */
  async _handleRulesetMutation(detail = {})
  {
    let keys = detail.fieldKeys ?? []
    if (!keys.length && detail.tagNames?.length) {
      keys = Object.keys(this._config).filter((fieldKey) => this._config[fieldKey].type === CONFIG_TYPE_RULESET)
    }
    for (let fieldKey of keys) {
      let field = this._config[fieldKey]
      if (!field || field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      if (detail.entryIds?.length && field.patchRow && !field.alwaysRefresh && !field._shouldRefreshWidget?.()) {
        for (let entryId of detail.entryIds) {
          let row = await this._repos.storage.get(IDB_STORE_RULESET_ENTRIES, entryId)
          if (row && row.fieldKey === fieldKey) {
            field.patchRow(row)
          } else if (row == null) {
            field.removeRow(entryId)
          }
        }
        await field.getOptimized()
        field.updateUserInterface?.()
        continue
      }
      await field.reload()
      field.updateUserInterface()
    }
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _refreshRulesetFieldUiIfClean(field)
  {
    if (field?.type !== CONFIG_TYPE_RULESET) {
      return
    }
    if (!field.widget) {
      return
    }
    if (field.alwaysRefresh || field._shouldRefreshWidget?.()) {
      void field.reload().then(() => field.updateUserInterface())
    }
  }

  // -------------------------------------------------------------------------
  // 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, this._fieldHelpLabelOptions(field))
      return field.element
    }
    field.setFromUserInterface = () => {
      field.value = []
      for (let input of field.element.querySelectorAll('input:checked')) {
        field.value.push(input.getAttribute('data-value'))
      }
    }
    field.updateUserInterface = () => {
      for (let key of field.value) {
        let input = field.element.querySelector('input[data-value="' + key + '"]')
        if (input) {
          input.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', this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.value
    }
    field.updateUserInterface = () => {
      field.element.value = 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', this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.checked
    }
    field.updateUserInterface = () => {
      if (field.element) {
        field.element.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
    /**
     * Bounded positive membership cache (recent claims + primed page hits).
     * Never holds the full ledger — membership is IndexedDB `postId` index lookups.
     * @type {Set<string>}
     */
    field.downloadedIds = new Set()
    /** @type {string[]} */
    field._ledgerCacheOrder = []

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

    let rememberHit = (id) => {
      if (!id || field.downloadedIds.has(id)) {
        return
      }
      field.downloadedIds.add(id)
      field._ledgerCacheOrder.push(id)
      let max = (typeof LEDGER_POSITIVE_CACHE_MAX === 'number') ? LEDGER_POSITIVE_CACHE_MAX : 16384
      while (field._ledgerCacheOrder.length > max) {
        let evict = field._ledgerCacheOrder.shift()
        field.downloadedIds.delete(evict)
      }
    }

    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
    }

    /**
     * Invalidates the bounded positive cache. Does **not** load the ledger into RAM.
     * @return {Promise<ConfigurationField>}
     */
    field.reload = async () => {
      field.downloadedIds = new Set()
      field._ledgerCacheOrder = []
      return field
    }

    /**
     * Batch-prime membership for page tiles / queue ids into the positive cache.
     * @param {Iterable<string|number|null|undefined>} ids
     * @return {Promise<void>}
     */
    field.primeHits = async (ids) => {
      let unique = []
      let seen = new Set()
      for (let raw of ids) {
        if (raw === null || raw === undefined) {
          continue
        }
        let id = String(raw).trim()
        if (!id || seen.has(id) || field.downloadedIds.has(id)) {
          continue
        }
        seen.add(id)
        unique.push(id)
      }
      if (!unique.length) {
        return
      }
      await cm._ensureStorageReady()
      let hits = await cm._repos.ledger.hasMany(unique)
      for (let id of hits) {
        rememberHit(id)
      }
    }

    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
      }
      await cm._ensureStorageReady()
      for (let id of ids) {
        if (!isValidId(id)) {
          continue
        }
        if (!(await cm._repos.ledger.claim(id))) {
          return false
        }
        rememberHit(id)
      }
      return true
    }

    field.has = async (id) => {
      if (id === null || id === undefined) {
        return false
      }
      id = String(id).trim()
      if (field.downloadedIds.has(id)) {
        return true
      }
      await cm._ensureStorageReady()
      let hit = await cm._repos.ledger.has(id)
      if (hit) {
        rememberHit(id)
      }
      return hit
    }

    field.merge = async (registry) => {
      if (!registry || typeof registry !== 'object') {
        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)
      for (let id of ids) {
        rememberHit(id)
      }
    }

    field.serializeForBackup = async () => {
      await cm._ensureStorageReady()
      let rows = await cm._repos.storage.getAll(IDB_STORE_LEDGER)
      let ids = rows.map((row) => String(row.postId).trim()).filter(Boolean)
      return ids.length ? {[field._ledgerPrimaryField()]: ids} : null
    }

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

    field.createElement = () => {
      field.element = document.createElement('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', this._fieldHelpLabelOptions(field))
      let input = inputGroup.querySelector('input')
      input.setAttribute('min', String(field.minimum))
      input.setAttribute('max', String(field.maximum))
      field.element = input
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = Number.parseInt(field.element.value.toString())
    }
    field.updateUserInterface = () => {
      field.element.value = 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, this._fieldHelpLabelOptions(field))
      field.element = inputGroup
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let checked = field.element.querySelector('input:checked')
      field.value = checked ? checked.getAttribute('data-value') : null
    }
    field.updateUserInterface = () => {
      let input = field.element.querySelector('input[data-value="' + field.value + '"]')
      if (input) {
        input.checked = true
        input.dispatchEvent(new Event('change', {bubbles: true}))
      }
    }
    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,
          this._fieldHelpLabelOptions(field))
      field.element = inputGroup
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let inputs = field.element.querySelectorAll('.bv-range-inputs input, :scope > .bv-input.bv-text')
      field.value = {
        minimum: Number.parseInt(inputs[0]?.value, 10) || 0,
        maximum: Number.parseInt(inputs[inputs.length - 1]?.value, 10) || 0,
      }
    }
    field.updateUserInterface = () => {
      let inputs = field.element.querySelectorAll('.bv-range-inputs input, :scope > .bv-input.bv-text')
      if (inputs[0]) {
        inputs[0].value = field.value.minimum ?? 0
      }
      if (inputs.length > 1) {
        inputs[inputs.length - 1].value = field.value.maximum ?? 0
      }
    }
    return field
  }

  /**
   * @param {ConfigurationField} field
   * @return {*}
   * @private
   */
  _mountRulesetPanelWidget(field)
  {
    this._syncRulesetSubjectFromTemplate(field)
    let panelCopy = this._resolveRulesetPanelCopy(field)
    let templateId = this._resolveRulesetTemplateId(field)
    let attrConfig = field.attributeActionsConfig
    let host = attrConfig?.host
    field.widget = this._uiGen.createRulesetPanel({
      title: field.title ?? '',
      help: field.help ?? '',
      fieldKey: field.key,
      addEntryCaption: panelCopy.addEntryCaption,
      createDetailTitle: panelCopy.createDetailTitle,
      emptyMessage: panelCopy.emptyMessage,
      noMatchMessage: panelCopy.noMatchMessage,
      searchPlaceholder: panelCopy.searchPlaceholder,
      sortHelpText: panelCopy.sortHelpText,
      config: field.config,
      sortModes: field.sortModes,
      crudOptions: {
        create: field.showAddButton === false ? false : (field.crudOptions?.create !== false),
        edit: templateId === 'bookmarks' ?
            false :
            (field.crudOptions?.edit !== false),
        delete: field.crudOptions?.delete !== false,
      },
      configControls: {
        autoSort: true,
        sortMode: true,
        groupingEnabled: field.capabilities?.grouping ? true : 'hidden',
        hideTagTypes: true,
      },
      configOptionHelp: field.configOptionHelp ?? {},
      pageMatch: field.pageMatch ?? null,
      onSort: templateId === 'bookmarks' ?
          null :
          (field.onSort ? () => Utilities.callEventHandler(field.onSort, []) : null),
      loadPage: async (pageOptions = {}) => {
        let query = String(pageOptions.query ?? '').trim()
        let groupQuery = String(pageOptions.groupQuery ?? '').trim()
        if (!query && !groupQuery && pageOptions.cursor == null && field.config?.autoSort &&
            typeof applyRulesetFieldSort === 'function') {
          await applyRulesetFieldSort(this._repos, field.key, field.config.sortMode ?? 'natural-asc')
        }
        let page = await this._loadRulesetPage(field.key, pageOptions)
        if (!query && !groupQuery && pageOptions.cursor == null) {
          field._rulesetRows = page.entries ?? []
          field._rulesetNextCursor = page.nextCursor ?? null
        }
        return page
      },
      renderEntryContent: (entry, renderOptions = {}) => {
        let template = typeof RulesetTemplateRegistry !== 'undefined' ?
            RulesetTemplateRegistry.get(templateId) : null
        if (template?.renderRowContent) {
          let baseCtx = this._buildRulesetTemplateCtx(field)
          let ctx = {
            ...baseCtx,
            config: {
              ...baseCtx.config,
              hideTagTypes: !!renderOptions.hideTagTypes,
            },
            tokenize: field.templateConfig?.tokenize ?? attrConfig?.tokenize ?? null,
            normalizeTag: field.templateConfig?.normalizeTag ?? attrConfig?.normalize ?? null,
            onNavigate: (url) => {
              if (field.onNavigate) {
                field.onNavigate(url)
              } else {
                location.href = url
              }
            },
          }
          return template.renderRowContent(entry, ctx)
        }
        return entry.rawLine ?? ''
      },
      getRowActions: attrConfig ? (entry) => {
        let row = {
          entryId: entry.entryId,
          label: entry?.payload?.label ?? entry?.rawLine ?? '',
          tags: entry?.payload?.tags ?? '',
          url: entry?.payload?.url ?? '',
        }
        let frameworkActions = []
        if (host && typeof host.createBookmarkRowAttributeActions === 'function') {
          frameworkActions = host.createBookmarkRowAttributeActions(row, attrConfig) ?? []
        }
        let consumerActions = Utilities.callEventHandler(field.getRowActions, [row], []) ?? []
        return [...frameworkActions, ...consumerActions]
      } : () => [],
      onBeforeRender: (entries) => {
        let warmTagNames = this._collectRulesetTagNamesForRender(field, templateId, entries)
        let warmPromise = warmTagNames.length && this._repos?.tagRuntime?.ensureNames ?
            this._repos.tagRuntime.ensureNames(warmTagNames) :
            Promise.resolve()
        if (attrConfig) {
          let rows = (entries ?? []).map((entry) => ({
            entryId: entry.entryId,
            label: entry?.payload?.label ?? entry?.rawLine ?? '',
            tags: entry?.payload?.tags ?? '',
            url: entry?.payload?.url ?? '',
          }))
          let runConsumer = () => Utilities.callEventHandler(field.onBeforeRender, [rows])
          if (!host || typeof host.prepareBookmarkRowAttributeData !== 'function') {
            return warmPromise.then(() => runConsumer())
          }
          return warmPromise.then(() => host.prepareBookmarkRowAttributeData(rows, {
            tokenize: attrConfig.tokenize,
            normalize: attrConfig.normalize,
            attemptedKey: attrConfig.attemptedKey ?? field.key,
            blacklist: attrConfig.blacklist,
            explore: attrConfig.explore,
          }).then(() => runConsumer()))
        }
        return warmPromise.then(() => Utilities.callEventHandler(field.onBeforeRender, [entries]))
      },
      onAddGroup: () => {
        let groups = [...new Set((field._rulesetRows ?? []).map((row) => row.groupLabel).filter(Boolean))]
        let picker = BrazenViewLayer.createGroupCombobox({groups})
        BrazenViewLayer.openSettingsDetailPane({
          title: 'Add group',
          content: Utilities.makeEl('div', {children: [picker]}),
        })
      },
      onAddRule: () => field.openDetailForm('create', {fieldKey: field.key}),
      onEdit: (entry) => field.openDetailForm('edit', {fieldKey: field.key, entry}),
      onRemove: (entryId) => {
        if (field.onRemove) {
          Utilities.callEventHandler(field.onRemove, [entryId])
          return
        }
        void this._removeRulesetEntry(field, entryId)
      },
      onRemoveGroup: (label) => void this._removeRulesetGroup(field, label),
      onConfigChange: (partial) => void this._updateRulesetFieldConfig(field, partial),
      detailFormTemplate: field.detailFormTemplate,
    })
    field.element = field.widget.element
    return field.widget
  }

  /**
   * @param {string} name
   * @return {ConfigurationField}
   */
  addRulesetField(name)
  {
    let field = this._createField(CONFIG_TYPE_RULESET, name, [], null)
    let cm = this
    field.persist = false
    field.widget = null
    field._rulesetRows = []
    field._rulesetNextCursor = null
    field._reloadGeneration = 0
    field._rulesetMain = null
    field.templateId = ''
    field.templateConfig = {}
    field.capabilities = {}
    field.config = {}
    field.configOptionHelp = {}
    field.sortModes = [
      {value: 'natural-asc', label: 'Natural A→Z'},
      {value: 'natural-desc', label: 'Natural Z→A'},
    ]
    field.crudOptions = {create: true, edit: true, delete: true}
    field.alwaysRefresh = false
    field.detailFormTemplate = null
    field.optimized = null
    field._rulesetTagIndex = new Map()

    let rulesetSpec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
        RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
    if (!field.templateId && rulesetSpec?.templateId) {
      field.templateId = rulesetSpec.templateId
    }
    cm._syncRulesetSubjectFromTemplate(field)

    field.rebuildRulesetTagIndex = () => {
      let index = new Map()
      let attrConfig = field.attributeActionsConfig
      let tokenize = attrConfig?.tokenize
      let normalize = attrConfig?.normalize ?? ((value) => String(value ?? '').trim())
      if (typeof tokenize === 'function') {
        for (let entry of field._rulesetRows ?? []) {
          let tokens = tokenize(entry?.payload?.tags ?? '')
          if (!Array.isArray(tokens)) {
            continue
          }
          for (let token of tokens) {
            let tagName = normalize(token)
            if (tagName) {
              index.set(tagName, true)
            }
          }
        }
      }
      field._rulesetTagIndex = index
    }

    field._shouldRefreshWidget = () => {
      if (!field.widget) {
        return false
      }
      if (field.alwaysRefresh) {
        return true
      }
      let active = document.activeElement
      return !!(active && field.widget.element?.contains(active))
    }

    field.getOptimized = async () => {
      let compiled = await cm._compileRulesetFieldFromIdb(field.key)
      field.value = compiled?.rawLines ?? []
      field.optimized = compiled?.optimized ?? null
      cm._cacheSetting(field.key, field.value, field.optimized)
      return field.optimized
    }

    field.reload = async (options = {}) => {
      let generation = ++field._reloadGeneration
      try {
        let main = await cm._repos.rulesetFields.get(field.key)
        if (generation !== field._reloadGeneration) {
          return field
        }
        if (main) {
          field._rulesetMain = main
          field.templateId = cm._resolveRulesetTemplateId(field)
          field.templateConfig = cm._mergeRulesetTemplateConfig(main.templateConfig, field.templateConfig)
          cm._applyRulesetUserConfig(field, main.config)
          cm._syncRulesetSubjectFromTemplate(field)
          field.widget?.updatePanelCopy?.(cm._resolveRulesetPanelCopy(field))
          field.widget?.updateConfig?.(field.config)
        }
        let shouldRepairSort = !options.skipAutoSortRepair &&
            !options.query &&
            !options.groupQuery &&
            options.cursor == null &&
            field.config?.autoSort &&
            typeof applyRulesetFieldSort === 'function'
        if (shouldRepairSort) {
          await applyRulesetFieldSort(cm._repos, field.key, field.config.sortMode ?? 'natural-asc')
        }
        let page
        if (options.query || options.groupQuery || options.cursor != null) {
          page = await cm._loadRulesetPage(field.key, options)
        } else {
          page = await cm._repos.rulesetEntries.listInitial(field.key, 50)
        }
        if (generation !== field._reloadGeneration) {
          return field
        }
        field._rulesetRows = page.entries ?? []
        field._rulesetNextCursor = page.nextCursor ?? null
        await cm._repairRulesetRowTagEntryIds(field, field._rulesetRows)
        await field.getOptimized()
        field.rebuildRulesetTagIndex()
      } catch (error) {
        console.log('Failed to load ruleset field:', error)
      }
      return field
    }

    field.patchRow = (row) => {
      if (!row || row.entryId == null) {
        return field
      }
      let index = field._rulesetRows.findIndex((entry) => entry.entryId === row.entryId)
      if (index >= 0) {
        field._rulesetRows[index] = row
      } else {
        field._rulesetRows.push(row)
      }
      field._rulesetRows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
      if (field._shouldRefreshWidget()) {
        field.updateUserInterface()
      }
      void field.getOptimized()
      return field
    }

    field.removeRow = (entryId) => {
      field._rulesetRows = field._rulesetRows.filter((entry) => entry.entryId !== entryId)
      if (field._shouldRefreshWidget()) {
        field.updateUserInterface()
      }
      void field.getOptimized()
      return field
    }

    field.findRuleIndex = (target) => {
      let templateId = field.templateId ?? field._rulesetMain?.templateId
      let matchTarget
      if (typeof target === 'string') {
        if (cm._rulesetTemplateRequiresTagEntryId(templateId)) {
          let cached = cm.getCachedTagEntry(target)
          if (cached?.entryId == null) {
            return -1
          }
          matchTarget = cm._rulesetPayloadForTag(templateId, cached)
        } else {
          matchTarget = cm._rulesetMatchTargetForTag(templateId, target)
        }
      } else {
        matchTarget = target
      }
      for (let index = 0; index < (field._rulesetRows ?? []).length; index++) {
        let entry = field._rulesetRows[index]
        if (typeof RulesetTemplateRegistry !== 'undefined') {
          let template = RulesetTemplateRegistry.get(templateId)
          if (template?.matchRule?.(entry.payload, matchTarget)) {
            return index
          }
        }
      }
      return -1
    }
    field.hasRule = (target) => field.findRuleIndex(target) >= 0
    field.setRules = () => {
      void field.reload()
    }
    field.addRule = () => {
      void field.reload()
    }
    field.removeRule = () => {
      void field.reload()
    }
    field.toggleRule = () => {
      void field.reload()
    }
    field.clearRules = () => {
      void field.reload()
    }

    field.serializeForBackup = async () => {
      await field.reload()
      let main = field._rulesetMain ?? await cm._repos.rulesetFields.get(field.key)
      let entries = await cm._repos.rulesetEntries.listAllForField(field.key)
      return main || entries.length ? {main, entries} : null
    }

    field.applyFromBackup = async (data) => {
      if (Array.isArray(data)) {
        await cm._importTagFieldFromValue(field.key, data)
        return
      }
      if (!data) {
        return
      }
      if (data.main) {
        await cm._repos.rulesetFields.upsert(data.main)
      } else if (data.config) {
        let main = field._rulesetMain ?? await cm._repos.rulesetFields.get(field.key)
        await cm._repos.rulesetFields.upsert({
          fieldKey: field.key,
          templateId: main?.templateId ?? field.templateId ?? '',
          templateConfig: main?.templateConfig ?? field.templateConfig ?? {},
          config: data.config,
        })
      }
      if (Array.isArray(data.entries)) {
        for (let entry of data.entries) {
          if (entry.entryId != null) {
            await cm._repos.rulesetEntries.update(entry)
          } else {
            await cm._repos.rulesetEntries.add(field.key, entry)
          }
        }
      }
      await field.reload()
      field.widget?.render(field._rulesetRows)
    }

    field.createElement = () => {
      field.widget?.dispose?.()
      if (typeof cm._uiGen.createRulesetPanel !== 'function') {
        field.element = Utilities.makeEl('div', {
          class: 'bv-ruleset-panel bv-ruleset-panel-placeholder',
          text: field.title ?? 'Ruleset',
        })
        void field.reload().then(() => field.updateUserInterface())
        return field.element
      }
      cm._mountRulesetPanelWidget(field)
      field.element = field.widget.element
      void field.reload().then(() => field.updateUserInterface())
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {
      if (!field.widget) {
        return
      }
      field.widget.render(field._rulesetRows, {nextCursor: field._rulesetNextCursor})
      field.widget.checkPageMatch?.()
    }
    field.isCurrentPagePresent = (url) => field.widget?.isCurrentPagePresent?.(url) ?? false
    field.dispose = () => {
      field.widget?.dispose?.()
      field.widget = null
      field.element = null
      field._rulesetRows = []
      field._rulesetNextCursor = null
    }

    if (!field.detailFormTemplate) {
      field.setDetailFormTemplate((mode, ctx) => {
        let template = typeof RulesetTemplateRegistry !== 'undefined' ?
            RulesetTemplateRegistry.get(cm._resolveRulesetTemplateId(field)) : null
        if (!template) {
          return Utilities.makeEl('div', {text: 'Ruleset template unavailable.'})
        }
        let templateCtx = {
          ...cm._buildRulesetTemplateCtx(field),
          existingGroups: [...new Set((field._rulesetRows ?? []).map((row) => row.groupLabel).filter(Boolean))],
          entryComment: mode === 'edit' ? String(ctx.entry?.comment ?? '') : '',
        }
        let panelCopy = cm._resolveRulesetPanelCopy(field)
        if (mode === 'create') {
          return BrazenViewLayer.createRulesetDetailMultiForm({
            addEntryCaption: panelCopy.addEntryCaption,
            submitCaption: panelCopy.submitCaption,
            createEntryForm: () => template.createForm(templateCtx),
            onSubmit: (forms) => void cm._submitRulesetDetailForms(field, forms),
          })
        }
        let form = template.editForm(ctx.entry, templateCtx)
        let wrapped = BrazenViewLayer.wrapRulesetDetailEntry(form)
        let footer = Utilities.makeEl('div', {
          class: 'bv-ruleset-detail-footer',
          children: [
            Utilities.makeEl('button', {
              class: 'bv-button bv-ruleset-detail-submit',
              text: 'Save',
              attrs: {type: 'button', title: 'Save changes'},
              on: {
                click: (event) => {
                  let root = event.currentTarget.closest('.bv-ruleset-detail-single')
                  let formEl = root?.querySelector('form.bv-ruleset-form, .bv-ruleset-form') ?? form
                  void cm._submitRulesetDetailForm(field, mode, ctx.entry, formEl)
                },
              },
            }),
          ],
        })
        return Utilities.makeEl('div', {
          class: 'bv-ruleset-detail-single',
          children: [wrapped, footer],
        })
      })
    }

    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, this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('select')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      field.value = field.element.value
    }
    field.updateUserInterface = () => {
      field.element.value = field.value
      field.element.dispatchEvent(new Event('change', {bubbles: true}))
    }
    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 = () => null
    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', this._fieldHelpLabelOptions(field))
      field.element = inputGroup.querySelector('input')
      return inputGroup
    }
    field.setFromUserInterface = () => {
      let value = field.element.value
      field.value = value === '' ? field.textDefault : value
    }
    field.updateUserInterface = () => {
      field.element.value = 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 null
      }
      field.element = this._uiGen.createFormButton(field.title, typeof field.help === 'string' ? field.help : '', () => {
        Utilities.callEventHandler(field.onClick, [])
      })
      return field.element
    }
    field.setFromUserInterface = () => {}
    field.updateUserInterface = () => {}
    return field
  }

  /**
   * True when any cached ruleset row tokenizes to one of the given tag names.
   * @param {string|string[]} tags
   * @param {string} [fieldKey]
   * @return {boolean}
   */
  rulesetContainsAnyTag(tags, fieldKey = null)
  {
    let names = Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])
    if (!names.length) {
      return false
    }
    for (let field of Object.values(this._config)) {
      if (field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      if (fieldKey && field.key !== fieldKey) {
        continue
      }
      let index = field._rulesetTagIndex
      if (!(index instanceof Map) || !index.size) {
        field.rebuildRulesetTagIndex?.()
        index = field._rulesetTagIndex
      }
      if (!(index instanceof Map)) {
        continue
      }
      for (let name of names) {
        if (name && index.has(name)) {
          return true
        }
      }
    }
    return false
  }


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

  /**
   * Ordered keys of root dock fields that should be mounted under the current include context.
   * Used to decide whether a dock rail relayout is required vs state-only refresh.
   * @return {string}
   */
  getDockRailMembershipSignature()
  {
    return this.getDockRootFields().
        filter((field) => this._evaluateDockInclude(field)).
        map((field) => field.key).
        join('|')
  }

  /**
   * @param {string} name
   * @returns {HTMLElement|null}
   */
  createElement(name)
  {
    let field = this.getFieldOrFail(name)
    if (this._dockActive && field.dock) {
      field.element = null
      return null
    }
    if (this._storageReady) {
      void this._syncFieldFromIdb(this._formatFieldKey(name)).then(() => {
        this._overlayFieldFromCache(field)
        if (field.element) {
          field.updateUserInterface()
        }
      })
    }
    let element = field.createElement()
    this._warnIfMissingHelp(field)
    return element
  }

  /**
   * @param {string} name
   * @return {HTMLElement|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 != null) {
              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.classList.add('bv-dock-slideout-pinned')
        }
      }
    }

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

  /**
   * @param {ConfigurationField} childField
   * @return {HTMLElement|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 {HTMLElement}
   * @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) {
      for (let cls of field.dock.buttonClass.split(/\s+/)) {
        if (cls) {
          button.classList.add(cls)
        }
      }
    }
    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.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 '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, templateName: name}
  }

  /**
   * @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 != null)) {
        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)
  {
    let included = true
    if (field.dock?.include !== undefined) {
      if (typeof field.dock.include === 'function') {
        if (this._dockIncludeContext) {
          included = !!field.dock.include.call(this._dockIncludeContext)
        } else {
          included = !!field.dock.include()
        }
      } else {
        included = !!field.dock.include
      }
    }
    return included
  }

  /**
   * @param {ConfigurationField} field
   * @private
   */
  _refreshDockRootSlot(field)
  {
    let oldNode = field.dockElement
    if (oldNode == null) {
      return
    }
    let newNode = this.createDockElement(field.key)
    if (newNode == null) {
      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 != null) {
        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.classList.contains('bv-dock-slot')) {
      field.dockElement.classList.toggle('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) => {
          if (Array.isArray(rules)) {
            return rules.length > 0
          }
          if (rules && typeof rules === 'object') {
            if (Array.isArray(rules.combos)) {
              return rules.combos.length > 0
            }
            if (typeof rules.length === 'number') {
              return rules.length > 0
            }
          }
          return !!rules
        }
        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._storageReady) {
      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._storageReady) {
      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_RULESET || !field.pageMatch) {
        continue
      }
      tasks.push(field.reload().then(() => field.updateUserInterface()))
    }
    await Promise.all(tasks)
    return this
  }

  /**
   * @return {Promise<BrazenConfigurationManager>}
   */
  async reloadRulesetFields()
  {
    let tasks = []
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      tasks.push(field.reload().then(() => {
        field.updateUserInterface()
      }))
    }
    await Promise.all(tasks)
    return this
  }

  /**
   * Tear down ruleset widgets on unload.
   * @return {void}
   */
  disposeRulesetFields()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_RULESET) {
        continue
      }
      field.dispose?.()
    }
  }

  /**
   * Tear down bookmark widgets (ResizeObserver + pageMatch listeners) on unload.
   * @return {void}
   */
  disposeBookmarkFields()
  {
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type !== CONFIG_TYPE_RULESET || !field.pageMatch) {
        continue
      }
      field.dispose?.()
    }
  }

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

  /**
   * @param {{onMigrationProgress?: function(MigrationProgress): void|Promise<void>, onMigrationStatus?: function(string): void|Promise<void>}|null} [options]
   *   Optional progress callback — invoked during safety backup, peer setup wait, schema rebuild, backfill, and ruleset migration.
   * @return {Promise<BrazenConfigurationManager>}
   */
  async initialize(options = null)
  {
    this._migrationError = null
    if (!this._repos.storage.available) {
      let error = new Error('This script requires IndexedDB and cannot run without it.')
      error.code = 'IDB_UNAVAILABLE'
      throw error
    }

    let report = this._bindMigrationProgress(options?.onMigrationProgress ?? options?.onMigrationStatus ?? null)
    this._reportMigrationProgress = report

    try {
      let wipeFlagKey = this._scriptPrefix + 'pending-idb-wipe'
      let pendingWipe = false
      try {
        pendingWipe = sessionStorage.getItem(wipeFlagKey) === '1'
      } catch (e) {
        pendingWipe = false
      }
      if (pendingWipe) {
        await this._repos.storage.deleteDatabase()
        try {
          sessionStorage.removeItem(wipeFlagKey)
        } catch (e) {
        }
      }

      let schemaConflict = await this.getSchemaVersionConflict()
      if (schemaConflict) {
        let schemaError = new Error(
            'Local database schema v' + schemaConflict.installed +
            ' is newer than this script supports (v' + schemaConflict.supported + ').')
        schemaError.code = 'SCHEMA_TOO_NEW'
        schemaError.schemaConflict = schemaConflict
        throw schemaError
      }

      await this._repos.storage.open()
      let existingMeta = await this._repos.meta.get()
      let migrationNeeded = await this._isMigrationWorkNeeded(existingMeta)

      if (migrationNeeded) {
        let prevCounts = existingMeta?.migrationSafetyBackupCounts
        let backupWasEmpty = prevCounts &&
            !prevCounts.tags && !prevCounts.bookmarks && !prevCounts.ledgerEntries
        let skipBackup = !backupWasEmpty &&
            existingMeta?.migrationSafetyBackupRevisionId === existingMeta?.revisionId &&
            existingMeta?.migrationSafetyBackupAt
        if (skipBackup) {
          let hint = this.getPreMigrationBackupFilenameHint() ??
              this._scriptPrefix + 'pre-migration-backup.zip'
          await report({
            phase: 'safety-backup',
            label: 'Safety backup already saved',
            detail: hint,
            current: 1,
            total: 1,
          })
        } else {
          await report({phase: 'safety-backup', label: 'Creating safety backup…', current: 0, total: 1})
          await this.createPreMigrationSafetyBackup(report)
          await report({phase: 'safety-backup', label: 'Safety backup saved', current: 1, total: 1})
        }
      }

      if (existingMeta?.setupInProgress) {
        await this._waitPeerSetupWithProgress(report)
      }

      let healthy = await this._repos.storage.isHealthy()
      if (!healthy) {
        await report({phase: 'schema-rebuild', label: 'Rebuilding database schema…', indeterminate: true})
        await this._repos.storage.deleteDatabase()
        await this._repos.storage.open()
        await this._runSetupPhase(report)
      } else {
        await this._repos.storage.runPendingIsDiscoveredBackfill(report)
        if (typeof migrateRulesetConfigDefaultsV9 === 'function') {
          await migrateRulesetConfigDefaultsV9(this._repos, report)
        }
        if (typeof migrateRulesetHideTagTypesV10 === 'function') {
          await migrateRulesetHideTagTypesV10(this._repos, report)
        }
        let meta = await this._repos.meta.get()
        if (!meta?.rulesetMigrated) {
          await this._runPostOpenRulesetMigration(report)
        }
        meta = await this._repos.meta.get()
        if (!meta?.bookmarksMigrated && typeof migrateBookmarksToRuleset === 'function') {
          await migrateBookmarksToRuleset(this._repos, report)
        }
        await this._seedAllFields()
      }
      await this._runNormalPhase()
      if (!(await this._repos.storage.isHealthy())) {
        throw new Error('Database health check failed after migration')
      }
    } catch (error) {
      console.log('[BrazenCM] initialize failed:', error)
      this._migrationError = error
      throw error
    } finally {
      this._reportMigrationProgress = null
    }

    return this
  }

  /**
   * @param {function(MigrationProgress|string): Promise<void>} report
   * @return {Promise<void>}
   * @private
   */
  async _runPostOpenRulesetMigration(report)
  {
    if (this._scriptSetupHandler) {
      await report({phase: 'script-seed', label: 'Applying site configuration…', indeterminate: true})
      await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
      return
    }
    if (typeof migrateRulesetFromLegacy === 'function') {
      await migrateRulesetFromLegacy(this._repos, this, report)
    }
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _runSetupPhase(report = null)
  {
    let progress = report ?? (async () => {})
    await progress({phase: 'schema-rebuild', label: 'Initializing database…', current: 0, total: 1})
    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())

    let sources = []
    if (this._legacyDataExists()) {
      await progress({phase: 'legacy-import', label: 'Importing legacy settings…', indeterminate: true})
      let importer = new BrazenLegacyImporter(this._repos, this)
      sources = await importer.run()
    }

    if (this._scriptSetupHandler) {
      await progress({phase: 'script-seed', label: 'Applying site configuration…', indeterminate: true})
      await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
    }

    await this._seedAllFields()
    await this._importTagDomainFromLegacySettings()
    await this._recompileAllTagRuleSets(progress)
    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_LEDGER) {
        await field.reload()
      }
      if (field.type === CONFIG_TYPE_RULESET) {
        await field.reload()
      }
    }

    if (this._idbVisibilityHandler) {
      document.removeEventListener('visibilitychange', this._idbVisibilityHandler)
    }
    this._idbVisibilityHandler = () => {
      if (document.hidden) {
        return
      }
      void this._syncFromForeignRevisionIfNeeded()
    }
    document.addEventListener('visibilitychange', this._idbVisibilityHandler)
    // bfcache restore may not flip visibility; same foreign-revision check as focus.
    if (this._idbPageshowHandler) {
      window.removeEventListener('pageshow', this._idbPageshowHandler)
    }
    this._idbPageshowHandler = (event) => {
      let persisted = !!event.persisted
      if (persisted || document.visibilityState === 'visible') {
        void this._syncFromForeignRevisionIfNeeded()
      }
    }
    window.addEventListener('pageshow', this._idbPageshowHandler)
  }

  /**
   * Reload settings / tags from IDB when another tab advanced `meta.revisionId`.
   * Clears TagRuntime RAM so sidebar / filters cannot keep stale attribute rows
   * (`ensureNames` skips names already cached).
   * @return {Promise<void>}
   * @private
   */
  async _syncFromForeignRevisionIfNeeded()
  {
    if (!this._storageReady) {
      return
    }
    let revisionId = await this._repos.meta.get().then((m) => m?.revisionId)
    if (this._syncedRevisionId === revisionId) {
      return
    }
    this._syncedRevisionId = revisionId
    this._workingSet = null
    this._repos.tagRuntime?.clearCache()
    await this._syncFieldsFromIdb()
    await this._repos.tagRuntime.warmCache()
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET) {
        await field.reload()
        field.updateUserInterface()
      }
    }
    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 (field.type === CONFIG_TYPE_RULESET) {
      let compiled = await this._compileRulesetFieldFromIdb(fieldKey)
      this._cacheSetting(fieldKey, compiled?.rawLines ?? [], compiled?.optimized ?? null)
      if (field.reload) {
        await field.reload()
      }
      this._overlayFieldFromCache(field)
      return
    }
    if (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 spec of RULESET_MIGRATION_FIELD_SPECS) {
      if (aggregate[spec.fieldKey] === undefined) {
        continue
      }
      let value = aggregate[spec.fieldKey]
      if (Array.isArray(value) && value.length === 0) {
        continue
      }
      await this._importTagFieldFromValue(spec.fieldKey, value)
    }
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @return {Promise<void>}
   * @private
   */
  async _importTagFieldFromValue(fieldKey, value, options = {})
  {
    let field = this._config[fieldKey]
    if (!field) {
      return
    }
    let spec = RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === fieldKey)
    if (!spec) {
      return
    }
    let seed = this._fieldSeeds.get(fieldKey)
    let existingMain = await this._repos.rulesetFields.get(fieldKey)
    let backupConfig = options.config ??
        (value && typeof value === 'object' && !Array.isArray(value) ? value.config : null)
    let importConfig
    if (backupConfig && typeof backupConfig === 'object') {
      importConfig = backupConfig
    } else if (existingMain?.config && Object.keys(existingMain.config).length) {
      importConfig = existingMain.config
    } else {
      importConfig = {...DEFAULT_RULESET_USER_CONFIG, ...(seed?.config ?? spec.config ?? {})}
    }
    await this._repos.rulesetFields.upsert({
      fieldKey: spec.fieldKey,
      templateId: spec.templateId,
      templateConfig: spec.templateConfig,
      config: importConfig,
    })
    field.templateId = spec.templateId
    field.templateConfig = spec.templateConfig
    field._rulesetMain = await this._repos.rulesetFields.get(fieldKey)

    let lines = []
    let lineSource = Array.isArray(value) ? value : (value?.lines ?? value?.entries ?? [])
    if (Array.isArray(lineSource)) {
      if (lineSource.length && typeof lineSource[0] === 'object' && lineSource[0]?.subject) {
        for (let entry of lineSource) {
          if (entry?.subject) {
            lines.push(entry.subject + ' → ' + (entry.replacement ?? ''))
          }
        }
      } else {
        lines = lineSource.map(String)
      }
    }
    let expanded = []
    for (let line of lines) {
      if (line.includes('|')) {
        expanded.push(...expandOrRuleLine(line))
      } else {
        expanded.push(line)
      }
    }
    if (!expanded.length) {
      return
    }

    let existing = await this._repos.rulesetEntries.listAllForField(fieldKey)
    for (let row of existing) {
      await this._repos.rulesetEntries.remove(row.entryId)
    }

    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get(spec.templateId) : null
    if (!template) {
      return
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    let sortOrder = 0
    for (let rawLine of expanded) {
      let parsed = template.parseImportLine?.(String(rawLine), ctx)
      if (!parsed) {
        continue
      }
      await template.persist(parsed.payload, ctx, {
        sortOrder: sortOrder++,
        comment: parsed.comment ?? '',
      })
    }
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, fieldKey)
    }
    await this._syncFieldFromIdb(fieldKey)
  }

  /**
   * @return {Promise<void>}
   * @private
   */
  async _recompileAllTagRuleSets(report = null)
  {
    await this.ensureRulesetFieldsCompiled(null, report)
  }

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

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

  /**
   * @return {boolean}
   */
  isIndexedDBAvailable()
  {
    return this._repos.storage.available
  }

  /**
   * Count post ids in the download duplicate ledger (IndexedDB row count; no full-table RAM load).
   * @return {Promise<number>}
   */
  async countDownloadLedger()
  {
    if (!this.getField('download-ledger')) {
      return 0
    }
    await this._ensureStorageReady()
    return this._repos.ledger.countLedgerEntries()
  }

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

  /**
   * Replace the download duplicate ledger with the supplied post ids (atomic wipe + write).
   * @param {Iterable<string|number|null|undefined>} ids
   * @return {Promise<number>} count of ids written
   */
  async replaceDownloadLedgerIds(ids)
  {
    await this._ensureStorageReady()
    let field = this.getField('download-ledger')
    let isValidId = field?._ledgerIsValidId?.() ??
        ((value) => typeof value === 'string' && value.trim().length > 0)
    let written = 0
    await this.beginDownloadLedgerFolderImport(true)
    let batch = []
    for (let raw of ids) {
      if (raw === null || raw === undefined || !String(raw).length) {
        continue
      }
      let postId = String(raw).trim()
      if (!postId || !isValidId(postId)) {
        continue
      }
      batch.push(postId)
      if (batch.length >= LEDGER_FOLDER_IMPORT_BATCH_SIZE) {
        written += await this.mergeDownloadLedgerImportBatch(batch)
        batch = []
      }
    }
    if (batch.length) {
      written += await this.mergeDownloadLedgerImportBatch(batch)
    }
    await this.finalizeDownloadLedgerFolderImport()
    return written
  }

  /**
   * Prepare ledger folder import: optionally wipe the ledger (replace mode).
   * Pair with {@link mergeDownloadLedgerImportBatch} and {@link finalizeDownloadLedgerFolderImport}.
   *
   * @param {boolean} [replace=false]
   * @return {Promise<boolean>}
   */
  async beginDownloadLedgerFolderImport(replace = false)
  {
    await this._ensureStorageReady()
    if (replace) {
      await this._repos.ledger.replaceAll([])
      let field = this.getField('download-ledger')
      if (field?.reload) {
        await field.reload()
      }
    }
    this._ledgerImportRevisionPending = true
    return true
  }

  /**
   * Upsert one batch of post ids during folder import (no full-table RAM mirror).
   *
   * @param {Iterable<string|number|null|undefined>} ids
   * @return {Promise<number>} rows accepted for write
   */
  async mergeDownloadLedgerImportBatch(ids)
  {
    await this._ensureStorageReady()
    let field = this.getField('download-ledger')
    let isValidId = field?._ledgerIsValidId?.() ??
        ((value) => typeof value === 'string' && value.trim().length > 0)
    let claimedAt = Date.now()
    /** @type {{postId: string, claimedAt: number}[]} */
    let rows = []
    for (let raw of ids) {
      if (raw === null || raw === undefined || !String(raw).length) {
        continue
      }
      let postId = String(raw).trim()
      if (!postId || !isValidId(postId)) {
        continue
      }
      rows.push({postId, claimedAt})
    }
    if (!rows.length) {
      return 0
    }
    await this._repos.ledger.mergeRows(rows, {deferRevision: true})
    return rows.length
  }

  /**
   * Commit a deferred folder import (single revision bump + cache reload).
   * @return {Promise<void>}
   */
  async finalizeDownloadLedgerFolderImport()
  {
    if (this._ledgerImportRevisionPending) {
      await this._repos.ledger.commitMergeRows()
      this._ledgerImportRevisionPending = false
    }
    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
    await this._repos.storage.deleteDatabase()
  }

  /**
   * Clear {@link TagEntry.isDiscovered} for all tags (`true` → `null`). Types and rulesets unchanged.
   * @param {function({current?: number, total?: number, label?: string}): void|Promise<void>|null} [onProgress]
   * @return {Promise<{updated: number, total: number}>}
   */
  async resetAllTagsDiscovered(onProgress = null)
  {
    if (!this._repos?.tags) {
      return {updated: 0, total: 0}
    }
    let result = await this._repos.tags.resetAllTagsDiscovered(onProgress)
    this._repos.tagRuntime?.clearCache()
    await this._commitTagFieldChange()
    return result
  }

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

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

  /**
   * Read a settings field from IndexedDB (source of truth via `_settingCache`).
   * @param {string} fieldKey
   * @return {Promise<*>}
   */
  async readSetting(fieldKey)
  {
    await this._ensureStorageReady()
    let stored = await this._repos.settings.getField(fieldKey)
    if (!stored) {
      return this._getCachedSetting(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)
  {
    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)
  {
    let field = this.getField(fieldKey)
    if (field?.type !== CONFIG_TYPE_RULESET) {
      return false
    }
    let templateId = field.templateId ?? field._rulesetMain?.templateId
    return templateId === 'tag-blacklist' ||
        templateId === 'explored-tags' ||
        templateId === 'tag-sole-ignore' ||
        templateId === 'substitution'
  }

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

  /**
   * Filename-ignore sole attribute — same row lookup as download join / Aa button.
   * @param {string} tagName
   * @return {boolean}
   */
  isFilenameTagIgnored(tagName)
  {
    return this.hasTagSoleAttribute('filename-tag-ignore-list', tagName)
  }

  /**
   * @param {ConfigurationField} field
   * @param {number} entryId
   * @return {boolean}
   * @private
   */
  _compiledIgnoreHasEntryId(field, entryId)
  {
    if (entryId == null) {
      return false
    }
    let ids = field?.optimized?.tagEntryIds
    return Array.isArray(ids) && ids.includes(entryId)
  }

  /**
   * Entry ids to consult for filename-ignore reads (full name + optional strip-series base).
   * @param {string} normalized
   * @param {string} fieldKey
   * @return {number[]}
   * @private
   */
  _ignoreCandidateEntryIds(normalized, fieldKey)
  {
    let entryIds = []
    let seen = new Set()
    let add = (name) => {
      if (!name) {
        return
      }
      let cached = this.getCachedTagEntry(name)
      if (cached?.entryId != null && !seen.has(cached.entryId)) {
        seen.add(cached.entryId)
        entryIds.push(cached.entryId)
      }
    }
    add(normalized)
    if (fieldKey === 'filename-tag-ignore-list') {
      let stripped = normalized.replace(/_\([^)]*\)$/, '')
      if (stripped && stripped !== normalized) {
        add(stripped)
      }
    }
    return entryIds
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} templateId
   * @param {string} normalized
   * @param {string} fieldKey
   * @return {boolean}
   * @private
   */
  _hasTagSoleAttributeByEntryId(field, templateId, normalized, fieldKey)
  {
    if (templateId === 'tag-sole-ignore') {
      for (let entryId of this._ignoreCandidateEntryIds(normalized, fieldKey)) {
        if (this._compiledIgnoreHasEntryId(field, entryId)) {
          return true
        }
        let ignoreIds = this.getTagRuntime()?._downloadIgnoreEntryIds
        if (ignoreIds?.has(entryId)) {
          return true
        }
      }
    }
    for (let entryId of this._ignoreCandidateEntryIds(normalized, fieldKey)) {
      let cached = this.getTagRuntime()?.getCachedByEntryId?.(entryId) ??
          {name: normalized, entryId}
      if (this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))) {
        return true
      }
    }
    let cached = this.getCachedTagEntry(normalized)
    if (cached?.entryId != null) {
      if (this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))) {
        return true
      }
    }
    return !!this._findRulesetRowForTarget(
        field,
        this._rulesetMatchTargetForTag(templateId, normalized))
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} templateId
   * @param {string} normalized
   * @param {boolean} present
   * @private
   */
  _patchOptimisticIgnoreCompiled(field, templateId, normalized, present)
  {
    if (templateId !== 'tag-sole-ignore') {
      return
    }
    if (!field.optimized) {
      field.optimized = {tagEntryIds: []}
    } else if (!field.optimized.tagEntryIds) {
      field.optimized = {...field.optimized, tagEntryIds: []}
    }
    let ids = new Set(field.optimized.tagEntryIds)
    for (let entryId of this._ignoreCandidateEntryIds(normalized, field.key)) {
      if (entryId == null) {
        continue
      }
      if (present) {
        ids.add(entryId)
      } else {
        ids.delete(entryId)
      }
    }
    field.optimized = {...field.optimized, tagEntryIds: [...ids]}
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} templateId
   * @param {string} normalized
   * @return {*|null}
   * @private
   */
  _findSoleAttributeRowForToggle(field, templateId, normalized)
  {
    for (let entryId of this._ignoreCandidateEntryIds(normalized, field.key)) {
      let cached = this.getTagRuntime()?.getCachedByEntryId?.(entryId) ??
          {name: normalized, entryId}
      let row = this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))
      if (row) {
        return row
      }
    }
    let cached = this.getCachedTagEntry(normalized)
    if (cached?.entryId != null) {
      return this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))
    }
    return this._findRulesetRowForTarget(field, this._rulesetMatchTargetForTag(templateId, normalized))
  }

  /**
   * @param {string} fieldKey
   * @param {string} tagName Normalized tag name.
   * @return {boolean}
   */
  hasTagSoleAttribute(fieldKey, tagName)
  {
    let normalized = String(tagName ?? '').trim()
    if (!normalized) {
      return false
    }
    let field = this.getField(fieldKey)
    if (field?.type === CONFIG_TYPE_RULESET) {
      let templateId = field.templateId ?? field._rulesetMain?.templateId
      if (templateId) {
        if (this._rulesetTemplateRequiresTagEntryId(templateId)) {
          return this._hasTagSoleAttributeByEntryId(field, templateId, normalized, fieldKey)
        }
        let matchTarget = this._rulesetMatchTargetForTag(templateId, normalized)
        return !!this._findRulesetRowForTarget(field, matchTarget)
      }
    }
    return field?.hasRule?.(normalized) ?? false
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} subjectName Normalized subject tag name.
   * @return {*|null}
   * @private
   */
  _findSubstitutionSubjectRow(field, subjectName)
  {
    let normalized = String(subjectName ?? '').trim()
    if (!normalized || !field) {
      return null
    }
    let cached = this.getCachedTagEntry(normalized)
    let cachedEntryId = cached?.entryId ?? null
    let cachedName = cached?.name ?? null
    for (let row of field._rulesetRows ?? []) {
      let payload = row.payload ?? {}
      if (payload.subjectName === normalized || (cachedName && payload.subjectName === cachedName)) {
        return row
      }
      if (cachedEntryId != null && payload.subjectTagEntryId === cachedEntryId) {
        return row
      }
    }
    return null
  }

  /**
   * @param {ConfigurationField} field
   * @param {string} subjectName Normalized subject tag name.
   * @return {boolean}
   * @private
   */
  _compiledSubstitutionHasSubject(field, subjectName)
  {
    let optimized = field?.optimized
    if (!Array.isArray(optimized)) {
      return false
    }
    let cached = this.getCachedTagEntry(subjectName)
    let cachedEntryId = cached?.entryId ?? null
    let cachedName = cached?.name ?? null
    for (let row of optimized) {
      if (cachedEntryId != null && row?.subjectTagEntryId === cachedEntryId) {
        return true
      }
      if (row?.subjectName === subjectName || (cachedName && row?.subjectName === cachedName)) {
        return true
      }
    }
    return false
  }

  /**
   * @param {string} tagName Normalized tag name.
   * @param {string} [fieldKey='filename-tag-substitutions']
   * @return {boolean}
   */
  hasTagSubstitutionSubject(tagName, fieldKey = 'filename-tag-substitutions')
  {
    let normalized = String(tagName ?? '').trim()
    if (!normalized) {
      return false
    }
    let field = this.getField(fieldKey)
    if (field?.type === CONFIG_TYPE_RULESET && (field.templateId === 'substitution' || field._rulesetMain?.templateId === 'substitution')) {
      if (this._compiledSubstitutionHasSubject(field, normalized)) {
        return true
      }
      return !!this._findSubstitutionSubjectRow(field, normalized)
    }
    return field?.hasRule?.(normalized) ?? false
  }

  /**
   * Persist a filename-tag substitution from the Subject → Alias composer.
   * @param {ConfigurationField} field
   * @param {string} subject
   * @param {string} alias
   * @return {Promise<boolean>}
   * @private
   */
  async _addSubstitutionFromComposer(field, subject, alias)
  {
    let normalize = field.substitutionComposer?.normalize
    let subjectName = normalize
        ? Utilities.callEventHandler(normalize, [subject], String(subject ?? '').trim())
        : String(subject ?? '').trim()
    let aliasName = normalize
        ? Utilities.callEventHandler(normalize, [alias], String(alias ?? '').trim())
        : String(alias ?? '').trim()
    if (!subjectName || !aliasName || subjectName === aliasName) {
      return false
    }
    return this.setTagSubstitution(subjectName, aliasName, field.key)
  }

  /**
   * 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']
   * @param {{subjectTypeName?: string|null, replacementTypeName?: string|null, source?: string|null}} [typeContext]
   * @return {Promise<boolean>}
   */
  async setTagSubstitution(subject, replacement, fieldKey = 'filename-tag-substitutions', typeContext = null)
  {
    let field = this.getField(fieldKey)
    if (!field) {
      return false
    }
    let subjectName = String(subject ?? '').trim()
    let replacementName = String(replacement ?? '').trim()
    if (!subjectName || !replacementName) {
      return false
    }
    let main = await this._repos.rulesetFields.get(fieldKey)
    if (main?.templateId === 'substitution' && typeof RulesetTemplateRegistry !== 'undefined') {
      let template = RulesetTemplateRegistry.get('substitution')
      let ctx = this._buildRulesetTemplateCtx(field)
      let existing = this._findSubstitutionSubjectRow(field, subjectName)
      if (!existing) {
        let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
        existing = rows.find((row) => template.matchRule(row.payload, {subjectName})) ?? null
      }
      if (existing?.entryId != null) {
        await this._repos.rulesetEntries.remove(existing.entryId)
        field.removeRow(existing.entryId)
      }
      let subjectCtx = this.createTagContext({
        typeName: typeContext?.subjectTypeName ?? null,
        replacementName,
        source: typeContext?.source ?? 'tag-action',
      })
      await this._repos.tagRuntime.ensureTag(subjectName, subjectCtx)
      if (typeContext?.replacementTypeName) {
        await this._repos.tagRuntime.ensureTag(replacementName, this.createTagContext({
          typeName: typeContext.replacementTypeName,
          source: typeContext?.source ?? 'tag-action',
        }))
      }
      let payload = {subjectName, replacementName}
      let row = await template.persist(payload, ctx)
      field.patchRow(row)
      if (typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, fieldKey)
      }
      await field.getOptimized()
      field.updateUserInterface?.()
      this._refreshRulesetFieldUiIfClean(field)
      // Parity with other ruleset mutations: repaint the settings panel widget and any
      // mounted mirror (sidebar / discovery) without waiting on the full config-change pass.
      RulesetMutationBus.notify({
        fieldKeys: [fieldKey],
        entryIds: row?.entryId != null ? [row.entryId] : [],
        tagNames: [subjectName, replacementName],
      })
      await this._commitTagFieldChange(this.createTagsChangeDetail([subjectName, replacementName], fieldKey))
      return true
    }
    return false
  }

  /**
   * 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)
    if (!field) {
      return false
    }
    let subjectName = String(subject ?? '').trim()
    if (!subjectName) {
      return false
    }
    let main = await this._repos.rulesetFields.get(fieldKey)
    if (main?.templateId === 'substitution' && typeof RulesetTemplateRegistry !== 'undefined') {
      let template = RulesetTemplateRegistry.get('substitution')
      let matchTarget = {subjectName}
      let existing = this._findSubstitutionSubjectRow(field, subjectName)
      if (!existing) {
        let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
        existing = rows.find((row) => template.matchRule(row.payload, matchTarget)) ?? null
      }
      let removedEntryId = null
      if (existing?.entryId != null) {
        removedEntryId = existing.entryId
        await this._repos.rulesetEntries.remove(existing.entryId)
        field.removeRow(existing.entryId)
      }
      if (typeof compileRulesetField === 'function') {
        await compileRulesetField(this._repos, fieldKey)
      }
      await field.getOptimized()
      field.updateUserInterface?.()
      this._refreshRulesetFieldUiIfClean(field)
      // Parity with other ruleset mutations: repaint mounted panel / mirrors immediately.
      RulesetMutationBus.notify({
        fieldKeys: [fieldKey],
        entryIds: removedEntryId != null ? [removedEntryId] : [],
        tagNames: [subjectName],
      })
      await this._commitTagFieldChange(this.createTagsChangeDetail(subjectName, fieldKey))
      return true
    }
    return false
  }

  /**
   * @param {string} fieldKey
   * @return {boolean}
   */
  hasTagComplianceRules(fieldKey)
  {
    let spec = this.getTagComplianceSpec(fieldKey)
    if (spec?.combos?.length) {
      return true
    }
    let field = this.getField(fieldKey)
    if (field?.optimized?.combos?.length) {
      return true
    }
    let cached = this._settingCache?.[fieldKey]?.optimized
    return !!(cached?.combos?.length)
  }

  /**
   * @param {string[]} [fieldKeys] Compliance ruleset fields.
   * @return {Promise<void>}
   */
  async refreshTagComplianceSpecs(fieldKeys = null)
  {
    let tagRuntime = this.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    let keys = fieldKeys ?? ['tag-blacklist', 'explored-tags-tracker']
    for (let fieldKey of keys) {
      this._tagComplianceSpecs[fieldKey] = await tagRuntime.getComplianceSpecForField(fieldKey)
    }
  }

  /**
   * @param {string} fieldKey
   * @return {{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
  }

  /**
   * Resolve any tag-type descriptor (canonical name, site class/label, or numeric API category id)
   * to the canonical type name using the `tagTypes` schema. Consumer scripts use this to type tags
   * from site payloads (e.g. autocomplete responses) without hardcoding label→type tables.
   *
   * @see TagRepository#resolveCanonicalTypeName
   * @param {string|number|null|undefined} descriptor
   * @return {Promise<string|null>}
   */
  async resolveCanonicalTypeName(descriptor)
  {
    return await this._repos?.tags?.resolveCanonicalTypeName?.(descriptor) ?? null
  }

  /**
   * 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) {
      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()
  {
    let meta = await this._repos.meta.get()
    let revisionId = meta?.revisionId ?? null
    if (this._bootRulesetCompiledRevisionId !== revisionId) {
      await this.ensureRulesetFieldsCompiled(['tag-blacklist', 'explored-tags-tracker'])
    }
    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._storageReady && tagRuntime) {
      return {
        ...resolver,
        tagRuntime,
        ignore: null,
        substitutions: null,
      }
    }
    return resolver
  }

  /**
   * @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
   * @return {Promise<void>}
   * @private
   */
  async _commitTagFieldChange(detail = null)
  {
    await this._repos.meta.bumpRevision()
    // Notify first so attribute UIs (discovery panel, sidebar) paint from the writer-updated
    // cache instead of waiting on a full-table warmCache reload.
    this.notifyConfigurationChange('tags', true, detail)
    await this._repos.tagRuntime?.warmCache()
  }

  /**
   * @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]
    if (!field) {
      return false
    }
    return this._toggleRulesetTagRule(fieldKey, tagName, context)
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<boolean>}
   */
  async clearTagRules(fieldKey)
  {
    let field = this._config[fieldKey]
    if (!field || field.type !== CONFIG_TYPE_RULESET) {
      return false
    }
    let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
    for (let row of rows) {
      await this._repos.rulesetEntries.remove(row.entryId)
    }
    if (typeof compileRulesetField === 'function') {
      await compileRulesetField(this._repos, fieldKey)
    }
    await field.reload()
    this._refreshRulesetFieldUiIfClean(field)
    if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
      await this.refreshTagComplianceSpecs([fieldKey])
    }
    await this._commitTagFieldChange(this.createTagsChangeDetail(null, fieldKey))
    return true
  }

  /**
   * @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._restoreIdbBackup(backupConfig)
      } else if (backupConfig.version === 3 && (backupConfig.stores || backupConfig.meta)) {
        await this._restoreIdbBackup(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()
  {
    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_RULESET) {
        await field.reload()
        field.updateUserInterface()
      }
    }
    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()
  {
    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
      }
      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)
    }
    for (let fieldKey in this._config) {
      let field = this._config[fieldKey]
      if (field.type === CONFIG_TYPE_RULESET && field.getOptimized) {
        await field.getOptimized()
      }
    }
    await this._repos.meta.bumpRevision()
    this.notifyConfigurationChange('all', true)
    return this
  }

  /**
   * @return {Promise<object[]>}
   * @private
   */
  async _exportBookmarksBackupRows()
  {
    let rows = await this._repos.rulesetEntries.listAllForField('bookmarks')
    return rows.map((entry) => ({
      entryId: entry.entryId,
      label: entry?.payload?.label ?? entry?.rawLine ?? '',
      tags: entry?.payload?.tags ?? '',
      url: entry?.payload?.url ?? '',
      sortOrder: entry.sortOrder ?? 0,
    }))
  }

  /**
   * @return {Promise<void>}
   */
  async backup()
  {
    await this._ensureStorageReady()
    let {zip, filename} = await this._buildBackupZip({purpose: 'userBackup'})
    this._triggerBackupZipDownload(zip, filename)
  }

  /**
   * @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: collectBackupJsonArrayFromZipFiles(files, 'tags'),
        rulesetFields: JSON.parse(files['rulesetFields.json'] ?? '[]'),
        rulesetEntries: JSON.parse(files['rulesetEntries.json'] ?? '[]'),
        bookmarks: collectBackupJsonArrayFromZipFiles(files, 'bookmarks'),
        ledgerEntries: collectBackupJsonArrayFromZipFiles(files, 'ledgerEntries'),
      },
    }
  }

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

  /**
   * Import a legacy flat bookmarks array into a ruleset field via the bookmarks template.
   * @param {string} fieldKey
   * @param {Array} data
   * @return {Promise<void>}
   * @private
   */
  async _importLegacyBookmarksArray(fieldKey, data)
  {
    if (!Array.isArray(data)) {
      return
    }
    let field = this.getField(fieldKey)
    if (!field || field.type !== CONFIG_TYPE_RULESET) {
      return
    }
    let template = typeof RulesetTemplateRegistry !== 'undefined' ?
        RulesetTemplateRegistry.get('bookmarks') : null
    if (!template) {
      return
    }
    let rows = data.
        map((bookmark) => {
          if (!bookmark || typeof bookmark !== 'object') {
            return null
          }
          let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
          if (!tags.length) {
            return null
          }
          let url = typeof bookmark.url === 'string' ? bookmark.url.trim() : ''
          let label = typeof bookmark.label === 'string' && bookmark.label.trim() ?
              bookmark.label.trim() : tags.replaceAll('_', ' ')
          return {
            entryId: bookmark.entryId ?? null,
            label,
            tags,
            url,
            sortOrder: bookmark.sortOrder ?? 0,
          }
        }).
        filter(Boolean)
    let existing = await this._repos.rulesetEntries.listAllForField(fieldKey)
    for (let row of existing) {
      await this._repos.rulesetEntries.remove(row.entryId)
    }
    let ctx = this._buildRulesetTemplateCtx(field)
    for (let index = 0; index < rows.length; index++) {
      let row = rows[index]
      await template.persist({
        label: row.label ?? '',
        tags: row.tags ?? '',
        url: row.url ?? '',
      }, ctx, {
        entryId: row.entryId,
        sortOrder: row.sortOrder ?? index,
      })
    }
    await field.reload?.()
    field.updateUserInterface?.()
  }

  /**
   * @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_LEDGER) {
        await field.merge(value)
      } else if (field.persist === false) {
        if (field.type === CONFIG_TYPE_RULESET && Array.isArray(value)) {
          if (field.templateId === 'bookmarks' || field._rulesetMain?.templateId === 'bookmarks') {
            await this._importLegacyBookmarksArray(fieldKey, value)
          } else {
            await this._importTagFieldFromValue(fieldKey, value)
          }
        } else {
          await field.applyFromBackup?.(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 _restoreIdbBackup(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.tags) {
      await this._repos.storage.clearStore(IDB_STORE_TAGS)
      await this._repos.storage.putMany(IDB_STORE_TAGS, stores.tags)
    }
    if (stores.rulesetFields?.length) {
      await this._repos.storage.clearStore(IDB_STORE_RULESET_FIELDS)
      for (let row of stores.rulesetFields) {
        await this._repos.storage.put(IDB_STORE_RULESET_FIELDS, row)
      }
    }
    if (stores.rulesetEntries?.length) {
      await this._repos.storage.clearStore(IDB_STORE_RULESET_ENTRIES)
      await this._repos.storage.putMany(IDB_STORE_RULESET_ENTRIES, stores.rulesetEntries)
    }
    if (stores.bookmarks?.length) {
      await this._importLegacyBookmarksArray('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.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_LEDGER ||
          field.persist === false) {
        continue
      }
      if (field.element) {
        field.setFromUserInterface()
        if (this._storageReady && 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
  }
}

BrazenConfigurationManager.DEFAULT_RULESET_USER_CONFIG = DEFAULT_RULESET_USER_CONFIG
BrazenConfigurationManager.RULESET_USER_CONFIG_KEYS = RULESET_USER_CONFIG_KEYS