Brazen Framework - IndexedDB Storage

IndexedDB storage layer and repositories for Brazen user scripts

Este script no debería instalarse directamente. Es una biblioteca que utilizan otros scripts mediante la meta-directiva de inclusión // @require https://update.greasyfork.org/scripts/587030/1880217/Brazen%20Framework%20-%20IndexedDB%20Storage.js

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         Brazen Framework - IndexedDB Storage
// @namespace    brazenvoid
// @version      1.2.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  IndexedDB storage layer and repositories for Brazen user scripts
// ==/UserScript==

// Keep at 3+: a brief mistaken bump in development may already have upgraded live DBs.
// IndexedDB cannot open a lower version (VersionError → entire script persistence dies).
const IDB_SCHEMA_VERSION = 3

const IDB_STORE_META = 'meta'
const IDB_STORE_SETTINGS = 'settings'
const IDB_STORE_APIS = 'apis'
const IDB_STORE_TAG_TYPES = 'tagTypes'
const IDB_STORE_TAGS = 'tags'
const IDB_STORE_TAG_RULES = 'tagRules'
const IDB_STORE_TAG_RULE_SETS = 'tagRuleSets'
const IDB_STORE_BOOKMARKS = 'bookmarks'
const IDB_STORE_LEDGER = 'ledgerEntries'
const IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE = 'downloadResolutionQueue'
const IDB_STORE_DOWNLOAD_QUEUE = 'downloadQueue'
const IDB_STORE_DOWNLOAD_MANAGER_STATE = 'downloadManagerState'

const IDB_ALL_STORES = [
  IDB_STORE_META,
  IDB_STORE_SETTINGS,
  IDB_STORE_APIS,
  IDB_STORE_TAG_TYPES,
  IDB_STORE_TAGS,
  IDB_STORE_TAG_RULES,
  IDB_STORE_TAG_RULE_SETS,
  IDB_STORE_BOOKMARKS,
  IDB_STORE_LEDGER,
  IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
  IDB_STORE_DOWNLOAD_QUEUE,
  IDB_STORE_DOWNLOAD_MANAGER_STATE,
]

const IDB_ROW_STORES = [
  IDB_STORE_TAGS,
  IDB_STORE_TAG_RULES,
  IDB_STORE_BOOKMARKS,
  IDB_STORE_LEDGER,
  IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
  IDB_STORE_DOWNLOAD_QUEUE,
]

const IDB_SINGLETON_STORES = [
  IDB_STORE_SETTINGS,
  IDB_STORE_APIS,
  IDB_STORE_TAG_TYPES,
  IDB_STORE_TAG_RULE_SETS,
  IDB_STORE_DOWNLOAD_MANAGER_STATE,
]

const IDB_PUT_MANY_CHUNK = 500

/** Bound for TagRuntime name/id maps — full-table warm is not used. */
const TAG_RUNTIME_CACHE_MAX = 4096

/** Bound for positive ledger membership cache (recent claims / primed hits). */
const LEDGER_POSITIVE_CACHE_MAX = 16384

const LEGACY_SOURCE_LOCAL = 'local'
const LEGACY_SOURCE_GM = 'gm'

/** @type {{source: string, read: function(string, *): *, remove: function(string): void}[]} */
const LEGACY_STORAGE_BACKENDS = [
  {
    source: LEGACY_SOURCE_LOCAL,
    read: readLegacyLocal,
    remove: removeLegacyLocal,
  },
  {
    source: LEGACY_SOURCE_GM,
    read: readLegacyGm,
    remove: removeLegacyGm,
  },
]

/**
 * @param {string} storeKey
 * @param {*} [fallback]
 * @return {*}
 */
function readLegacyLocal(storeKey, fallback = null)
{
  let storedStore = localStorage.getItem(storeKey)
  if (!storedStore || storedStore === '') {
    return fallback
  }
  let parsed = JSON.parse(storedStore)
  if (parsed && typeof parsed === 'object' && 'arrays' in parsed && 'objects' in parsed && 'properties' in parsed) {
    return Utilities.objectFromJSON(storedStore)
  }
  return parsed
}

/**
 * @param {string} storeKey
 */
function removeLegacyLocal(storeKey)
{
  localStorage.removeItem(storeKey)
}

/**
 * @param {string} storeKey
 * @param {*} [fallback]
 * @return {*}
 */
function readLegacyGm(storeKey, fallback = null)
{
  try {
    let value = GM_getValue(storeKey, fallback)
    if (value === undefined) {
      return fallback
    }
    return Utilities.reviveGmStoredValue(value)
  } catch (error) {
    console.log('Failed to read legacy GM storage key:', storeKey, error)
    return fallback
  }
}

/**
 * @param {string} storeKey
 */
function removeLegacyGm(storeKey)
{
  if (typeof GM_deleteValue === 'function') {
    GM_deleteValue(storeKey)
  }
}

/**
 * Whether localStorage or GM still holds pre-IDB Brazen settings/bookmarks/ledger keys.
 * @param {string} prefix
 * @param {string|null|undefined} legacyPrefix
 * @return {boolean}
 */
function legacyStorageHasData(prefix, legacyPrefix)
{
  for (let backend of LEGACY_STORAGE_BACKENDS) {
    if (backend.read(prefix + 'settings', null)) {
      return true
    }
    if (legacyPrefix && legacyPrefix !== prefix && backend.read(legacyPrefix + 'settings', null)) {
      return true
    }
  }
  if (readLegacyGm(prefix + 'bookmarks', null) || readLegacyGm(prefix + 'download-ledger', null)) {
    return true
  }
  return false
}

/**
 * @param {string} prefix
 * @return {Object|null}
 */
function readLegacySettingsAggregate(prefix)
{
  return readLegacyLocal(prefix + 'settings', null) ?? readLegacyGm(prefix + 'settings', null)
}

const TAG_RULESET_PROPERTY_BY_FIELD = {
  'tag-blacklist': 'tagBlacklist',
  'explored-tags-tracker': 'exploredTagsTracker',
  'filename-tag-ignore-list': 'filenameTagIgnoreList',
  'filename-tag-substitutions': 'filenameTagSubstitutions',
  'favourite-tags': 'favouriteTags',
  'disliked-tags': 'dislikedTags',
  'adult-characters': 'highlightAdult',
  'underage-characters': 'highlightUnderage',
  'unknown-age-characters': 'highlightUnknownAge',
}

const TAG_RULE_GROUP_BY_FIELD = {
  'tag-blacklist': 'blacklist',
  'explored-tags-tracker': 'explored',
  'filename-tag-ignore-list': 'filenameIgnore',
  'filename-tag-substitutions': 'filenameSubstitutions',
  'favourite-tags': 'highlightFavourite',
  'disliked-tags': 'highlightDisliked',
  'adult-characters': 'highlightAdult',
  'underage-characters': 'highlightUnderage',
  'unknown-age-characters': 'highlightUnknownAge',
}

const TAG_DOMAIN_FIELD_KEYS = new Set([
  ...Object.keys(TAG_RULESET_PROPERTY_BY_FIELD),
])

/**
 * rule34xxx tagging registry fields (sole attributes on TagEntry; combos in tagRules).
 * @type {Record<string, {group: string, usesTaggingRegistry: boolean, usesCombos?: boolean, soleAttribute?: {root: string, key: string}, substitution?: boolean}>}
 */
const TAG_FIELD_SPEC = {
  'tag-blacklist': {
    group: 'blacklist',
    usesTaggingRegistry: true,
    usesCombos: true,
    soleAttribute: {root: 'compliance', key: 'blacklisted'},
  },
  'explored-tags-tracker': {
    group: 'explored',
    usesTaggingRegistry: true,
    usesCombos: true,
    soleAttribute: {root: 'compliance', key: 'explored'},
  },
  'filename-tag-ignore-list': {
    group: 'filenameIgnore',
    usesTaggingRegistry: true,
    usesCombos: false,
    soleAttribute: {root: 'download', key: 'filenameIgnored'},
  },
  'filename-tag-substitutions': {
    group: 'filenameSubstitutions',
    usesTaggingRegistry: true,
    usesCombos: false,
    substitution: true,
  },
}

const TAGGING_REGISTRY_GROUPS = new Set(
    Object.values(TAG_FIELD_SPEC).map((spec) => spec.group),
)

/**
 * @param {*} row
 * @return {*}
 */
function normalizeTagEntry(row)
{
  if (!row) {
    return row
  }
  let now = Date.now()
  row.compliance = row.compliance ?? {blacklisted: false, explored: false}
  row.download = row.download ?? {filenameIgnored: false, substitution: null}
  row.meta = row.meta ?? {createdAt: now, updatedAt: now}
  if (row.typeEntryId === undefined) {
    row.typeEntryId = null
  }
  return row
}

/**
 * @param {string} rawLine
 * @return {boolean}
 */
function isComboTagLine(rawLine)
{
  let rule = String(rawLine).split('//')[0].trim()
  return rule.includes('&') && !rule.includes('→') && !rule.includes('->')
}

/**
 * @param {string} rawLine
 * @return {{subject: string, replacement: string}|null}
 */
function parseSubstitutionTagLine(rawLine)
{
  let line = String(rawLine).split('//')[0].trim()
  let match = line.match(/^(.+?)\s*(?:→|->)\s*(.+)$/)
  if (!match) {
    return null
  }
  let subject = match[1].trim()
  let replacement = match[2].trim()
  if (!subject || !replacement) {
    return null
  }
  return {subject, replacement}
}

/**
 * @param {*} entry
 * @param {{root: string, key: string}} soleAttribute
 * @return {boolean}
 */
function readTagSoleAttribute(entry, soleAttribute)
{
  if (!entry || !soleAttribute) {
    return false
  }
  let section = entry[soleAttribute.root]
  return !!(section && section[soleAttribute.key])
}

/**
 * @param {*} entry
 * @param {{root: string, key: string}} soleAttribute
 * @param {boolean} value
 */
function writeTagSoleAttribute(entry, soleAttribute, value)
{
  if (!entry[soleAttribute.root]) {
    entry[soleAttribute.root] = soleAttribute.root === 'compliance' ?
        {blacklisted: false, explored: false} :
        {filenameIgnored: false, substitution: null}
  }
  entry[soleAttribute.root][soleAttribute.key] = !!value
}

function dbNameFromScriptPrefix(scriptPrefix)
{
  return String(scriptPrefix).replace(/-$/, '')
}

function fieldKeyToProperty(fieldKey)
{
  return String(fieldKey).replace(/-([a-z0-9])/g, (_, char) => char.toUpperCase())
}

function propertyToFieldKey(property)
{
  return String(property).replace(/([A-Z])/g, '-$1').replace(/^-/, '').toLowerCase()
}

function yieldToBrowser()
{
  return new Promise((resolve) => setTimeout(resolve, 0))
}

function promisifyRequest(request)
{
  return new Promise((resolve, reject) => {
    request.onsuccess = () => resolve(request.result)
    request.onerror = () => reject(request.error)
  })
}

function waitTransaction(tx)
{
  return new Promise((resolve, reject) => {
    tx.oncomplete = () => resolve()
    tx.onerror = () => reject(tx.error)
    tx.onabort = () => reject(tx.error || new Error('Transaction aborted'))
  })
}

class BrazenIndexedDBStorage
{
  constructor(scriptPrefix)
  {
    this._scriptPrefix = scriptPrefix
    this._dbName = dbNameFromScriptPrefix(scriptPrefix)
    this._db = null
    /** @type {Promise<IDBDatabase>|null} Coalesces concurrent open() after close(). */
    this._openPromise = null
    this._available = typeof indexedDB !== 'undefined'
    this._revisionId = null
  }

  get available()
  {
    return this._available
  }

