Brazen Framework - Configuration Manager

Configuration management for the Brazen user scripts framework

Ovu skriptu ne treba izravno instalirati. To je biblioteka za druge skripte koje se uključuju u meta direktivu // @require https://update.greasyfork.org/scripts/418665/1901979/Brazen%20Framework%20-%20Configuration%20Manager.js

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==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