  get dbName()
  {
    return this._dbName
  }

  get database()
  {
    return this._db
  }

  /**
   * @return {Promise<IDBDatabase>}
   */
  async open()
  {
    if (!this._available) {
      throw new Error('IndexedDB is not available')
    }
    if (this._db) {
      return this._db
    }
    if (this._openPromise) {
      return this._openPromise
    }

    this._openPromise = new Promise((resolve, reject) => {
      let request = indexedDB.open(this._dbName, IDB_SCHEMA_VERSION)
      request.onupgradeneeded = (event) => {
        let db = event.target.result
        BrazenIndexedDBStorage._upgradeSchema(db, event.oldVersion)
      }
      request.onsuccess = () => resolve(request.result)
      request.onerror = () => reject(request.error)
    }).then((db) => {
      this._db = db
      this._db.onversionchange = () => {
        this.close()
      }
      return this._db
    }).finally(() => {
      this._openPromise = null
    })

    return this._openPromise
  }

  /**
   * Drop the open connection so a navigating / bfcache'd document cannot pin the DB
   * against the next page. Callers reopen via {@link open} (async helpers do this).
   */
  close()
  {
    if (!this._db) {
      return
    }
    try {
      this._db.close()
    } catch (e) {
      // ignore already-closed
    }
    this._db = null
  }

  /**
   * @param {IDBDatabase} db
   * @param {number} oldVersion
   * @private
   */
  static _upgradeSchema(db, oldVersion)
  {
    if (!db.objectStoreNames.contains(IDB_STORE_META)) {
      db.createObjectStore(IDB_STORE_META, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_SETTINGS)) {
      db.createObjectStore(IDB_STORE_SETTINGS, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_APIS)) {
      db.createObjectStore(IDB_STORE_APIS, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_TAG_TYPES)) {
      db.createObjectStore(IDB_STORE_TAG_TYPES, {keyPath: 'id'})
    }
    if (!db.objectStoreNames.contains(IDB_STORE_TAG_RULE_SETS)) {
      db.createObjectStore(IDB_STORE_TAG_RULE_SETS, {keyPath: 'id'})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_TAGS)) {
      let tags = db.createObjectStore(IDB_STORE_TAGS, {keyPath: 'entryId'})
      tags.createIndex('name', 'name', {unique: true})
      tags.createIndex('typeEntryId_name', ['typeEntryId', 'name'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_TAG_RULES)) {
      let rules = db.createObjectStore(IDB_STORE_TAG_RULES, {keyPath: 'entryId'})
      rules.createIndex('group_sortOrder', ['group', 'sortOrder'], {unique: false})
      rules.createIndex('group_entryId', ['group', 'entryId'], {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_BOOKMARKS)) {
      let bookmarks = db.createObjectStore(IDB_STORE_BOOKMARKS, {keyPath: 'entryId'})
      bookmarks.createIndex('sortOrder', 'sortOrder', {unique: false})
      bookmarks.createIndex('label', 'label', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_LEDGER)) {
      let ledger = db.createObjectStore(IDB_STORE_LEDGER, {keyPath: 'entryId'})
      ledger.createIndex('postId', 'postId', {unique: true})
      ledger.createIndex('claimedAt', 'claimedAt', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)) {
      let resolutionQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, {keyPath: 'itemId'})
      resolutionQueue.createIndex('status', 'status', {unique: false})
      resolutionQueue.createIndex('addedAt', 'addedAt', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_QUEUE)) {
      let downloadQueue = db.createObjectStore(IDB_STORE_DOWNLOAD_QUEUE, {keyPath: 'itemId'})
      downloadQueue.createIndex('status', 'status', {unique: false})
      downloadQueue.createIndex('addedAt', 'addedAt', {unique: false})
    }

    if (!db.objectStoreNames.contains(IDB_STORE_DOWNLOAD_MANAGER_STATE)) {
      db.createObjectStore(IDB_STORE_DOWNLOAD_MANAGER_STATE, {keyPath: 'id'})
    }

    void oldVersion
  }

  /**
   * @return {Promise<boolean>}
   */
  async isHealthy()
  {
    try {
      await this.open()
      for (let storeName of IDB_ALL_STORES) {
        if (!this._db.objectStoreNames.contains(storeName)) {
          return false
        }
      }
      let meta = await this.get(IDB_STORE_META, 'meta')
      if (!meta || meta.setupComplete !== true) {
        return false
      }
      for (let key of ['nextTagEntryId', 'nextTagRuleEntryId', 'nextBookmarkEntryId', 'nextLedgerEntryId']) {
        if (typeof meta[key] !== 'number' || meta[key] < 1) {
          return false
        }
      }
      let settings = await this.get(IDB_STORE_SETTINGS, 'settings')
      let tagRuleSets = await this.get(IDB_STORE_TAG_RULE_SETS, 'tagRuleSets')
      if (!settings || !tagRuleSets) {
        return false
      }
      return true
    } catch (error) {
      console.log('[BrazenIDB] health check failed:', error)
      return false
    }
  }

  /**
   * @return {Promise<void>}
   */
  async deleteDatabase()
  {
    this.close()
    if (!this._available) {
      return
    }
    // Do not resolve on `onblocked` — that fires while other connections (or this tab's
    // just-closed handle) are still releasing. Resolving early + reload aborts the delete.
    await new Promise((resolve, reject) => {
      let request = indexedDB.deleteDatabase(this._dbName)
      request.onsuccess = () => resolve()
      request.onerror = () => reject(request.error ?? new Error('IndexedDB deleteDatabase failed'))
      request.onblocked = () => {
        console.warn('[BrazenIDB] deleteDatabase blocked; waiting for connections to close:', this._dbName)
      }
    })
  }

  /**
   * @param {string[]} storeNames
   * @param {IDBTransactionMode} mode
   * @return {IDBTransaction}
   */
  transaction(storeNames, mode = 'readonly')
  {
    if (!this._db) {
      throw new Error('IndexedDB is closed; call open() first')
    }
    return this._db.transaction(storeNames, mode)
  }

  /**
   * @param {string} store
   * @param {*} pk
   * @return {Promise<*>}
   */
  async get(store, pk)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).get(pk))
  }

  /**
   * @param {string} store
   * @return {Promise<*[]>}
   */
  async getAll(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).getAll())
  }

  /**
   * @param {string} store
   * @return {Promise<number>}
   */
  async count(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).count())
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<number>}
   */
  async countByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).count(IDBKeyRange.only(key)))
  }

  /**
   * @param {string} store
   * @return {Promise<*[]>}
   */
  async getAllKeys(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).getAllKeys())
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<*[]>}
   */
  async getAllKeysByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).getAllKeys(IDBKeyRange.only(key)))
  }

  /**
   * @param {string} store
   * @param {string} indexName
   * @param {*} key
   * @return {Promise<*[]>}
   */
  async getAllByIndex(store, indexName, key)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    return promisifyRequest(tx.objectStore(store).index(indexName).getAll(IDBKeyRange.only(key)))
  }

  /**
   * First row on an index matching `predicate`, walking in index order.
   * @param {string} store
   * @param {string} indexName
   * @param {IDBKeyRange|null|undefined} range
   * @param {function(*): boolean} predicate
   * @return {Promise<*|null>}
   */
  async findFirstByIndex(store, indexName, range, predicate)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    let index = tx.objectStore(store).index(indexName)
    // Do not pass `null` into openCursor — some environments treat it as an invalid key.
    let request = range == null ? index.openCursor() : index.openCursor(range)
    return new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor) {
          resolve(null)
          return
        }
        if (predicate(cursor.value)) {
          resolve(cursor.value)
          return
        }
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })
  }

  /**
   * @param {string} store
   * @param {*} record
   * @return {Promise<void>}
   */
  async put(store, record)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).put(record)
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @param {*} pk
   * @return {Promise<void>}
   */
  async delete(store, pk)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).delete(pk)
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @return {Promise<void>}
   */
  async clearStore(store)
  {
    await this.open()
    let tx = this.transaction([store], 'readwrite')
    tx.objectStore(store).clear()
    await waitTransaction(tx)
  }

  /**
   * @param {string} store
   * @param {*[]} records
   * @param {number} chunkSize
   * @return {Promise<void>}
   */
  async putMany(store, records, chunkSize = IDB_PUT_MANY_CHUNK)
  {
    if (!records.length) {
      return
    }
    await this.open()
    for (let index = 0; index < records.length; index += chunkSize) {
      let chunk = records.slice(index, index + chunkSize)
      let tx = this.transaction([store], 'readwrite')
      let objectStore = tx.objectStore(store)
      for (let record of chunk) {
        objectStore.put(record)
      }
      await waitTransaction(tx)
      await yieldToBrowser()
    }
  }

  /**
   * @param {string} store
   * @param {IDBKeyRange|null} range
   * @param {number} limit
   * @return {Promise<{entries: *[], nextCursor: *}>}
   */
  async cursorPage(store, range = null, limit = 50, startAfter = null)
  {
    await this.open()
    let tx = this.transaction([store], 'readonly')
    let objectStore = tx.objectStore(store)
    let effectiveRange = range
    if (startAfter !== null && startAfter !== undefined) {
      effectiveRange = range ?
          IDBKeyRange.bound(range.lower, range.upper, range.lowerOpen, range.upperOpen) :
          IDBKeyRange.lowerBound(startAfter, true)
    }
    let request = objectStore.openCursor(effectiveRange)
    let entries = []
    let nextCursor = null

    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let cursor = request.result
        if (!cursor || entries.length >= limit) {
          if (cursor) {
            nextCursor = cursor.key
          }
          resolve()
          return
        }
        entries.push(cursor.value)
        cursor.continue()
      }
      request.onerror = () => reject(request.error)
    })

    return {entries, nextCursor}
  }

  /**
   * @param {string} store
   * @param {number} chunkSize
   * @return {Promise<*[]>}
   */
  async getAllChunked(store, chunkSize = IDB_PUT_MANY_CHUNK)
  {
    let entries = []
    let startAfter = null
    while (true) {
      let page = await this.cursorPage(store, null, chunkSize, startAfter)
      entries.push(...page.entries)
      if (!page.nextCursor) {
        break
      }
      startAfter = page.nextCursor
      await yieldToBrowser()
    }
    return entries
  }

  /**
   * @return {Promise<number>}
   */
  async getRevisionId()
  {
    let meta = await this.getMeta()
    return meta?.revisionId ?? 0
  }

  /**
   * @return {Promise<object>}
   */
  async getMeta()
  {
    return (await this.get(IDB_STORE_META, 'meta')) ?? null
  }

  /**
   * @param {Function} fn
   * @return {Promise<*>}
   */
  async guardedWrite(fn)
  {
    let revisionBefore = await this.getRevisionId()
    let result = await fn()
    let revisionAfter = await this.getRevisionId()
    if (revisionBefore !== revisionAfter) {
      this._revisionId = revisionAfter
    }
    return result
  }

  /**
   * @return {Promise<object>}
   */
  async createDefaultMeta()
  {
    return {
      id: 'meta',
      revisionId: Utilities.generateId(),
      schemaVersion: IDB_SCHEMA_VERSION,
      scriptPrefix: this._scriptPrefix,
      setupComplete: false,
      setupInProgress: false,
      schemaMigrationState: 'idle',
      compilePendingGroups: [],
      nextTagEntryId: 1,
      nextTagRuleEntryId: 1,
      nextBookmarkEntryId: 1,
      nextLedgerEntryId: 1,
      migrationSources: [],
    }
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptySettingsDocument()
  {
    return {id: 'settings'}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyApisDocument()
  {
    return {id: 'apis', entries: []}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyTagTypesDocument()
  {
    return {id: 'tagTypes', entries: []}
  }

  /**
   * @return {Promise<object>}
   */
  async createEmptyTagRuleSetsDocument()
  {
    return {id: 'tagRuleSets'}
  }
}

class MetaRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {function(number|string): void|null} [onRevisionBump]
   */
  constructor(storage, onRevisionBump = null)
  {
    this._storage = storage
    this._onRevisionBump = onRevisionBump
  }

  async get()
  {
    return this._storage.getMeta()
  }

  async put(meta)
  {
    await this._storage.put(IDB_STORE_META, meta)
  }

  async bumpRevision()
  {
    let meta = await this.get()
    if (!meta) {
      return 0
    }
    meta.revisionId = Utilities.generateId()
    await this.put(meta)
    this._storage._revisionId = meta.revisionId
    if (this._onRevisionBump) {
      this._onRevisionBump(meta.revisionId)
    }
    return meta.revisionId
  }

  async waitForSetupComplete(timeoutMs = 120000)
  {
    let started = Date.now()
    while (Date.now() - started < timeoutMs) {
      let meta = await this.get()
      if (meta?.setupComplete && !meta?.setupInProgress) {
        return true
      }
      if (meta?.setupInProgress) {
        await Utilities.sleep(200)
        continue
      }
      return false
    }
    throw new Error('Timed out waiting for setup to complete')
  }

  async beginSetup()
  {
    let meta = await this.get() ?? await this._storage.createDefaultMeta()
    if (meta.setupInProgress) {
      await this.waitForSetupComplete()
      return this.get()
    }
    meta.setupInProgress = true
    meta.schemaMigrationState = 'idle'
    await this.put(meta)
    return meta
  }

  async completeSetup(sources = [])
  {
    let meta = await this.get() ?? await this._storage.createDefaultMeta()
    meta.setupComplete = true
    meta.setupInProgress = false
    meta.migratedAt = Date.now()
    meta.migrationSources = sources
    await this.put(meta)
    return meta
  }

  async markCompilePending(groups)
  {
    let meta = await this.get()
    if (!meta) {
      return
    }
    let pending = new Set(meta.compilePendingGroups ?? [])
    for (let group of groups) {
      pending.add(group)
    }
    meta.compilePendingGroups = [...pending]
    await this.put(meta)
  }

  async clearCompilePending(groups)
  {
    let meta = await this.get()
    if (!meta) {
      return
    }
    let pending = new Set(meta.compilePendingGroups ?? [])
    for (let group of groups) {
      pending.delete(group)
    }
    meta.compilePendingGroups = [...pending]
    await this.put(meta)
  }

  async allocateEntryId(counterKey)
  {
    let meta = await this.get()
    if (!meta) {
      throw new Error('Meta record missing')
    }
    let value = meta[counterKey] ?? 1
    meta[counterKey] = value + 1
    await this.put(meta)
    return value
  }
}

class SettingsRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   */
  constructor(storage)
  {
    this._storage = storage
  }

  async getDocument()
  {
    return (await this._storage.get(IDB_STORE_SETTINGS, 'settings')) ?? {id: 'settings'}
  }

  async putDocument(doc)
  {
    doc.id = 'settings'
    await this._storage.put(IDB_STORE_SETTINGS, doc)
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{value: *, optimized: *, updatedAt: number}|null>}
   */
  async getField(fieldKey)
  {
    let doc = await this.getDocument()
    let property = fieldKeyToProperty(fieldKey)
    return doc[property] ?? null
  }

  async getValue(fieldKey)
  {
    let field = await this.getField(fieldKey)
    return field?.value ?? null
  }

  async getOptimized(fieldKey)
  {
    let field = await this.getField(fieldKey)
    return field?.optimized ?? null
  }

  /**
   * @param {string} fieldKey
   * @param {*} value
   * @param {*} optimized
   * @return {Promise<void>}
   */
  async putField(fieldKey, value, optimized = null)
  {
    let doc = await this.getDocument()
    let property = fieldKeyToProperty(fieldKey)
    doc[property] = {
      value,
      optimized,
      updatedAt: Date.now(),
    }
    await this.putDocument(doc)
  }
}

class BookmarkRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('bookmarks')
    }
  }

  async listAll()
  {
    let rows = await this._storage.getAll(IDB_STORE_BOOKMARKS)
    return rows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
  }

  async get(entryId)
  {
    return this._storage.get(IDB_STORE_BOOKMARKS, entryId)
  }

  async add(entry)
  {
    let entryId = entry.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
    let now = Date.now()
    let row = {
      entryId,
      label: entry.label ?? '',
      tags: entry.tags ?? '',
      url: entry.url ?? '',
      sortOrder: entry.sortOrder ?? 0,
      createdAt: entry.createdAt ?? now,
      updatedAt: now,
    }
    await this._storage.put(IDB_STORE_BOOKMARKS, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return row
  }

  async update(row)
  {
    row.updatedAt = Date.now()
    await this._storage.put(IDB_STORE_BOOKMARKS, row)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return row
  }

  async remove(entryId)
  {
    await this._storage.delete(IDB_STORE_BOOKMARKS, entryId)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }

  async replaceAll(rows)
  {
    await this._storage.clearStore(IDB_STORE_BOOKMARKS)
    let sortOrder = 0
    let normalized = []
    for (let row of rows) {
      let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextBookmarkEntryId')
      normalized.push({
        entryId,
        label: row.label ?? '',
        tags: row.tags ?? '',
        url: row.url ?? '',
        sortOrder: row.sortOrder ?? sortOrder++,
        createdAt: row.createdAt ?? Date.now(),
        updatedAt: row.updatedAt ?? Date.now(),
      })
    }
    await this._storage.putMany(IDB_STORE_BOOKMARKS, normalized)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }
}

class LedgerRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('ledger')
    }
  }

  async has(postId)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
    let index = tx.objectStore(IDB_STORE_LEDGER).index('postId')
    return !!(await promisifyRequest(index.get(String(postId))))
  }

  /**
   * Batch membership check via the unique `postId` index (no getAll).
   * @param {string[]} postIds
   * @return {Promise<Set<string>>} ids that exist in the ledger
   */
  async hasMany(postIds)
  {
    let unique = [...new Set(postIds.map((id) => String(id).trim()).filter(Boolean))]
    let hits = new Set()
    if (!unique.length) {
      return hits
    }
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
    let index = tx.objectStore(IDB_STORE_LEDGER).index('postId')
    await Promise.all(unique.map(async (postId) => {
      let row = await promisifyRequest(index.get(postId))
      if (row) {
        hits.add(postId)
      }
    }))
    return hits
  }

  async claim(postId)
  {
    let normalized = String(postId).trim()
    if (!normalized) {
      return false
    }
    if (await this.has(normalized)) {
      return false
    }
    let entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
    await this._storage.put(IDB_STORE_LEDGER, {
      entryId,
      postId: normalized,
      claimedAt: Date.now(),
    })
    await this._metaRepo.bumpRevision()
    this._notifyChange()
    return true
  }

  async mergeRows(rows)
  {
    await this._storage.open()
    for (let row of rows) {
      let postId = String(row.postId ?? row.id ?? '').trim()
      if (!postId) {
        continue
      }
      let tx = this._storage.transaction([IDB_STORE_LEDGER], 'readonly')
      let existing = await promisifyRequest(tx.objectStore(IDB_STORE_LEDGER).index('postId').get(postId))
      if (!existing || (row.claimedAt ?? 0) >= (existing.claimedAt ?? 0)) {
        if (!row.entryId) {
          row.entryId = await this._metaRepo.allocateEntryId('nextLedgerEntryId')
        }
        row.postId = postId
        await this._storage.put(IDB_STORE_LEDGER, row)
      }
    }
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }

  async replaceAll(rows)
  {
    await this._storage.clearStore(IDB_STORE_LEDGER)
    let normalized = []
    for (let row of rows) {
      let postId = String(row.postId ?? row.id ?? '').trim()
      if (!postId) {
        continue
      }
      let entryId = row.entryId ?? await this._metaRepo.allocateEntryId('nextLedgerEntryId')
      normalized.push({
        entryId,
        postId,
        claimedAt: row.claimedAt ?? Date.now(),
      })
    }
    await this._storage.putMany(IDB_STORE_LEDGER, normalized)
    await this._metaRepo.bumpRevision()
    this._notifyChange()
  }
}

/**
 * @return {object}
 */
function createDefaultDownloadManagerState()
{
  return {
    id: 'state',
    paused: true,
    resolutionBlocked: false,
    resolutionBlockedItemId: null,
    humanInteractionBlocked: false,
    humanInteractionContext: null,
    humanInteractionItemId: null,
    humanInteractionPromptTabId: null,
    humanInteractionOpenUrl: null,
    processingTabId: null,
    processingHeartbeatAt: 0,
    lastResolutionInitiationAt: 0,
    lastDownloadInitiationAt: 0,
    tagDiscoveryEnabled: false,
    tagDiscoveryPanelTabId: null,
    discoveryPanelTags: null,
    discoveryPanelKnownTags: null,
    /** `'unknown'` | `'ignoredPins'` | null — why the discovery panel is open. */
    discoveryReviewMode: null,
    pendingImmediateDownload: null,
    completedResolutionCount: 0,
    completedDownloadCount: 0,
  }
}

/** Terminal resolution-queue statuses (must match Download Manager). */
const RESOLUTION_QUEUE_TERMINAL_STATUSES = ['failed', 'done']
const RESOLUTION_QUEUE_TERMINAL_SET = new Set(RESOLUTION_QUEUE_TERMINAL_STATUSES)
/** Non-terminal resolution-queue statuses used for key listing. */
const RESOLUTION_QUEUE_ACTIVE_STATUSES = ['queued', 'resolving', 'tagReview']
/** Terminal download-queue statuses (must match Download Manager). */
const DOWNLOAD_QUEUE_TERMINAL_STATUSES = ['done', 'skipped', 'duplicate', 'failed']
const DOWNLOAD_QUEUE_TERMINAL_SET = new Set(DOWNLOAD_QUEUE_TERMINAL_STATUSES)
/** Non-terminal download-queue statuses used for key listing. */
const DOWNLOAD_QUEUE_ACTIVE_STATUSES = ['queued', 'downloading']

/**
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} terminalStatuses
 * @param {Set<string>} terminalSet
 * @return {Promise<number>}
 */
async function countActiveQueueRows(storage, store, terminalStatuses, terminalSet)
{
  try {
    let total = await storage.count(store)
    let terminal = 0
    for (let status of terminalStatuses) {
      terminal += await storage.countByIndex(store, 'status', status)
    }
    return Math.max(0, total - terminal)
  } catch (error) {
    let rows = await storage.getAll(store)
    return rows.filter((row) => !terminalSet.has(row.status)).length
  }
}

/**
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @param {string[]} activeStatuses
 * @param {Set<string>} terminalSet
 * @return {Promise<string[]>}
 */
async function listActiveQueueItemIds(storage, store, activeStatuses, terminalSet)
{
  try {
    let ids = []
    for (let status of activeStatuses) {
      let keys = await storage.getAllKeysByIndex(store, 'status', status)
      for (let key of keys) {
        ids.push(String(key))
      }
    }
    return ids
  } catch (error) {
    let rows = await storage.getAll(store)
    return rows.filter((row) => !terminalSet.has(row.status)).map((row) => String(row.itemId))
  }
}

/**
 * Oldest `queued` row. Prefers the status index (does not require `addedAt` index membership).
 * @param {BrazenIndexedDBStorage} storage
 * @param {string} store
 * @return {Promise<object|null>}
 */
async function peekNextQueuedRow(storage, store)
{
  try {
    let rows = await storage.getAllByIndex(store, 'status', 'queued')
    if (!rows.length) {
      return null
    }
    let best = rows[0]
    for (let index = 1; index < rows.length; index++) {
      let row = rows[index]
      if ((row.addedAt ?? 0) < (best.addedAt ?? 0)) {
        best = row
      }
    }
    return best
  } catch (error) {
    let rows = await storage.getAll(store)
    rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
    return rows.find((row) => row.status === 'queued') ?? null
  }
}

class DownloadResolutionQueueRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('downloadResolutionQueue')
    }
  }

  async listAll()
  {
    let rows = await this._storage.getAll(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)
    return rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
  }

  /**
   * Non-terminal row count without materializing fat queue payloads.
   * @return {Promise<number>}
   */
  async countActive()
  {
    return countActiveQueueRows(
        this._storage,
        IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
        RESOLUTION_QUEUE_TERMINAL_STATUSES,
        RESOLUTION_QUEUE_TERMINAL_SET,
    )
  }

  /**
   * Primary keys for non-terminal rows (no row bodies).
   * @return {Promise<string[]>}
   */
  async listActiveItemIds()
  {
    return listActiveQueueItemIds(
        this._storage,
        IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE,
        RESOLUTION_QUEUE_ACTIVE_STATUSES,
        RESOLUTION_QUEUE_TERMINAL_SET,
    )
  }

  /**
   * Oldest `queued` row by `addedAt`, or null.
   * @return {Promise<object|null>}
   */
  async peekNextQueued()
  {
    return peekNextQueuedRow(this._storage, IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)
  }

  async get(itemId)
  {
    return this._storage.get(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, itemId)
  }

  async put(row)
  {
    await this._storage.put(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, row)
    this._notifyChange()
    return row
  }

  async remove(itemId)
  {
    await this._storage.delete(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE, itemId)
    this._notifyChange()
  }

  async clearAll()
  {
    await this._storage.clearStore(IDB_STORE_DOWNLOAD_RESOLUTION_QUEUE)
    this._notifyChange()
  }
}

class DownloadQueueRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('downloadQueue')
    }
  }

  async listAll()
  {
    let rows = await this._storage.getAll(IDB_STORE_DOWNLOAD_QUEUE)
    return rows.sort((left, right) => (left.addedAt ?? 0) - (right.addedAt ?? 0))
  }

  /**
   * Non-terminal row count without materializing fat queue payloads.
   * @return {Promise<number>}
   */
  async countActive()
  {
    return countActiveQueueRows(
        this._storage,
        IDB_STORE_DOWNLOAD_QUEUE,
        DOWNLOAD_QUEUE_TERMINAL_STATUSES,
        DOWNLOAD_QUEUE_TERMINAL_SET,
    )
  }

  /**
   * Primary keys for non-terminal rows (no row bodies).
   * @return {Promise<string[]>}
   */
  async listActiveItemIds()
  {
    return listActiveQueueItemIds(
        this._storage,
        IDB_STORE_DOWNLOAD_QUEUE,
        DOWNLOAD_QUEUE_ACTIVE_STATUSES,
        DOWNLOAD_QUEUE_TERMINAL_SET,
    )
  }

  /**
   * Oldest `queued` row by `addedAt`, or null.
   * @return {Promise<object|null>}
   */
  async peekNextQueued()
  {
    return peekNextQueuedRow(this._storage, IDB_STORE_DOWNLOAD_QUEUE)
  }

  async get(itemId)
  {
    return this._storage.get(IDB_STORE_DOWNLOAD_QUEUE, itemId)
  }

  async put(row)
  {
    await this._storage.put(IDB_STORE_DOWNLOAD_QUEUE, row)
    this._notifyChange()
    return row
  }

  async remove(itemId)
  {
    await this._storage.delete(IDB_STORE_DOWNLOAD_QUEUE, itemId)
    this._notifyChange()
  }

  async clearAll()
  {
    await this._storage.clearStore(IDB_STORE_DOWNLOAD_QUEUE)
    this._notifyChange()
  }
}

class DownloadManagerStateRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {function(string): void|null} onChange
   */
  constructor(storage, metaRepo, onChange = null)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._onChange = onChange
  }

  /**
   * @private
   */
  _notifyChange()
  {
    if (this._onChange) {
      this._onChange('downloadManagerState')
    }
  }

  async get()
  {
    return (await this._storage.get(IDB_STORE_DOWNLOAD_MANAGER_STATE, 'state')) ?? createDefaultDownloadManagerState()
  }

  async put(state)
  {
    state.id = 'state'
    await this._storage.put(IDB_STORE_DOWNLOAD_MANAGER_STATE, state)
    this._notifyChange()
    return state
  }

  async reset()
  {
    return this.put(createDefaultDownloadManagerState())
  }
}

class TagRuleRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   */
  constructor(storage, metaRepo)
  {
    this._storage = storage
    this._metaRepo = metaRepo
  }

  async listByGroup(group)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAG_RULES], 'readonly')
    let index = tx.objectStore(IDB_STORE_TAG_RULES).index('group_sortOrder')
    let range = IDBKeyRange.bound([group, 0], [group, Number.MAX_SAFE_INTEGER])
    return promisifyRequest(index.getAll(range))
  }

  /**
   * @param {string} group
   * @param {number|null} cursor
   * @param {number} limit
   * @return {Promise<{entries: *[], nextCursor: number|null}>}
   */
  async listPaginated(group, cursor = null, limit = 50)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAG_RULES], 'readonly')
    let index = tx.objectStore(IDB_STORE_TAG_RULES).index('group_entryId')
    let lower = cursor === null ? [group, 0] : [group, cursor]
    let range = IDBKeyRange.bound(lower, [group, Number.MAX_SAFE_INTEGER])
    let request = index.openCursor(range)
    let entries = []
    let nextCursor = null

    await new Promise((resolve, reject) => {
      request.onsuccess = () => {
        let row = request.result
        if (!row || entries.length >= limit) {
          if (row) {
            nextCursor = row.value.entryId
          }
          resolve()
          return
        }
        entries.push(row.value)
        row.continue()
      }
      request.onerror = () => reject(request.error)
    })

    return {entries, nextCursor}
  }

  /**
   * @param {string} group
   * @param {string} tagName
   * @return {Promise<boolean>}
   */
  async hasSoleTagRule(group, tagName)
  {
    let rules = await this.listByGroup(group)
    return rules.some((rule) => TagRuleRepository._isSoleTagRule(rule, tagName))
  }

  async addRule(group, rule)
  {
    let entryId = rule.entryId ?? await this._metaRepo.allocateEntryId('nextTagRuleEntryId')
    let row = {
      entryId,
      group,
      sortOrder: rule.sortOrder ?? 0,
      rawLine: rule.rawLine ?? '',
      mode: rule.mode ?? 'tags',
      selectors: rule.selectors ?? null,
      expression: rule.expression ?? null,
      comment: rule.comment ?? '',
      updatedAt: Date.now(),
    }
    await this._storage.put(IDB_STORE_TAG_RULES, row)
    await this._metaRepo.markCompilePending([group])
    await this._metaRepo.bumpRevision()
    return row
  }

  async removeRule(entryId, group = null)
  {
    await this._storage.delete(IDB_STORE_TAG_RULES, entryId)
    if (group) {
      await this._metaRepo.markCompilePending([group])
    }
    await this._metaRepo.bumpRevision()
  }

  async removeAllInGroup(group)
  {
    let rules = await this.listByGroup(group)
    for (let rule of rules) {
      await this._storage.delete(IDB_STORE_TAG_RULES, rule.entryId)
    }
    if (rules.length) {
      await this._metaRepo.markCompilePending([group])
      await this._metaRepo.bumpRevision()
    }
  }

  async clearAll()
  {
    await this._storage.guardedWrite(async () => {
      await this._storage.clearStore(IDB_STORE_TAG_RULES)
      await this._metaRepo.bumpRevision()
    })
  }

  async removeSoleTagRules(group, tagName)
  {
    let rules = await this.listByGroup(group)
    let removed = false
    for (let rule of rules) {
      if (TagRuleRepository._isSoleTagRule(rule, tagName)) {
        await this.removeRule(rule.entryId, group)
        removed = true
      }
    }
    if (removed) {
      await this._metaRepo.markCompilePending([group])
    }
  }

  /**
   * @param {*} rule
   * @param {string} tagName
   * @return {boolean}
   * @private
   */
  static _isSoleTagRule(rule, tagName)
  {
    let raw = String(rule.rawLine ?? '').split('//')[0].trim()
    if (!raw || raw.includes('&') || raw.includes('|')) {
      return false
    }
    return raw.trim() === tagName
  }
}

class TagRepository
{
  /**
   * @param {BrazenIndexedDBStorage} storage
   * @param {MetaRepository} metaRepo
   * @param {TagRuleRepository} tagRuleRepo
   */
  constructor(storage, metaRepo, tagRuleRepo)
  {
    this._storage = storage
    this._metaRepo = metaRepo
    this._tagRuleRepo = tagRuleRepo
  }

  async getByName(name)
  {
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAGS], 'readonly')
    let row = await promisifyRequest(tx.objectStore(IDB_STORE_TAGS).index('name').get(name))
    return normalizeTagEntry(row)
  }

  /**
   * @param {string[]} names
   * @return {Promise<Map<string, *>>} name → entry for rows that exist
   */
  async getByNames(names)
  {
    let unique = [...new Set(names.filter(Boolean))]
    let map = new Map()
    if (!unique.length) {
      return map
    }
    await this._storage.open()
    let tx = this._storage.transaction([IDB_STORE_TAGS], 'readonly')
    let index = tx.objectStore(IDB_STORE_TAGS).index('name')
    await Promise.all(unique.map(async (name) => {
      let row = await promisifyRequest(index.get(name))
      if (row) {
        map.set(name, normalizeTagEntry(row))
      }
    }))
    return map
  }

  async getByEntryId(entryId)
  {
    return normalizeTagEntry(await this._storage.get(IDB_STORE_TAGS, entryId))
  }

  /**
   * @param {number[]} entryIds
   * @return {Promise<Map<number, *>>}
   */
  async getByEntryIds(entryIds)
  {
    let unique = [...new Set(entryIds.filter((id) => id != null))]
    let map = new Map()
    if (!unique.length) {
      return map
    }
    await Promise.all(unique.map(async (entryId) => {
      let row = await this._storage.get(IDB_STORE_TAGS, entryId)
      if (row) {
        map.set(entryId, normalizeTagEntry(row))
      }
    }))
    return map
  }

  async putTag(row)
  {
    normalizeTagEntry(row)
    row.meta.updatedAt = Date.now()
    await this._storage.guardedWrite(async () => {
      await this._storage.put(IDB_STORE_TAGS, row)
      await this._metaRepo.bumpRevision()
    })
    return row
  }

  async registerTag(name, typeEntryId = null)
  {
    let existing = await this.getByName(name)
    if (existing) {
      if (typeEntryId != null && existing.typeEntryId == null) {
        existing.typeEntryId = typeEntryId
      }
      existing.meta.updatedAt = Date.now()
      return this.putTag(existing)
    }
    let entryId = await this._metaRepo.allocateEntryId('nextTagEntryId')
    let now = Date.now()
    let row = normalizeTagEntry({
      entryId,
      name,
      typeEntryId,
      compliance: {
        blacklisted: false,
        explored: false,
      },
      download: {
        filenameIgnored: false,
        substitution: null,
      },
      meta: {
        createdAt: now,
        updatedAt: now,
      },
    })
    await this._storage.guardedWrite(async () => {
      await this._storage.put(IDB_STORE_TAGS, row)
      await this._metaRepo.bumpRevision()
    })
    return row
  }

  /**
   * Resolves a canonical tag type name to its {@link IDB_STORE_TAG_TYPES} entryId.
   * Alias entries (e.g. site "artist" → canonical author) return the canonical id.
   *
   * @param {string|null|undefined} typeName
   * @return {Promise<number|null>}
   */
  async resolveCanonicalTypeEntryId(typeName)
  {
    if (!typeName) {
      return null
    }
    let doc = await this._storage.get(IDB_STORE_TAG_TYPES, 'tagTypes')
    let entries = doc?.entries ?? []
    let entry = entries.find((row) => row.name === typeName)
    if (!entry) {
      return null
    }
    if (entry.aliasOfEntryIds?.length) {
      return entry.aliasOfEntryIds[0] ?? null
    }
    return entry.entryId ?? null
  }

  /**
   * @param {{cursor?: number|string|null, limit?: number, typeEntryId?: number|null, namePrefix?: string|null}} options
   *        For `typeEntryId` pages, `cursor` is the last included tag `name` (exclusive start for the next page).
   * @return {Promise<{entries: *[], nextCursor?: number|string}>}
   */
  async listTags(options = {})
  {
    let limit = options.limit ?? 50
    let store = IDB_STORE_TAGS
    if (options.namePrefix) {
      let prefix = String(options.namePrefix)
      let range = IDBKeyRange.bound(prefix, prefix + '\uffff')
      let page = await this._storage.cursorPage(store, range, limit, options.cursor ?? null)
      return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
    }
    if (options.typeEntryId !== null && options.typeEntryId !== undefined) {
      await this._storage.open()
      let tx = this._storage.transaction([store], 'readonly')
      let index = tx.objectStore(store).index('typeEntryId_name')
      let typeId = options.typeEntryId
      let range = options.cursor != null && options.cursor !== ''
          ? IDBKeyRange.bound([typeId, String(options.cursor)], [typeId, '\uffff'], true, false)
          : IDBKeyRange.bound([typeId, ''], [typeId, '\uffff'])
      let request = index.openCursor(range)
      let entries = []
      let nextCursor = null
      await new Promise((resolve, reject) => {
        request.onsuccess = () => {
          let row = request.result
          if (!row) {
            resolve()
            return
          }
          if (entries.length >= limit) {
            nextCursor = entries[entries.length - 1]?.name ?? null
            resolve()
            return
          }
          entries.push(row.value)
          row.continue()
        }
        request.onerror = () => reject(request.error)
      })
      return {entries, nextCursor: nextCursor ?? undefined}
    }
    let page = await this._storage.cursorPage(store, null, limit, options.cursor ?? null)
    return {entries: page.entries, nextCursor: page.nextCursor ?? undefined}
  }

  async clearAll()
  {
    await this._storage.guardedWrite(async () => {
      await this._storage.clearStore(IDB_STORE_TAGS)
      await this._metaRepo.bumpRevision()
    })
  }

  async resetCompiledRuleSets()
  {
    await this._storage.guardedWrite(async () => {
      await this.putTagRuleSetsDocument(await this._storage.createEmptyTagRuleSetsDocument())
      await this._metaRepo.bumpRevision()
    })
  }

  async getTagRuleSetsDocument()
  {
    return (await this._storage.get(IDB_STORE_TAG_RULE_SETS, 'tagRuleSets')) ?? {id: 'tagRuleSets'}
  }

  async putTagRuleSetsDocument(doc)
  {
    doc.id = 'tagRuleSets'
    await this._storage.put(IDB_STORE_TAG_RULE_SETS, doc)
  }

  async getCompiledField(fieldKey)
  {
    let property = TAG_RULESET_PROPERTY_BY_FIELD[fieldKey]
    if (!property) {
      return null
    }
    let doc = await this.getTagRuleSetsDocument()
    return doc[property] ?? null
  }

  /**
   * @param {string} fieldKey
   * @param {string[]} rawLines
   * @param {*} optimized
   * @return {Promise<void>}
   */
  async putCompiledField(fieldKey, rawLines, optimized)
  {
    let property = TAG_RULESET_PROPERTY_BY_FIELD[fieldKey]
    if (!property) {
      return
    }
    let doc = await this.getTagRuleSetsDocument()
    doc[property] = {
      rawLines,
      optimized,
      updatedAt: Date.now(),
    }
    await this.putTagRuleSetsDocument(doc)
  }

  /**
   * @param {string} group
   * @param {Function} optimizeFn
   * @param {boolean} useSelectors
   * @return {Promise<void>}
   */
  async compileGroup(group, optimizeFn, useSelectors = false)
  {
    let fieldKey = Object.entries(TAG_RULE_GROUP_BY_FIELD).find(([, g]) => g === group)?.[0]
    if (!fieldKey) {
      return
    }
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (spec?.usesTaggingRegistry) {
      let rawLines = await this._projectTaggingFieldLines(fieldKey, group, spec)
      let optimized = null
      if (spec.usesCombos) {
        let rules = await this._tagRuleRepo.listByGroup(group)
        let combos = rules.
            filter((rule) => Array.isArray(rule.expression?.tagEntryIds) && rule.expression.tagEntryIds.length >= 2).
            map((rule) => ({tagEntryIds: [...rule.expression.tagEntryIds]}))
        combos.sort((left, right) => left.tagEntryIds.length - right.tagEntryIds.length)
        optimized = {combos}
      }
      await this.putCompiledField(fieldKey, rawLines, optimized)
      await this._metaRepo.clearCompilePending([group])
      return
    }
    let rules = await this._tagRuleRepo.listByGroup(group)
    let rawLines = rules.map((rule) => rule.rawLine).filter(Boolean)
    let optimized = optimizeFn(rawLines, useSelectors)
    await this.putCompiledField(fieldKey, rawLines, optimized)
    await this._metaRepo.clearCompilePending([group])
  }

  /**
   * @param {string} fieldKey
   * @param {string} group
   * @param {*} spec
   * @return {Promise<string[]>}
   * @private
   */
  async _projectTaggingFieldLines(fieldKey, group, spec)
  {
    let lines = []
    let all = await this._storage.getAll(IDB_STORE_TAGS)
    if (spec.substitution) {
      for (let entry of all) {
        normalizeTagEntry(entry)
        if (entry.download?.substitution?.replacementName) {
          lines.push(entry.name + ' → ' + entry.download.substitution.replacementName)
        }
      }
    } else if (spec.soleAttribute) {
      for (let entry of all) {
        normalizeTagEntry(entry)
        if (readTagSoleAttribute(entry, spec.soleAttribute)) {
          lines.push(entry.name)
        }
      }
      lines.sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'}))
    }
    if (spec.usesCombos) {
      let rules = await this._tagRuleRepo.listByGroup(group)
      lines.push(...rules.map((rule) => rule.rawLine).filter(Boolean))
    }
    return lines
  }

  async compilePendingGroups(optimizeFn, useSelectorsByGroup = {})
  {
    let meta = await this._metaRepo.get()
    let groups = meta?.compilePendingGroups ?? []
    for (let group of groups) {
      await this.compileGroup(group, optimizeFn, !!useSelectorsByGroup[group])
    }
  }
}

class TagRuntime
{
  /**
   * @param {TagRepository} tagRepo
   * @param {TagRuleRepository} tagRuleRepo
   */
  constructor(tagRepo, tagRuleRepo)
  {
    this._tagRepo = tagRepo
    this._tagRuleRepo = tagRuleRepo
    /** @type {Map<string, *>} */
    this._byName = new Map()
    /** @type {Map<string, string>} lower(name) → canonical cache key in `_byName` (legacy casing). */
    this._byNameLower = new Map()
    /** @type {Map<number, *>} */
    this._byEntryId = new Map()
    /** @type {Set<string>} Names confirmed absent from IDB (avoid repeat misses). */
    this._missingNames = new Set()
    /** @type {string[]} Insertion order for LRU eviction of `_byName`. */
    this._cacheOrder = []
    this._warmed = false
  }

  clearCache()
  {
    this._byName.clear()
    this._byNameLower.clear()
    this._byEntryId.clear()
    this._missingNames.clear()
    this._cacheOrder = []
    this._warmed = false
  }

  /**
   * @param {*} entry
   * @private
   */
  _cacheEntry(entry)
  {
    if (!entry?.name) {
      return
    }
    normalizeTagEntry(entry)
    delete entry._optimistic
    let name = entry.name
    this._missingNames.delete(name)
    this._missingNames.delete(name.toLowerCase())
    if (!this._byName.has(name)) {
      this._cacheOrder.push(name)
    }
    this._byName.set(name, entry)
    this._byNameLower.set(name.toLowerCase(), name)
    if (entry.entryId != null) {
      this._byEntryId.set(entry.entryId, entry)
    }
    this._evictCacheIfNeeded()
  }

  /**
   * Ensure a sync-readable cache row exists for attribute UI (no IDB I/O).
   * Persisted later via {@link ensureTag} / {@link patchAttributes}.
   *
   * @param {string} name
   * @return {*}
   */
  primeOptimisticEntry(name)
  {
    if (!name) {
      return null
    }
    let existing = this.resolveCachedByName(name)
    if (existing) {
      this._missingNames.delete(name)
      this._missingNames.delete(String(name).toLowerCase())
      return existing
    }
    this._missingNames.delete(name)
    this._missingNames.delete(String(name).toLowerCase())
    let now = Date.now()
    let entry = normalizeTagEntry({
      entryId: null,
      name,
      typeEntryId: null,
      compliance: {blacklisted: false, explored: false},
      download: {filenameIgnored: false, substitution: null},
      meta: {createdAt: now, updatedAt: now},
      _optimistic: true,
    })
    this._byName.set(name, entry)
    this._byNameLower.set(String(name).toLowerCase(), name)
    this._cacheOrder.push(name)
    this._evictCacheIfNeeded()
    this._warmed = true
    return entry
  }

  /**
   * @private
   */
  _evictCacheIfNeeded()
  {
    while (this._byName.size > TAG_RUNTIME_CACHE_MAX && this._cacheOrder.length) {
      let evictName = this._cacheOrder.shift()
      let entry = this._byName.get(evictName)
      this._byName.delete(evictName)
      if (evictName) {
        let lower = evictName.toLowerCase()
        if (this._byNameLower.get(lower) === evictName) {
          this._byNameLower.delete(lower)
        }
      }
      if (entry?.entryId != null) {
        this._byEntryId.delete(entry.entryId)
      }
    }
    while (this._missingNames.size > TAG_RUNTIME_CACHE_MAX) {
      let first = this._missingNames.values().next().value
      this._missingNames.delete(first)
    }
  }

  /**
   * Marks the runtime ready for sync readers. Does **not** load the full tags table —
   * use {@link ensureNames} / {@link ensureEntryIds} / {@link ensureComplianceLookups}.
   *
   * @return {Promise<void>}
   */
  async warmCache()
  {
    this._warmed = true
  }

  /**
   * Load the given tag names from IndexedDB into the bounded cache (skips already cached / known-missing).
   *
   * @param {Iterable<string>} names
   * @return {Promise<void>}
   */
  async ensureNames(names)
  {
    let missing = []
    for (let name of names) {
      if (!name || this.resolveCachedByName(name) || this._missingNames.has(name) ||
          this._missingNames.has(String(name).toLowerCase())) {
        continue
      }
      missing.push(name)
    }
    if (!missing.length) {
      this._warmed = true
      return
    }
    let found = await this._tagRepo.getByNames(missing)
    for (let name of missing) {
      let entry = found.get(name)
      if (entry) {
        this._cacheEntry(entry)
      } else {
        this._missingNames.add(name)
        this._missingNames.add(String(name).toLowerCase())
      }
    }
    this._evictCacheIfNeeded()
    this._warmed = true
  }

  /**
   * @param {Iterable<number>} entryIds
   * @return {Promise<void>}
   */
  async ensureEntryIds(entryIds)
  {
    let missing = []
    for (let entryId of entryIds) {
      if (entryId == null || this._byEntryId.has(entryId)) {
        continue
      }
      missing.push(entryId)
    }
    if (!missing.length) {
      this._warmed = true
      return
    }
    let found = await this._tagRepo.getByEntryIds(missing)
    for (let entry of found.values()) {
      this._cacheEntry(entry)
    }
    this._warmed = true
  }

  /**
   * Prefetch tags needed for sync compliance evaluation of the given item tag lists.
   *
   * @param {Iterable<string[]>} itemTagNameLists
   * @param {Iterable<{soleAttribute?: *, combos?: {tagEntryIds: number[]}[]}>} specs
   * @return {Promise<void>}
   */
  async ensureComplianceLookups(itemTagNameLists, specs)
  {
    let names = new Set()
    for (let list of itemTagNameLists) {
      if (!Array.isArray(list)) {
        continue
      }
      for (let name of list) {
        if (name) {
          names.add(name)
        }
      }
    }
    let entryIds = new Set()
    for (let spec of specs) {
      if (!spec) {
        continue
      }
      for (let combo of spec.combos ?? []) {
        for (let entryId of combo.tagEntryIds ?? []) {
          if (entryId != null) {
            entryIds.add(entryId)
          }
        }
      }
    }
    await this.ensureNames(names)
    await this.ensureEntryIds(entryIds)
  }

  /**
   * @param {string} name
   * @return {*|null}
   */
  getCachedByName(name)
  {
    return this.resolveCachedByName(name)
  }

  /**
   * Resolve a cached tag row by exact name or legacy different casing.
   * Same lookup used by ignore chrome and filename join.
   * @param {string} name
   * @return {*|null}
   */
  resolveCachedByName(name)
  {
    if (!name) {
      return null
    }
    if (this._missingNames.has(name)) {
      return null
    }
    let direct = this._byName.get(name)
    if (direct) {
      return direct
    }
    let lower = String(name).toLowerCase()
    if (this._missingNames.has(lower)) {
      return null
    }
    let canonical = this._byNameLower.get(lower)
    if (!canonical) {
      return null
    }
    return this._byName.get(canonical) ?? null
  }

  /**
   * Filename-ignore flag for download join / Aa button (same cached row).
   * @param {string} name
   * @return {boolean}
   */
  isFilenameIgnoredCached(name)
  {
    return !!this.resolveCachedByName(name)?.download?.filenameIgnored
  }

  /**
   * @param {number} entryId
   * @return {*|null}
   */
  getCachedByEntryId(entryId)
  {
    return this._byEntryId.get(entryId) ?? null
  }

  /**
   * @param {string} name
   * @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null, source?: string}|null} [context]
   * @return {Promise<*>}
   */
  async ensureTag(name, context = null)
  {
    let resolvedTypeId = context?.typeEntryId ?? null
    let needTypeResolve = resolvedTypeId == null && !!context?.typeName
    let seenOnly = context?.source === 'media' || context?.source === 'sidebar'
    let cached = this.getCachedByName(name)
    // Optimistic stubs are sync-only — register a real row, then re-apply stub attrs.
    let optimisticSnapshot = null
    if (cached && (cached._optimistic || cached.entryId == null)) {
      optimisticSnapshot = {
        compliance: {
          blacklisted: !!cached.compliance?.blacklisted,
          explored: !!cached.compliance?.explored,
        },
        download: {
          filenameIgnored: !!cached.download?.filenameIgnored,
          substitution: cached.download?.substitution ?? null,
        },
        lastSeenTypeEntryId: cached.meta?.lastSeenTypeEntryId ?? null,
        typeEntryId: cached.typeEntryId ?? null,
      }
      cached = null
    }
    if (cached) {
      // Skip type resolve/put when the row already has what this call would write.
      let needsSeenType = seenOnly && needTypeResolve
      let needsConfirmType = !seenOnly && cached.typeEntryId == null && (resolvedTypeId != null || needTypeResolve)
      if (!needsSeenType && !needsConfirmType && resolvedTypeId == null && !needTypeResolve) {
        return cached
      }
      if (needsSeenType || needsConfirmType) {
        if (resolvedTypeId == null && context?.typeName) {
          resolvedTypeId = await this._tagRepo.resolveCanonicalTypeEntryId(context.typeName)
        }
        if (resolvedTypeId != null) {
          let mutated = false
          if (seenOnly) {
            if (cached.meta.lastSeenTypeEntryId !== resolvedTypeId) {
              cached.meta.lastSeenTypeEntryId = resolvedTypeId
              mutated = true
            }
          } else if (cached.typeEntryId == null) {
            cached.typeEntryId = resolvedTypeId
            mutated = true
          }
          if (mutated) {
            cached = await this._tagRepo.putTag(cached)
            this._cacheEntry(cached)
          }
        }
      }
      return cached
    }
    if (this._missingNames.has(name)) {
      this._missingNames.delete(name)
    }
    if (resolvedTypeId == null && context?.typeName) {
      resolvedTypeId = await this._tagRepo.resolveCanonicalTypeEntryId(context.typeName)
    }
    let typeForRegister = seenOnly ? null : resolvedTypeId
    if (!seenOnly && typeForRegister == null && optimisticSnapshot?.typeEntryId != null) {
      typeForRegister = optimisticSnapshot.typeEntryId
    }
    let entry = await this._tagRepo.registerTag(name, typeForRegister)
    if (resolvedTypeId != null && seenOnly) {
      entry.meta.lastSeenTypeEntryId = resolvedTypeId
      entry = await this._tagRepo.putTag(entry)
    } else if (seenOnly && optimisticSnapshot?.lastSeenTypeEntryId != null &&
        entry.meta.lastSeenTypeEntryId == null) {
      entry.meta.lastSeenTypeEntryId = optimisticSnapshot.lastSeenTypeEntryId
      entry = await this._tagRepo.putTag(entry)
    }
    if (optimisticSnapshot) {
      entry.compliance.blacklisted = optimisticSnapshot.compliance.blacklisted
      entry.compliance.explored = optimisticSnapshot.compliance.explored
      entry.download.filenameIgnored = optimisticSnapshot.download.filenameIgnored
      if (optimisticSnapshot.download.substitution) {
        entry.download.substitution = optimisticSnapshot.download.substitution
      }
    }
    this._cacheEntry(entry)
    return entry
  }

  /**
   * @param {string[]} names
   * @param {{typeEntryId?: number|null, typeName?: string|null}|null} [context]
   * @return {Promise<Map<string, *>>}
   */
  async ensureTags(names, context = null)
  {
    let map = new Map()
    for (let name of names) {
      if (!name) {
        continue
      }
      map.set(name, await this.ensureTag(name, context))
    }
    return map
  }

  /**
   * @param {string|number} nameOrId
   * @return {Promise<*|null>}
   */
  async getTag(nameOrId)
  {
    if (typeof nameOrId === 'number') {
      let cached = this.getCachedByEntryId(nameOrId)
      if (cached) {
        return cached
      }
      let entry = await this._tagRepo.getByEntryId(nameOrId)
      if (entry) {
        this._cacheEntry(entry)
      }
      return entry
    }
    let cached = this.getCachedByName(nameOrId)
    if (cached) {
      return cached
    }
    if (this._missingNames.has(nameOrId)) {
      return null
    }
    let entry = await this._tagRepo.getByName(nameOrId)
    if (entry) {
      this._cacheEntry(entry)
    } else {
      this._missingNames.add(nameOrId)
      this._evictCacheIfNeeded()
    }
    return entry
  }

  /**
   * @param {string} name
   * @param {*} patch
   * @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null}|null} [context]
   * @return {Promise<*>}
   */
  async patchAttributes(name, patch, context = null)
  {
    let entry = await this.ensureTag(name, context)
    if (patch.typeEntryId !== undefined) {
      entry.typeEntryId = patch.typeEntryId
    }
    if (patch.compliance) {
      if (patch.compliance.blacklisted !== undefined) {
        entry.compliance.blacklisted = !!patch.compliance.blacklisted
      }
      if (patch.compliance.explored !== undefined) {
        entry.compliance.explored = !!patch.compliance.explored
      }
    }
    if (patch.download) {
      if (patch.download.filenameIgnored !== undefined) {
        entry.download.filenameIgnored = !!patch.download.filenameIgnored
      }
      if (patch.download.substitution !== undefined) {
        if (patch.download.substitution === null) {
          entry.download.substitution = null
        } else {
          let replacementName = patch.download.substitution.replacementName ?? context?.replacementName
          if (!replacementName) {
            throw new Error('Tag substitution requires replacementName context')
          }
          let replacement = await this.ensureTag(replacementName, context)
          entry.download.substitution = {
            replacementName: replacement.name,
            replacementTagEntryId: replacement.entryId,
          }
        }
      }
    }
    entry = await this._tagRepo.putTag(entry)
    this._cacheEntry(entry)
    return entry
  }

  /**
   * @param {{root: string, key: string}} soleAttribute
   * @return {Promise<string[]>}
   */
  async listNamesWithSoleAttribute(soleAttribute)
  {
    let rows = await this._tagRepo._storage.getAll(IDB_STORE_TAGS)
    let names = []
    for (let entry of rows) {
      normalizeTagEntry(entry)
      if (readTagSoleAttribute(entry, soleAttribute)) {
        names.push(entry.name)
      }
    }
    return names.sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'}))
  }

  /**
   * @param {string[]} tagNames
   * @param {boolean} [stripCharacterSeries]
   * @return {string[]}
   */
  applyDownloadAttributesSync(tagNames, stripCharacterSeries = false)
  {
    let result = []
    for (let tagName of tagNames) {
      let entry = this.resolveCachedByName(tagName)
      if (!entry) {
        result.push(tagName)
        continue
      }
      // Same ignore read as Aa / hasTagSoleAttribute (via shared cache row).
      if (entry.download?.filenameIgnored) {
        continue
      }
      let effective = tagName
      let substitution = entry.download?.substitution
      if (substitution?.replacementTagEntryId != null) {
        let replacement = this.getCachedByEntryId(substitution.replacementTagEntryId)
        effective = replacement?.name ?? substitution.replacementName ?? tagName
      } else if (substitution?.replacementName) {
        effective = substitution.replacementName
      }
      if (stripCharacterSeries) {
        let stripped = effective.replace(/_\([^)]*\)$/, '')
        if (stripped !== effective) {
          let strippedEntry = this.resolveCachedByName(stripped)
          if (strippedEntry?.download?.filenameIgnored) {
            continue
          }
          if (strippedEntry?.download?.substitution?.replacementTagEntryId != null) {
            let replacement = this.getCachedByEntryId(strippedEntry.download.substitution.replacementTagEntryId)
            effective = replacement?.name ?? strippedEntry.download.substitution.replacementName ?? stripped
          } else if (strippedEntry?.download?.substitution?.replacementName) {
            effective = strippedEntry.download.substitution.replacementName
          } else {
            effective = stripped
          }
        }
      }
      result.push(effective)
    }
    return result
  }

  /**
   * @param {{tagEntryIds: number[]}} combo
   * @param {Set<number>} postEntryIds
   * @return {boolean}
   */
  evaluateComboExpression(combo, postEntryIds)
  {
    if (!combo?.tagEntryIds?.length) {
      return false
    }
    for (let entryId of combo.tagEntryIds) {
      if (!postEntryIds.has(entryId)) {
        return false
      }
    }
    return true
  }

  /**
   * @param {string[]} itemTagNames
   * @param {{soleAttribute: {root: string, key: string}, combos: {tagEntryIds: number[]}[]}} spec
   * @return {{complies: boolean, rule?: string}}
   */
  evaluateComplianceSync(itemTagNames, spec)
  {
    let postEntryIds = new Set()
    for (let name of itemTagNames) {
      let entry = this.getCachedByName(name)
      if (entry?.entryId != null) {
        postEntryIds.add(entry.entryId)
        if (spec.soleAttribute && readTagSoleAttribute(entry, spec.soleAttribute)) {
          return {complies: false, rule: name}
        }
      }
    }
    for (let combo of spec.combos ?? []) {
      if (this.evaluateComboExpression(combo, postEntryIds)) {
        let labels = combo.tagEntryIds.map((entryId) => this.getCachedByEntryId(entryId)?.name ?? String(entryId))
        return {complies: false, rule: labels.join(' & ')}
      }
    }
    return {complies: true}
  }

  /**
   * @param {string} fieldKey
   * @return {Promise<{soleAttribute: {root: string, key: string}|null, combos: {tagEntryIds: number[]}[]}>}
   */
  async getComplianceSpecForField(fieldKey)
  {
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (!spec) {
      return {soleAttribute: null, combos: []}
    }
    let compiled = await this._tagRepo.getCompiledField(fieldKey)
    let combos = compiled?.optimized?.combos ?? []
    return {
      soleAttribute: spec.soleAttribute ?? null,
      combos,
    }
  }

  /**
   * @param {string} group
   * @param {string} rawLine
   * @param {number} sortOrder
   * @param {boolean} useSelectors
   * @return {Promise<void>}
   */
  async importComboRuleLine(group, rawLine, sortOrder, useSelectors)
  {
    let parts = String(rawLine).split('//')[0].split('&').map((part) => part.trim()).filter(Boolean)
    let tagEntryIds = []
    for (let part of parts) {
      let entry = await this.ensureTag(part)
      tagEntryIds.push(entry.entryId)
    }
    await this._tagRuleRepo.addRule(group, {
      sortOrder,
      rawLine,
      mode: useSelectors ? 'selectors' : 'tags',
      expression: {
        operator: 'and',
        tagEntryIds,
      },
    })
  }

  /**
   * @param {string} fieldKey
   * @param {string[]} lines
   * @return {Promise<void>}
   */
  async persistTaggingFieldLines(fieldKey, lines)
  {
    let spec = TAG_FIELD_SPEC[fieldKey]
    if (!spec) {
      return
    }
    let group = spec.group
    let soleNames = new Set()
    let comboLines = []
    let substitutionLines = []

    for (let rawLine of lines) {
      if (spec.substitution && typeof rawLine === 'object' && rawLine !== null) {
        let subject = String(rawLine.subject ?? '').trim()
        let replacement = String(rawLine.replacement ?? '').trim()
        if (subject && replacement) {
          substitutionLines.push({subject, replacement})
        }
        continue
      }
      let line = String(rawLine).trim()
      if (!line) {
        continue
      }
      if (spec.substitution) {
        let parsed = parseSubstitutionTagLine(line)
        if (parsed) {
          substitutionLines.push(parsed)
          continue
        }
      }
      if (isComboTagLine(line)) {
        comboLines.push(line)
        continue
      }
      let tagName = line.split('//')[0].trim()
      if (tagName && !tagName.includes('→') && !tagName.includes('->')) {
        soleNames.add(tagName)
      }
    }

    if (spec.substitution) {
      let all = await this._tagRepo._storage.getAll(IDB_STORE_TAGS)
      let desiredSubjects = new Set(substitutionLines.map((row) => row.subject))
      for (let entry of all) {
        normalizeTagEntry(entry)
        if (entry.download?.substitution && !desiredSubjects.has(entry.name)) {
          entry.download.substitution = null
          await this._tagRepo.putTag(entry)
          this._cacheEntry(entry)
        }
      }
      for (let row of substitutionLines) {
        await this.patchAttributes(row.subject, {
          download: {
            substitution: {replacementName: row.replacement},
          },
        }, {replacementName: row.replacement})
      }
    } else if (spec.soleAttribute) {
      let all = await this._tagRepo._storage.getAll(IDB_STORE_TAGS)
      for (let entry of all) {
        normalizeTagEntry(entry)
        let hasFlag = readTagSoleAttribute(entry, spec.soleAttribute)
        let shouldHave = soleNames.has(entry.name)
        if (hasFlag && !shouldHave) {
          writeTagSoleAttribute(entry, spec.soleAttribute, false)
          await this._tagRepo.putTag(entry)
          this._cacheEntry(entry)
        }
      }
      for (let tagName of soleNames) {
        let patch = spec.soleAttribute.root === 'compliance' ?
            {compliance: {[spec.soleAttribute.key]: true}} :
            {download: {[spec.soleAttribute.key]: true}}
        await this.patchAttributes(tagName, patch)
      }
    }

    if (spec.usesCombos) {
      let existing = await this._tagRuleRepo.listByGroup(group)
      for (let rule of existing) {
        await this._tagRuleRepo.removeRule(rule.entryId, group)
      }
      let sortOrder = 0
      for (let rawLine of comboLines) {
        await this.importComboRuleLine(group, rawLine, sortOrder++, false)
      }
    }
  }
}

class BrazenStorageRepositories
{
  /**
   * @param {string} scriptPrefix
   * @param {function(string): void|null} [onRepositoryChange]
   * @param {function(number|string): void|null} [onRevisionBump] Called after every local `meta.revisionId` bump so the
   *   script can track revisions it caused (focus sync must ignore those).
   */
  constructor(scriptPrefix, onRepositoryChange = null, onRevisionBump = null)
  {
    this.storage = new BrazenIndexedDBStorage(scriptPrefix)
    this.meta = new MetaRepository(this.storage, onRevisionBump)
    this.settings = new SettingsRepository(this.storage)
    this.bookmarks = new BookmarkRepository(this.storage, this.meta, onRepositoryChange)
    this.ledger = new LedgerRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadResolutionQueue = new DownloadResolutionQueueRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadQueue = new DownloadQueueRepository(this.storage, this.meta, onRepositoryChange)
    this.downloadManagerState = new DownloadManagerStateRepository(this.storage, this.meta, onRepositoryChange)
    this.tagRules = new TagRuleRepository(this.storage, this.meta)
    this.tags = new TagRepository(this.storage, this.meta, this.tagRules)
    this.tagRuntime = new TagRuntime(this.tags, this.tagRules)
  }
}

/**
 * Expand legacy OR rules into separate lines (no | in output).
 * @param {string} line
 * @return {string[]}
 */
function expandOrRuleLine(line)
{
  let commentIndex = line.indexOf('//')
  let rulePart = commentIndex >= 0 ? line.slice(0, commentIndex) : line
  let comment = commentIndex >= 0 ? line.slice(commentIndex) : ''
  let segments = rulePart.split('&')
  let expanded = ['']
  for (let segment of segments) {
    let alternatives = segment.split('|').map((part) => part.trim()).filter(Boolean)
    if (alternatives.length <= 1) {
      expanded = expanded.map((prefix) => prefix ? prefix + '&' + segment.trim() : segment.trim())
      continue
    }
    let next = []
    for (let prefix of expanded) {
      for (let alt of alternatives) {
        next.push(prefix ? prefix + '&' + alt : alt)
      }
    }
    expanded = next
  }
  return expanded.filter(Boolean).map((rule) => rule + comment)
}

/**
 * IEEE / PKZIP CRC-32 over a byte array (polynomial 0xEDB88320).
 * @param {Uint8Array} bytes
 * @return {number}
 */
function zipCrc32(bytes)
{
  let crc = 0xffffffff
  for (let i = 0; i < bytes.length; i++) {
    crc ^= bytes[i]
    for (let bit = 0; bit < 8; bit++) {
      crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1))
    }
  }
  return (crc ^ 0xffffffff) >>> 0
}

/**
 * Minimal ZIP writer (STORE method, no compression) for backup bundles.
 */
class BrazenZipWriter
{
  constructor()
  {
    this._files = []
  }

  /**
   * @param {string} name
   * @param {string} content
   */
  addFile(name, content)
  {
    this._files.push({name, content})
  }

  /**
   * @return {Blob}
   */
  build()
  {
    let parts = []
    let centralDirectory = []
    let offset = 0

    for (let file of this._files) {
      let nameBytes = new TextEncoder().encode(file.name)
      let dataBytes = new TextEncoder().encode(file.content)
      let crc = zipCrc32(dataBytes)
      let header = new DataView(new ArrayBuffer(30))
      header.setUint32(0, 0x04034b50, true)
      header.setUint16(4, 20, true)
      header.setUint16(6, 0, true)
      header.setUint16(8, 0, true)
      header.setUint16(10, 0, true)
      header.setUint16(12, 0, true)
      header.setUint32(14, crc, true)
      header.setUint32(18, dataBytes.length, true)
      header.setUint32(22, dataBytes.length, true)
      header.setUint16(26, nameBytes.length, true)
      header.setUint16(28, 0, true)

      parts.push(new Uint8Array(header.buffer), nameBytes, dataBytes)

      let cdHeader = new DataView(new ArrayBuffer(46))
      cdHeader.setUint32(0, 0x02014b50, true)
      cdHeader.setUint16(4, 20, true)
      cdHeader.setUint16(6, 20, true)
      cdHeader.setUint16(8, 0, true)
      cdHeader.setUint16(10, 0, true)
      cdHeader.setUint16(12, 0, true)
      cdHeader.setUint16(14, 0, true)
      cdHeader.setUint32(16, crc, true)
      cdHeader.setUint32(20, dataBytes.length, true)
      cdHeader.setUint32(24, dataBytes.length, true)
      cdHeader.setUint16(28, nameBytes.length, true)
      cdHeader.setUint16(30, 0, true)
      cdHeader.setUint16(32, 0, true)
      cdHeader.setUint16(34, 0, true)
      cdHeader.setUint16(36, 0, true)
      cdHeader.setUint32(38, 0, true)
      cdHeader.setUint32(42, offset, true)

      centralDirectory.push(new Uint8Array(cdHeader.buffer), nameBytes)
      offset += 30 + nameBytes.length + dataBytes.length
    }

    let centralStart = offset
    for (let part of centralDirectory) {
      parts.push(part)
      offset += part.length
    }

    let end = new DataView(new ArrayBuffer(22))
    end.setUint32(0, 0x06054b50, true)
    end.setUint16(4, 0, true)
    end.setUint16(6, 0, true)
    end.setUint16(8, this._files.length, true)
    end.setUint16(10, this._files.length, true)
    end.setUint32(12, centralDirectory.reduce((sum, part) => sum + part.length, 0), true)
    end.setUint32(16, centralStart, true)
    end.setUint16(20, 0, true)
    parts.push(new Uint8Array(end.buffer))

    return new Blob(parts, {type: 'application/zip'})
  }
}

/**
 * Minimal ZIP reader (STORE method only) for backup restore.
 */
class BrazenZipReader
{
  /**
   * @param {ArrayBuffer} buffer
   * @return {Record<string, string>}
   */
  static parse(buffer)
  {
    let view = new DataView(buffer)
    let files = {}
    let offset = 0

    while (offset + 30 <= buffer.byteLength) {
      let signature = view.getUint32(offset, true)
      if (signature === 0x04034b50) {
        let compression = view.getUint16(offset + 8, true)
        let compressedSize = view.getUint32(offset + 18, true)
        let nameLength = view.getUint16(offset + 26, true)
        let extraLength = view.getUint16(offset + 28, true)
        let nameStart = offset + 30
        let name = new TextDecoder().decode(new Uint8Array(buffer, nameStart, nameLength))
        let dataStart = nameStart + nameLength + extraLength
        if (compression !== 0) {
          throw new Error('Compressed zip entries are not supported: ' + name)
        }
        let dataBytes = new Uint8Array(buffer, dataStart, compressedSize)
        files[name] = new TextDecoder().decode(dataBytes)
        offset = dataStart + compressedSize
        continue
      }
      if (signature === 0x02014b50 || signature === 0x06054b50) {
        break
      }
      break
    }

    return files
  }
}

class BrazenLegacyImporter
{
  /**
   * @param {BrazenStorageRepositories} repos
   * @param {BrazenConfigurationManager} manager
   */
  constructor(repos, manager)
  {
    this._repos = repos
    this._manager = manager
    this._summary = {}
  }

  /**
   * @return {Promise<string[]>}
   */
  async run()
  {
    let sources = []
    let prefix = this._manager._scriptPrefix
    let legacyPrefix = this._manager._legacyScriptPrefix
    this._errors = []

    await this._importSettingsRevision(prefix, legacyPrefix, sources)
    await this._importSettings(prefix, legacyPrefix, sources)
    await this._importBookmarks(prefix, legacyPrefix, sources)
    await this._importLedger(prefix, legacyPrefix, sources)
    await this._verifyAndPurgeLegacy(prefix, legacyPrefix, sources)

    console.log('[BrazenIDB] legacy import summary:', this._summary, 'sources:', sources, 'errors:', this._errors)
    if (this._summary.importedTotal > 0) {
      alert('Brazen script — settings migrated to IndexedDB (' + this._summary.importedTotal + ' records).')
    }
    return sources
  }

  async _importSettingsRevision(prefix, legacyPrefix, sources)
  {
    for (let backend of LEGACY_STORAGE_BACKENDS) {
      for (let activePrefix of [prefix, legacyPrefix]) {
        if (!activePrefix) {
          continue
        }
        let revision = backend.read(activePrefix + 'settings-id', null)
        if (typeof revision === 'number' || (typeof revision === 'string' && revision.trim())) {
          let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
          meta.revisionId = String(revision)
          await this._repos.meta.put(meta)
          if (!sources.includes(backend.source)) {
            sources.push(backend.source)
          }
          this._summary.settingsRevision = meta.revisionId
          return
        }
      }
    }
  }

  async _verifyAndPurgeLegacy(prefix, legacyPrefix, sources)
  {
    let settingsDoc = await this._repos.settings.getDocument()
    let bookmarkCount = (await this._repos.bookmarks.listAll()).length
    let ledgerCount = (await this._repos.storage.getAll(IDB_STORE_LEDGER)).length
    let tagCount = (await this._repos.storage.getAll(IDB_STORE_TAGS)).length
    let tagRuleCount = (await this._repos.storage.getAll(IDB_STORE_TAG_RULES)).length

    let expectedSettings = this._summary.settings ?? 0
    if (expectedSettings && Object.keys(settingsDoc).length <= 1) {
      throw new Error('Legacy settings import verification failed')
    }
    if ((this._summary.bookmarksExpected ?? 0) > 0 && bookmarkCount === 0) {
      throw new Error('Legacy bookmark import verification failed')
    }
    if ((this._summary.ledgerExpected ?? 0) > 0 && ledgerCount === 0) {
      throw new Error('Legacy ledger import verification failed')
    }

    this._summary.importedTotal = (this._summary.settings ?? 0) + bookmarkCount + ledgerCount + tagCount + tagRuleCount
    this._summary.migrationSources = [...sources]

    for (let backend of LEGACY_STORAGE_BACKENDS) {
      this._safeRemove(backend, prefix + 'settings')
      this._safeRemove(backend, prefix + 'settings-id')
      this._safeRemove(backend, prefix + 'bookmarks')
      this._safeRemove(backend, prefix + 'download-ledger')
      if (legacyPrefix && legacyPrefix !== prefix) {
        this._safeRemove(backend, legacyPrefix + 'settings')
        this._safeRemove(backend, legacyPrefix + 'settings-id')
        this._safeRemove(backend, legacyPrefix + 'bookmarks')
      }
    }

    let ledgerPrefixes = [prefix + 'download-ledger/e/']
    if (legacyPrefix) {
      ledgerPrefixes.push(legacyPrefix + 'download-ledger/e/')
    }
    if (typeof GM_listValues === 'function' && typeof GM_deleteValue === 'function') {
      for (let storeKey of GM_listValues()) {
        if (ledgerPrefixes.some((ledgerPrefix) => storeKey.startsWith(ledgerPrefix))) {
          try {
            GM_deleteValue(storeKey)
          } catch (error) {
            this._errors.push('ledger-delete:' + storeKey)
            console.log('[BrazenIDB] failed to delete ledger key:', storeKey, error)
          }
        }
      }
    }
  }

  _safeRemove(backend, key)
  {
    if (!key) {
      return
    }
    try {
      if (backend.read(key, null) !== null) {
        backend.remove(key)
      }
    } catch (error) {
      console.log('[BrazenIDB] failed to purge legacy key:', key, error)
    }
  }

  async _importSettings(prefix, legacyPrefix, sources)
  {
    let aggregate = null
    for (let backend of LEGACY_STORAGE_BACKENDS) {
      let current = this._coerceSettingsBlob(backend.read(prefix + 'settings', null))
      if (current.settings) {
        aggregate = {...(aggregate ?? {}), ...current.settings}
        if (!sources.includes(backend.source)) {
          sources.push(backend.source)
        }
      } else if (current.invalid) {
        this._errors.push('settings-invalid:' + backend.source)
      }
      if (legacyPrefix && legacyPrefix !== prefix) {
        let legacy = this._coerceSettingsBlob(backend.read(legacyPrefix + 'settings', null))
        if (legacy.settings) {
          aggregate = {...legacy.settings, ...(aggregate ?? {})}
          if (!sources.includes(backend.source)) {
            sources.push(backend.source)
          }
        } else if (legacy.invalid) {
          this._errors.push('legacy-settings-invalid:' + backend.source)
        }
      }
    }
    if (!aggregate) {
      this._summary.settings = 0
      return
    }

    let doc = await this._repos.settings.getDocument()
    let count = 0
    for (let fieldKey in aggregate) {
      if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
        continue
      }
      let property = fieldKeyToProperty(fieldKey)
      let value = aggregate[fieldKey]
      doc[property] = {
        value,
        optimized: this._manager._computeFieldOptimized(fieldKey, value),
        updatedAt: Date.now(),
      }
      count++
    }
    await this._repos.settings.putDocument(doc)
    this._summary.settings = count
  }

  /**
   * Normalize a driver settings blob. Empty objects, arrays, and whitespace strings are treated as absent (not errors).
   * @param {*} blob
   * @return {{settings: Object|null, invalid: boolean}}
   * @private
   */
  _coerceSettingsBlob(blob)
  {
    if (blob === null || blob === undefined) {
      return {settings: null, invalid: false}
    }
    blob = Utilities.reviveGmStoredValue(blob)
    if (typeof blob === 'string') {
      let trimmed = blob.trim()
      if (!trimmed.length) {
        return {settings: null, invalid: false}
      }
      try {
        blob = JSON.parse(trimmed)
      } catch (error) {
        return {settings: null, invalid: true}
      }
    }
    if (blob && typeof blob === 'object' && 'arrays' in blob && 'objects' in blob && 'properties' in blob) {
      try {
        blob = Utilities.objectFromJSON(JSON.stringify(blob))
      } catch (error) {
        return {settings: null, invalid: true}
      }
    }
    if (!blob || typeof blob !== 'object' || Array.isArray(blob) || !Object.keys(blob).length) {
      return {settings: null, invalid: false}
    }
    return {settings: blob, invalid: false}
  }

  async _importBookmarks(prefix, legacyPrefix, sources)
  {
    let rows = []
    let expected = 0
    let keys = [prefix + 'bookmarks']
    if (legacyPrefix) {
      keys.push(legacyPrefix + 'bookmarks')
    }
    for (let key of keys) {
      let stored = readLegacyGm(key, null)
      let list = Utilities.coerceBookmarkArray(stored)
      expected += list.length
      for (let bookmark of list) {
        let normalized = this._normalizeBookmarkRow(bookmark)
        if (normalized) {
          rows.push(normalized)
        }
      }
      if (list.length && !sources.includes(LEGACY_SOURCE_GM)) {
        sources.push(LEGACY_SOURCE_GM)
      }
    }

    for (let backend of LEGACY_STORAGE_BACKENDS) {
      for (let activePrefix of [prefix, legacyPrefix]) {
        if (!activePrefix) {
          continue
        }
        let settings = backend.read(activePrefix + 'settings', null)
        let fromSettings = Utilities.coerceBookmarkArray(settings?.bookmarks)
        expected += fromSettings.length
        for (let bookmark of fromSettings) {
          let normalized = this._normalizeBookmarkRow(bookmark)
          if (normalized) {
            rows.push(normalized)
          }
        }
        if (fromSettings.length && !sources.includes(backend.source)) {
          sources.push(backend.source)
        }
      }
    }

    if (rows.length) {
      await this._repos.bookmarks.replaceAll(rows)
    }
    this._summary.bookmarks = rows.length
    this._summary.bookmarksExpected = expected
  }

  _normalizeBookmarkRow(bookmark)
  {
    if (!bookmark || typeof bookmark !== 'object') {
      return null
    }
    let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
    if (!tags.length) {
      return null
    }
    return this._bookmarkToRow(bookmark)
  }

  _bookmarkToRow(bookmark)
  {
    return {
      entryId: null,
      label: bookmark.label ?? '',
      tags: bookmark.tags ?? '',
      url: bookmark.url ?? '',
      sortOrder: 0,
      createdAt: Date.now(),
      updatedAt: Date.now(),
    }
  }

  async _importLedger(prefix, legacyPrefix, sources)
  {
    let registryKeys = [prefix + 'download-ledger']
    if (legacyPrefix && legacyPrefix !== prefix) {
      registryKeys.push(legacyPrefix + 'download-ledger')
    }
    let ids = []
    for (let registryKey of registryKeys) {
      let registry = readLegacyGm(registryKey, null)
      if (registry && typeof registry === 'object') {
        ids.push(...(Array.isArray(registry.postIds) ? registry.postIds
            : Array.isArray(registry.ids) ? registry.ids : []))
        if (!sources.includes(LEGACY_SOURCE_GM)) {
          sources.push(LEGACY_SOURCE_GM)
        }
      }
    }
    let rows = []
    let ledgerPrefixes = registryKeys.map((key) => key + '/e/')
    if (typeof GM_listValues === 'function') {
      for (let storeKey of GM_listValues()) {
        let matchedPrefix = ledgerPrefixes.find((ledgerPrefix) => storeKey.startsWith(ledgerPrefix))
        if (!matchedPrefix) {
          continue
        }
        let postId = decodeURIComponent(storeKey.slice(matchedPrefix.length))
        let claimedAt = readLegacyGm(storeKey, null)
        rows.push({
          entryId: null,
          postId,
          claimedAt: typeof claimedAt === 'number' ? claimedAt : Date.now(),
        })
      }
    } else {
      for (let id of ids) {
        rows.push({entryId: null, postId: String(id), claimedAt: Date.now()})
      }
    }
    if (rows.length) {
      await this._repos.ledger.replaceAll(rows)
      if (!sources.includes(LEGACY_SOURCE_GM)) {
        sources.push(LEGACY_SOURCE_GM)
      }
    }
    this._summary.ledgerEntries = rows.length
    this._summary.ledgerExpected = Math.max(ids.length, rows.length)
  }
}