Configuration management for the Brazen user scripts framework
This script should not be not be installed directly. It is a library for other scripts to include with the meta directive // @require https://update.greasyfork.org/scripts/418665/1875152/Brazen%20Framework%20-%20Configuration%20Manager.js
// ==UserScript==
// @name Brazen Framework - Configuration Manager
// @namespace brazenvoid
// @version 4.0.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Configuration management for the Brazen user scripts framework
// ==/UserScript==
const CONFIG_BACKUP_VERSION = 2
const CONFIG_TYPE_CHECKBOXES_GROUP = 'checkboxes'
const CONFIG_TYPE_COLOR = 'color'
const CONFIG_TYPE_FLAG = 'flag'
const CONFIG_TYPE_LEDGER = 'ledger'
const CONFIG_TYPE_NUMBER = 'number'
const CONFIG_TYPE_RADIOS_GROUP = 'radios'
const CONFIG_TYPE_RANGE = 'range'
const CONFIG_TYPE_RULESET = 'ruleset'
const CONFIG_TYPE_SELECT = 'select'
const CONFIG_TYPE_TEXT = 'text'
const CONFIG_TYPE_ACTION = 'action'
const CONFIG_TYPE_BOOKMARKS = 'bookmarks'
const LEDGER_BUILTIN_LEGACY_INDEX_FIELDS = ['ids']
class BrazenConfigurationManager
{
/**
* @typedef {{key: string, title: string, type: string, element: null|JQuery, value: *, maximum: int, minimum: int, options: string[], helpText: string,
* onFormatForUI: ConfigurationManagerRulesetCallback, onTranslateFromUI: ConfigurationManagerRulesetCallback,
* onOptimize: ConfigurationManagerRulesetCallback, onMatchRule?: ConfigurationManagerRulesetMatchCallback,
* createElement: Function, setFromUserInterface: Function, updateUserInterface: Function,
* optimized?: *, persist?: boolean, widget?: *, storage?: string, sortRules?: boolean,
* setRules?: function(*): void, findRuleIndex?: function(*): number, hasRule?: function(*): boolean,
* addRule?: function(*, *=): void, removeRule?: function(*): void, toggleRule?: function(*, *=): void,
* clearRules?: function(): void,
* serializeForBackup?: function(): *, applyFromBackup?: function(*): void}} ConfigurationField
*/
/**
* @callback ConfigurationManagerRulesetCallback
* @param {*} values
*/
/**
* @callback ConfigurationManagerRulesetMatchCallback
* @param {*} storedRule
* @param {*} target
* @return {boolean}
*/
/**
* @callback ExternalConfigurationChangeCallback
* @param {BrazenConfigurationManager} manager
*/
// -------------------------------------------------------------------------
// Private class variables
// -------------------------------------------------------------------------
/**
* @type {{}}
* @private
*/
_config = {}
/**
* @type {{local: StorageDriver, gm: StorageDriver}}
* @private
*/
_drivers = {
local: new LocalStorageDriver(),
gm: new GmStorageDriver(),
}
/**
* @type {{local?: LocalStore|DriverStore, gm?: DriverStore}}
* @private
*/
_driverStores = {}
/**
* @type {{local?: LocalStore|DriverStore, gm?: DriverStore}}
* @private
*/
_driverStoreIds = {}
/**
* @type {{local?: *, gm?: *}}
* @private
*/
_syncedDriverStoreIds = {}
/**
* @type {string|null}
* @private
*/
_lastFieldKey = null
/**
* @type {boolean}
* @private
*/
_dockActive = false
/**
* User-script instance used to evaluate dock `include` callbacks.
* @type {object|null}
* @private
*/
_dockIncludeContext = null
/**
* @type {JQuery[]}
* @private
*/
_dockElements = []
/**
* @type {Function|null}
* @private
*/
_onDockToggle = null
/**
* @type {string|null}
* @private
*/
_legacyScriptPrefix = null
/**
* @type {boolean}
* @private
*/
_storageMigrationComplete = false
/**
* @type {boolean}
* @private
*/
_storageMigrationRunning = false
/**
* @type {boolean}
* @private
*/
_suppressStoreNotifications = false
/**
* @type {Function|null}
* @private
*/
_onMigrationStatusChange = null
/**
* @type {ExternalConfigurationChangeCallback|null}
* @private
*/
_onExternalConfigurationChange = null
/**
* @param {string} scriptPrefix
* @param {BrazenViewLayer} uiGenerator
* @param {SearchEnhancerTagSelectorGeneratorCallback} tagSelectorGenerator
*/
constructor(scriptPrefix, uiGenerator, tagSelectorGenerator)
{
this._scriptPrefix = scriptPrefix
this._tagSelectorGenerator = tagSelectorGenerator
this._uiGen = uiGenerator
}
// -------------------------------------------------------------------------
// Private class methods
// -------------------------------------------------------------------------
/**
* @param {string} type
* @param {string} name
* @param {*} value
* @param {string|null} helpText
* @return ConfigurationField
* @private
*/
_createField(type, name, value, helpText)
{
let fieldKey = this._formatFieldKey(name)
let field = this._config[fieldKey]
if (field) {
field.key = field.key ?? fieldKey
if (helpText) {
field.helpText = helpText
}
field.value = value
} else {
field = {
key: fieldKey,
element: null,
helpText: helpText,
title: name,
type: type,
value: value,
storage: STORAGE_DRIVER_LOCAL,
createElement: null,
setFromUserInterface: null,
updateUserInterface: null,
}
this._config[fieldKey] = field
}
this._lastFieldKey = fieldKey
this._wireDefaultFieldHooks(field)
this._wireFieldBuilder(field)
return field
}
/**
* @param {ConfigurationField} field
* @private
*/
_wireFieldBuilder(field)
{
field.setTitle = (title) => {
field.title = title
return field
}
field.setHelpText = (helpText) => {
field.helpText = helpText
return field
}
field.setDefault = (value) => {
field.value = value
if (field.type === CONFIG_TYPE_TEXT) {
field.textDefault = value
}
return field
}
field.setRows = (rows) => {
field.rows = rows
return field
}
field.setMinimum = (minimum) => {
field.minimum = minimum
return field
}
field.setMaximum = (maximum) => {
field.maximum = maximum
return field
}
field.setOptions = (keyValuePairs) => {
field.options = keyValuePairs
if (field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_SELECT) {
field.value = keyValuePairs[0][1]
}
return field
}
field.setTranslateFromUI = (callback) => {
field.onTranslateFromUI = callback
return field
}
field.setFormatForUI = (callback) => {
field.onFormatForUI = callback
return field
}
field.setOptimize = (callback) => {
field.onOptimize = callback
return field
}
field.setSortRules = (sortRules = true) => {
field.sortRules = sortRules
return field
}
field.setFormatter = (formatter) => {
field.formatter = formatter
return field
}
field.setStorageKey = (storageKey) => {
field.customStorageKey = storageKey
return field
}
field.setLegacyStorageKeys = (legacyStorageKeys) => {
field.legacyStorageKeys = legacyStorageKeys
return field
}
field.setStorage = (driverKey) => {
field.storage = driverKey
this._ensureDriverStore(driverKey)
return field
}
field.setPrimaryField = (primaryField) => {
field.ledgerPrimaryField = primaryField
return field
}
field.setMaxEntries = (maxEntries) => {
field.ledgerMaxEntries = maxEntries
return field
}
field.setLegacyIdFields = (legacyIdFields) => {
field.ledgerLegacyIdFields = legacyIdFields
return field
}
field.setLedgerLegacyStorageKeys = (legacyStorageKeys) => {
field.ledgerLegacyStorageKeys = legacyStorageKeys
return field
}
field.setIsValidId = (isValidId) => {
field.ledgerIsValidId = isValidId
return field
}
field.setNormalizeBookmarks = (normalizeBookmarks) => {
field.normalizeBookmarks = normalizeBookmarks
return field
}
field.setShowAddButton = (showAddButton) => {
field.showAddButton = showAddButton
return field
}
field.setPageMatch = (pageMatch) => {
field.pageMatch = pageMatch
return field
}
field.setOnAdd = (onAdd) => {
field.onAdd = onAdd
return field
}
field.setOnSort = (onSort) => {
field.onSort = onSort
return field
}
field.setOnRemove = (onRemove) => {
field.onRemove = onRemove
return field
}
field.setOnNavigate = (onNavigate) => {
field.onNavigate = onNavigate
return field
}
field.setGetRowActions = (getRowActions) => {
field.getRowActions = getRowActions
return field
}
field.setDockButton = (dockOptions) => {
field.dock = {...(field.dock ?? {}), ...dockOptions}
return field
}
field.setDockOrder = (order) => {
field.dockOrder = order
return field
}
field.setDockSlideOut = (childNames) => {
field.dockSlideOutChildren = childNames
for (let childName of childNames) {
let childField = this.getField(childName)
if (childField) {
childField.dock = {...(childField.dock ?? {}), parent: field.key}
}
}
return field
}
field.setOnClick = (onClick) => {
field.onClick = onClick
return field
}
field.setAction = (onClick) => {
field.onClick = onClick
return field
}
field.attachStorage = (driverKey) => {
field.storage = driverKey
this._ensureDriverStore(driverKey)
return field
}
}
/**
* @param {ConfigurationField} field
* @private
*/
_wireDefaultFieldHooks(field)
{
if (!field.serializeForBackup) {
field.serializeForBackup = () => field.value
}
if (!field.applyFromBackup) {
field.applyFromBackup = (blob) => {
field.value = blob
if (field.type === CONFIG_TYPE_RULESET) {
field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
}
}
}
}
/**
* @return {string[]}
* @private
*/
_getPersistedStoreDrivers()
{
let drivers = new Set()
for (let key in this._config) {
let field = this._config[key]
if (field.persist === false) {
continue
}
drivers.add(field.storage ?? STORAGE_DRIVER_LOCAL)
}
return [...drivers]
}
/**
* @param {string} driverKey
* @private
*/
_ensureDriverStore(driverKey)
{
if (this._driverStores[driverKey]) {
return
}
if (!this._getPersistedStoreDrivers().includes(driverKey)) {
return
}
let settingsKey = this._scriptPrefix + 'settings'
let settingsIdKey = this._scriptPrefix + 'settings-id'
if (driverKey === STORAGE_DRIVER_LOCAL) {
this._driverStores.local = new LocalStore(settingsKey, this._toStoreObject(STORAGE_DRIVER_LOCAL))
this._driverStoreIds.local = new LocalStore(settingsIdKey, {id: Utilities.generateId()})
this._driverStores.local.onChange(() => {
if (this._suppressStoreNotifications) {
return
}
this.updateInterface()
})
return
}
let driver = this._drivers[driverKey]
if (!driver) {
throw new Error('Unknown storage driver "' + driverKey + '"')
}
this._driverStores[driverKey] = new DriverStore(driver, settingsKey, this._toStoreObject(driverKey))
this._driverStoreIds[driverKey] = new DriverStore(driver, settingsIdKey, {id: Utilities.generateId()})
this._driverStores[driverKey].onChange(() => {
if (this._suppressStoreNotifications) {
return
}
this.updateInterface()
})
}
/**
* @return {string[]}
* @private
*/
_getUsedDrivers()
{
let drivers = new Set()
for (let key in this._config) {
drivers.add(this._config[key].storage ?? STORAGE_DRIVER_LOCAL)
}
return [...drivers]
}
/**
* @param {string} name
* @return {string}
* @private
*/
_formatFieldKey(name)
{
return Utilities.toKebabCase(name)
}
/**
* @param {string[]} rules
* @return {string[]}
* @private
*/
_deduplicateRulesetRules(rules)
{
if (!Array.isArray(rules) || !rules.length) {
return Array.isArray(rules) ? rules : []
}
let seen = new Set()
let deduplicated = []
for (let rule of rules) {
if (!seen.has(rule)) {
seen.add(rule)
deduplicated.push(rule)
}
}
return deduplicated
}
/**
* @param {Array} rules
* @param {boolean} useSelectors
* @return {JQuery.Selector[]}
* @private
*/
_optimizeTagRuleset(rules, useSelectors)
{
let orTags, iteratedRuleset
let optimizedRuleset = []
let expandRuleset = (ruleset, tags) => {
let grownRuleset = []
for (let tag of tags) {
let cleanedTag = tag.trim()
for (let rule of ruleset) {
grownRuleset.push([...rule, useSelectors ? this._tagSelectorGenerator(cleanedTag) : cleanedTag])
}
}
return grownRuleset
}
let growRuleset = (ruleset, tagToAdd) => {
if (ruleset.length) {
tagToAdd = tagToAdd.trim()
for (let rule of ruleset) {
rule.push(useSelectors ? this._tagSelectorGenerator(tagToAdd) : tagToAdd)
}
} else {
let tags = typeof tagToAdd === 'string' ? [tagToAdd] : tagToAdd
for (let tag of tags) {
let cleanedTag = tag.trim()
ruleset.push([useSelectors ? this._tagSelectorGenerator(cleanedTag) : cleanedTag])
}
}
}
for (let rule of rules) {
iteratedRuleset = []
for (let andTag of rule.split(' // ')[0].split('&')) {
orTags = andTag.split('|')
if (orTags.length === 1) {
growRuleset(iteratedRuleset, andTag)
} else if (iteratedRuleset.length) {
iteratedRuleset = expandRuleset(iteratedRuleset, orTags)
} else {
growRuleset(iteratedRuleset, orTags)
}
}
optimizedRuleset = optimizedRuleset.concat(iteratedRuleset)
}
return optimizedRuleset.sort((a, b) => a.length - b.length)
}
/**
* @param {string|null} driverKey
* @private
*/
_syncDriverStores(driverKey = null)
{
let drivers = driverKey ? [driverKey] : this._getPersistedStoreDrivers()
for (let currentDriverKey of drivers) {
if (currentDriverKey === STORAGE_DRIVER_LOCAL && !this._driverStores.local) {
continue
}
if (!this._driverStores[currentDriverKey]) {
continue
}
let storeObject = this._driverStores[currentDriverKey].get()
for (let key in this._config) {
let field = this._config[key]
if ((field.storage ?? STORAGE_DRIVER_LOCAL) !== currentDriverKey) {
continue
}
if (field.persist === false) {
continue
}
if (storeObject[key] !== undefined) {
field.value = storeObject[key]
if (field.type === CONFIG_TYPE_RULESET) {
field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
}
}
}
}
this.updateInterface()
}
/**
* @param {string|null} driverKey
* @return {{}}
* @private
*/
_toStoreObject(driverKey = null)
{
let storeObject = {}
for (let key in this._config) {
let field = this._config[key]
if (field.persist === false) {
continue
}
if (driverKey !== null && (field.storage ?? STORAGE_DRIVER_LOCAL) !== driverKey) {
continue
}
storeObject[key] = field.value
}
return storeObject
}
/**
* @param {string} driverKey
* @param {*} id
* @private
*/
_updateDriverStoreId(driverKey, id = null)
{
if (id === null) {
id = Utilities.generateId()
}
this._ensureDriverStore(driverKey)
this._driverStoreIds[driverKey].save({id: id})
this._syncedDriverStoreIds[driverKey] = id
}
/**
* @param {Object} backupConfig
* @private
*/
_restoreVersionedBackup(backupConfig)
{
let id = backupConfig.id
let drivers = backupConfig.drivers ?? {}
for (let driverKey in drivers) {
let section = drivers[driverKey]
if (!section || typeof section !== 'object') {
continue
}
let aggregate = {}
for (let fieldKey in section) {
let field = this._config[fieldKey]
if (!field) {
continue
}
if (field.persist === false) {
field.applyFromBackup(section[fieldKey])
continue
}
aggregate[fieldKey] = section[fieldKey]
}
if (Object.keys(aggregate).length) {
this._ensureDriverStore(driverKey)
let currentStore = this._driverStores[driverKey].get()
this._driverStores[driverKey].save({...currentStore, ...aggregate})
}
}
this._syncDriverStores()
if (id !== undefined) {
this._updateDriverStoreId(STORAGE_DRIVER_LOCAL, id)
}
}
/**
* @param {Object} backupConfig
* @private
*/
_restoreLegacyBackup(backupConfig)
{
let id = backupConfig.id
delete backupConfig.id
this._ensureDriverStore(STORAGE_DRIVER_LOCAL)
this._driverStores.local.save(backupConfig)
this._syncDriverStores(STORAGE_DRIVER_LOCAL)
this._updateDriverStoreId(STORAGE_DRIVER_LOCAL, id)
}
// -------------------------------------------------------------------------
// Public class methods
// -------------------------------------------------------------------------
/**
* @param {string} driverKey
* @returns {BrazenConfigurationManager}
*/
attachStorage(driverKey)
{
if (!this._lastFieldKey) {
throw new Error('attachStorage() must follow an add*Field() call')
}
let field = this._config[this._lastFieldKey]
field.storage = driverKey
this._ensureDriverStore(driverKey)
return this
}
/**
* @param {string} name
* @param {array} keyValuePairs
* @return {ConfigurationField}
*/
addCheckboxesGroup(name, keyValuePairs)
{
let field = this._createField(CONFIG_TYPE_CHECKBOXES_GROUP, name, [], null)
field.options = keyValuePairs
field.createElement = () => {
field.element = this._uiGen.createFormCheckBoxesGroupSection(field.title, field.options, field.helpText)
return field.element
}
field.setFromUserInterface = () => {
field.value = []
field.element.find('input:checked').each((index, element) => {
field.value.push($(element).attr('data-value'))
})
}
field.updateUserInterface = () => {
let elements = field.element.find('input')
for (let key of field.value) {
elements.filter('[data-value="' + key + '"]').prop('checked', true)
}
}
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addColorField(name)
{
let field = this._createField(CONFIG_TYPE_COLOR, name, false, null)
field.createElement = () => {
let inputGroup = this._uiGen.createFormInputGroup(field.title, 'color', field.helpText)
field.element = inputGroup.find('input')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = field.element.val()
}
field.updateUserInterface = () => {
field.element.val(field.value)
}
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addFlagField(name)
{
let field = this._createField(CONFIG_TYPE_FLAG, name, false, null)
field.createElement = () => {
let inputGroup = this._uiGen.createFormInputGroup(field.title, 'checkbox', field.helpText)
field.element = inputGroup.find('input')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = field.element.prop('checked')
}
field.updateUserInterface = () => {
if (field.element) {
field.element.prop('checked', field.value)
}
}
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addLedgerField(name)
{
let field = this._createField(CONFIG_TYPE_LEDGER, name, null, null)
field.persist = false
field.storage = STORAGE_DRIVER_GM
field.downloadedIds = new Set()
field.entryKeysBackfilled = false
field.pendingFlushIds = new Set()
field._storageMigrated = false
field._ledgerStorageKey = () => field.customStorageKey ?? this._scriptPrefix + 'download-ledger'
field._ledgerPrimaryField = () => field.ledgerPrimaryField ?? 'ids'
field._ledgerMaxEntries = () => field.ledgerMaxEntries ?? 25000
field._ledgerLegacyIdFields = () => field.ledgerLegacyIdFields ?? []
field._ledgerIsValidId = () => field.ledgerIsValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)
field._ledgerDriver = () => this._drivers[field.storage]
field._ledgerLegacyStorageKeys = () => field.ledgerLegacyStorageKeys ?? []
field._purgeLegacyLedgerKeys = async (ids, onProgress) => {
let driver = field._ledgerDriver()
let canonicalKey = field._ledgerStorageKey()
let legacyKeys = [...field._ledgerLegacyStorageKeys()].filter((legacyKey) => legacyKey && legacyKey !== canonicalKey)
if (!legacyKeys.length) {
return
}
let legacyEntryKeys = []
if (typeof GM_listValues === 'function') {
for (let storeKey of GM_listValues()) {
for (let legacyKey of legacyKeys) {
let prefix = legacyKey + '/e/'
if (storeKey.startsWith(prefix)) {
legacyEntryKeys.push(storeKey)
}
}
}
} else {
for (let legacyKey of legacyKeys) {
for (let id of ids) {
legacyEntryKeys.push(legacyKey + '/e/' + encodeURIComponent(String(id).trim()))
}
}
}
let total = legacyKeys.length + legacyEntryKeys.length
let done = 0
const BATCH = 40
for (let legacyKey of legacyKeys) {
this._safeRemoveDriverKey(driver, legacyKey)
done++
onProgress?.(`Cleaning legacy registry (${done}/${total})…`)
await this._yieldToBrowser()
}
for (let index = 0; index < legacyEntryKeys.length; index += BATCH) {
let batch = legacyEntryKeys.slice(index, index + BATCH)
for (let entryKey of batch) {
this._safeRemoveDriverKey(driver, entryKey)
}
done += batch.length
onProgress?.(`Cleaning legacy registry (${Math.min(done, total)}/${total})…`)
await this._yieldToBrowser()
}
}
field._hasLegacyLedgerEntryKeys = () => {
let canonicalKey = field._ledgerStorageKey()
for (let legacyKey of field._ledgerLegacyStorageKeys()) {
if (!legacyKey || legacyKey === canonicalKey) {
continue
}
let prefix = legacyKey + '/e/'
if (typeof GM_listValues === 'function') {
for (let storeKey of GM_listValues()) {
if (storeKey.startsWith(prefix)) {
return true
}
}
} else {
let driver = field._ledgerDriver()
let registry = driver.read(canonicalKey, null)
let ids = registry && typeof registry === 'object' ? field._collectRegistryIds(registry) : []
for (let id of ids) {
if (driver.read(legacyKey + '/e/' + encodeURIComponent(String(id).trim()), null) !== null) {
return true
}
}
}
}
return false
}
field._migrateLegacyLedgerEntryKeys = async (ids, onProgress) => {
let driver = field._ledgerDriver()
let canonicalKey = field._ledgerStorageKey()
let legacyKeys = [...field._ledgerLegacyStorageKeys()].filter((legacyKey) => legacyKey && legacyKey !== canonicalKey)
if (!legacyKeys.length) {
return
}
let migrations = []
if (typeof GM_listValues === 'function') {
for (let storeKey of GM_listValues()) {
for (let legacyKey of legacyKeys) {
let prefix = legacyKey + '/e/'
if (!storeKey.startsWith(prefix)) {
continue
}
let suffix = storeKey.slice(prefix.length)
migrations.push({
legacyEntryKey: storeKey,
canonicalEntryKey: canonicalKey + '/e/' + suffix,
})
}
}
} else {
for (let legacyKey of legacyKeys) {
for (let id of ids) {
let encoded = encodeURIComponent(String(id).trim())
migrations.push({
legacyEntryKey: legacyKey + '/e/' + encoded,
canonicalEntryKey: canonicalKey + '/e/' + encoded,
})
}
}
}
const BATCH = 40
for (let index = 0; index < migrations.length; index += BATCH) {
let batch = migrations.slice(index, index + BATCH)
for (let {legacyEntryKey, canonicalEntryKey} of batch) {
if (driver.read(canonicalEntryKey, null) !== null) {
continue
}
let value = driver.read(legacyEntryKey, null)
if (value === null) {
continue
}
try {
driver.write(canonicalEntryKey, value)
} catch (error) {
console.log('Failed to migrate ledger entry:', legacyEntryKey, error)
}
}
onProgress?.(`Migrating download registry entries (${Math.min(index + batch.length, migrations.length)}/${migrations.length})…`)
await this._yieldToBrowser()
}
}
field._hasLegacyLedgerStorage = () => {
let driver = field._ledgerDriver()
let canonicalKey = field._ledgerStorageKey()
for (let legacyKey of field._ledgerLegacyStorageKeys()) {
if (!legacyKey || legacyKey === canonicalKey) {
continue
}
let legacyRegistry = driver.read(legacyKey, null)
if (legacyRegistry && typeof legacyRegistry === 'object' && field._collectRegistryIds(legacyRegistry).length) {
return true
}
}
return false
}
field._loadCanonicalLedger = () => {
field.entryKeysBackfilled = false
let driver = field._ledgerDriver()
let canonicalKey = field._ledgerStorageKey()
let registry = driver.read(canonicalKey, null)
if (registry && typeof registry === 'object') {
registry = field._migrateRegistryIfNeeded(registry)
field.applyRegistry(registry)
field.backfillEntryKeys()
} else {
field.downloadedIds = new Set()
}
return field
}
field._migrateLedgerToCanonical = async (onProgress) => {
if (field._storageMigrated && !field._hasLegacyLedgerEntryKeys?.()) {
return field._loadCanonicalLedger()
}
let driver = field._ledgerDriver()
let canonicalKey = field._ledgerStorageKey()
let registry = driver.read(canonicalKey, null)
let ids = registry && typeof registry === 'object' ? field._collectRegistryIds(registry) : []
let migratedFromLegacy = false
let hadLegacyEntryKeys = field._hasLegacyLedgerEntryKeys?.()
if (!ids.length) {
for (let legacyKey of field._ledgerLegacyStorageKeys()) {
if (!legacyKey || legacyKey === canonicalKey) {
continue
}
let legacyRegistry = driver.read(legacyKey, null)
if (legacyRegistry && typeof legacyRegistry === 'object' && field._collectRegistryIds(legacyRegistry).length) {
registry = legacyRegistry
migratedFromLegacy = true
onProgress?.('Migrating download registry…')
break
}
}
}
if (registry && typeof registry === 'object') {
registry = field._migrateRegistryIfNeeded(registry)
ids = field.readIds(registry)
try {
driver.write(canonicalKey, field._normalizeRegistryBlob(ids))
} catch (error) {
console.log('Failed to persist ledger registry:', error)
}
let verified = driver.read(canonicalKey, null)
let verifiedIds = verified && typeof verified === 'object' ? field._collectRegistryIds(verified) : []
if (!verifiedIds.length && ids.length) {
field.applyRegistry(registry)
return field
}
field.applyRegistry(registry)
if (hadLegacyEntryKeys) {
await field._migrateLegacyLedgerEntryKeys(ids, onProgress)
}
const BATCH = 40
for (let index = 0; index < ids.length; index += BATCH) {
let batch = ids.slice(index, index + BATCH)
for (let id of batch) {
if (!field.isEntryStored(id)) {
try {
field.setEntry(id)
} catch (error) {
console.log('Failed to backfill ledger entry during migration:', id, error)
}
}
}
onProgress?.(`Migrating download registry (${Math.min(index + batch.length, ids.length)}/${ids.length})…`)
await this._yieldToBrowser()
}
field.entryKeysBackfilled = true
if (ids.length || migratedFromLegacy || hadLegacyEntryKeys) {
await field._purgeLegacyLedgerKeys(ids, onProgress)
field._storageMigrated = true
}
} else if (hadLegacyEntryKeys) {
await field._migrateLegacyLedgerEntryKeys(ids, onProgress)
await field._purgeLegacyLedgerKeys(ids, onProgress)
field._storageMigrated = true
} else {
field.downloadedIds = new Set()
if (!field._hasLegacyLedgerStorage()) {
field._storageMigrated = true
}
}
return field
}
field.getEntryKey = (id) => field._ledgerStorageKey() + '/e/' + encodeURIComponent(String(id).trim())
field.isEntryStored = (id) => {
try {
return field._ledgerDriver().read(field.getEntryKey(id), null) !== null
} catch (error) {
console.log('Failed to read ledger entry:', error)
return false
}
}
field.setEntry = (id) => {
field._ledgerDriver().write(field.getEntryKey(id), Date.now())
}
field.deleteEntry = (id) => {
field._ledgerDriver().remove(field.getEntryKey(id))
}
field._ledgerIndexFieldNames = () => {
let names = new Set([field._ledgerPrimaryField(), ...field._ledgerLegacyIdFields()])
for (let legacyField of LEDGER_BUILTIN_LEGACY_INDEX_FIELDS) {
if (legacyField !== field._ledgerPrimaryField()) {
names.add(legacyField)
}
}
return [...names]
}
field._extractIdFromLegacyValue = (value) => {
if (value === null || value === undefined) {
return null
}
let text = String(value).trim()
if (!text.length) {
return null
}
if (field._ledgerIsValidId()(text)) {
return text
}
let match = text.match(/[?&]id=(\d+)/)
return match ? match[1] : null
}
field._collectRegistryIds = (registry) => {
let ids = []
let seen = new Set()
let primaryField = field._ledgerPrimaryField()
let addValue = (value) => {
let id = field._extractIdFromLegacyValue(value)
if (!id || seen.has(id)) {
return
}
seen.add(id)
ids.push(id)
}
if (Array.isArray(registry[primaryField])) {
for (let value of registry[primaryField]) {
addValue(value)
}
}
for (let indexField of field._ledgerIndexFieldNames()) {
if (indexField === primaryField || !Array.isArray(registry[indexField])) {
continue
}
for (let value of registry[indexField]) {
addValue(value)
}
}
return ids
}
field.readIds = (registry) => field._collectRegistryIds(registry)
field._normalizeRegistryBlob = (ids) => ({[field._ledgerPrimaryField()]: ids})
field._registryHasLegacyIndexFields = (registry) => {
let primaryField = field._ledgerPrimaryField()
return Object.keys(registry).some((key) => key !== primaryField && Array.isArray(registry[key]))
}
field._registryPrimaryMatchesIds = (registry, ids) => {
let primaryField = field._ledgerPrimaryField()
if (!Array.isArray(registry[primaryField])) {
return !ids.length
}
if (registry[primaryField].length !== ids.length) {
return false
}
for (let index = 0; index < ids.length; index++) {
if (String(registry[primaryField][index]).trim() !== ids[index]) {
return false
}
}
return true
}
field._trimRegistryIdsFifo = (ids) => {
let maxEntries = field._ledgerMaxEntries()
if (ids.length <= maxEntries) {
return ids
}
for (let droppedId of ids.slice(0, ids.length - maxEntries)) {
try {
field.deleteEntry(droppedId)
} catch (error) {
console.log('Failed to delete trimmed ledger entry:', droppedId, error)
}
}
return ids.slice(-maxEntries)
}
field._migrateRegistryIfNeeded = (registry) => {
let ids = field._trimRegistryIdsFifo(field._collectRegistryIds(registry))
let normalizedRegistry = field._normalizeRegistryBlob(ids)
let needsBlobRewrite = field._registryHasLegacyIndexFields(registry) || !field._registryPrimaryMatchesIds(registry, ids)
let needsEntryBackfill = ids.some((id) => !field.isEntryStored(id))
if (needsEntryBackfill) {
for (let id of ids) {
if (field.isEntryStored(id)) {
continue
}
try {
field.setEntry(id)
} catch (error) {
console.log('Failed to backfill ledger entry during migration:', id, error)
}
}
}
if (needsBlobRewrite) {
try {
field._ledgerDriver().write(field._ledgerStorageKey(), normalizedRegistry)
} catch (error) {
console.log('Failed to persist migrated ledger registry:', error)
}
}
return normalizedRegistry
}
field.backfillEntryKeys = () => {
if (field.entryKeysBackfilled) {
return
}
field.entryKeysBackfilled = true
for (let id of field.downloadedIds) {
if (!field.isEntryStored(id)) {
try {
field.setEntry(id)
} catch (error) {
console.log('Failed to backfill ledger entry:', id, error)
}
}
}
}
field.applyRegistry = (registry) => {
field.downloadedIds = new Set(field.readIds(registry))
}
field.reload = () => {
try {
field._loadCanonicalLedger()
if (field.downloadedIds.size) {
return field
}
if (!field._hasLegacyLedgerStorage?.()) {
return field
}
let driver = field._ledgerDriver()
let canonicalKey = field._ledgerStorageKey()
for (let legacyKey of field._ledgerLegacyStorageKeys()) {
if (!legacyKey || legacyKey === canonicalKey) {
continue
}
let legacyRegistry = driver.read(legacyKey, null)
if (legacyRegistry && typeof legacyRegistry === 'object' && field._collectRegistryIds(legacyRegistry).length) {
let registry = field._migrateRegistryIfNeeded(legacyRegistry)
let ids = field.readIds(registry)
try {
driver.write(canonicalKey, field._normalizeRegistryBlob(ids))
} catch (error) {
console.log('Failed to promote legacy ledger registry:', error)
}
let verified = driver.read(canonicalKey, null)
if (verified && typeof verified === 'object' && field._collectRegistryIds(verified).length) {
field.applyRegistry(registry)
field.backfillEntryKeys()
}
break
}
}
} catch (error) {
console.log('Failed to load ledger:', error)
}
return field
}
field.has = (id) => {
if (id === null || id === undefined) {
return false
}
id = String(id).trim()
if (field.isEntryStored(id)) {
return true
}
return field.downloadedIds.has(id)
}
field.appendIndexId = (id) => {
let isValidId = field._ledgerIsValidId()
if (!isValidId(id)) {
return
}
id = String(id).trim()
field.downloadedIds.add(id)
try {
let storageKey = field._ledgerStorageKey()
let primaryField = field._ledgerPrimaryField()
let maxEntries = field._ledgerMaxEntries()
let registry = field._ledgerDriver().read(storageKey, null)
if (!registry || typeof registry !== 'object') {
registry = {}
}
let ids = Array.isArray(registry[primaryField])
? registry[primaryField].map((value) => String(value).trim()).filter(Boolean)
: []
if (ids.includes(id)) {
return
}
ids.push(id)
if (ids.length > maxEntries) {
for (let droppedId of ids.slice(0, ids.length - maxEntries)) {
field.deleteEntry(droppedId)
}
ids = ids.slice(-maxEntries)
}
registry[primaryField] = ids
field._ledgerDriver().write(storageKey, registry)
} catch (error) {
console.log('Failed to append ledger index id:', error)
}
}
field.add = (id) => {
if (!field._ledgerIsValidId()(id)) {
return
}
id = String(id).trim()
try {
field.setEntry(id)
} catch (error) {
console.log('Failed to set ledger entry:', id, error)
}
field.appendIndexId(id)
}
field.claim = (downloadIds) => {
let isValidId = field._ledgerIsValidId()
let ids = [...new Set(downloadIds
.filter((id) => id !== null && id !== undefined && String(id).length)
.map((id) => String(id).trim()))]
if (!ids.length) {
return true
}
field.reload()
for (let id of ids) {
if (!isValidId(id)) {
continue
}
if (field.has(id)) {
return false
}
}
for (let id of ids) {
if (!isValidId(id)) {
continue
}
field.add(id)
field.pendingFlushIds.add(id)
}
return true
}
field.flushPending = () => {
for (let id of field.pendingFlushIds) {
field.appendIndexId(id)
}
field.pendingFlushIds.clear()
}
field.merge = (registry) => {
if (!registry || typeof registry !== 'object') {
return
}
for (let id of field.readIds(registry)) {
field.add(id)
}
field.reload()
}
field.serializeForBackup = () => {
field.reload()
let ids = [...field.downloadedIds]
return ids.length ? {[field._ledgerPrimaryField()]: ids} : null
}
field.applyFromBackup = (data) => {
field.merge(data)
}
field.createElement = () => {
field.element = $('<div></div>')
return field.element
}
field.setFromUserInterface = () => {}
field.updateUserInterface = () => {}
return field
}
/**
* @param {string} name
* @param {int} minimum
* @param {int} maximum
* @return {ConfigurationField}
*/
addNumberField(name, minimum, maximum)
{
let field = this._createField(CONFIG_TYPE_NUMBER, name, minimum, null)
field.minimum = minimum
field.maximum = maximum
field.createElement = () => {
let inputGroup = this._uiGen.createFormInputGroup(field.title, 'number', field.helpText).
attr('min', field.minimum).
attr('max', field.maximum)
field.element = inputGroup.find('input')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = Number.parseInt(field.element.val().toString())
}
field.updateUserInterface = () => {
field.element.val(field.value)
}
return field
}
/**
* @param {string} name
* @param {array} keyValuePairs
* @return {ConfigurationField}
*/
addRadiosGroup(name, keyValuePairs)
{
let field = this._createField(CONFIG_TYPE_RADIOS_GROUP, name, keyValuePairs[0][1], null)
field.options = keyValuePairs
field.createElement = () => {
let inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, field.helpText)
field.element = inputGroup
return inputGroup
}
field.setFromUserInterface = () => {
field.value = field.element.find('input:checked').attr('data-value')
}
field.updateUserInterface = () => {
field.element.find('input[data-value="' + field.value + '"]').prop('checked', true).trigger('change')
}
return field
}
/**
* @param {string} name
* @param {int} minimum
* @param {int} maximum
* @return {ConfigurationField}
*/
addRangeField(name, minimum, maximum)
{
let field = this._createField(CONFIG_TYPE_RANGE, name, {minimum: minimum, maximum: minimum}, null)
field.minimum = minimum
field.maximum = maximum
field.createElement = () => {
let inputGroup = this._uiGen.createFormRangeInputGroup(field.title, 'number', field.minimum, field.maximum,
field.helpText)
field.element = inputGroup.find('input')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = {
minimum: field.element.first().val(),
maximum: field.element.last().val(),
}
}
field.updateUserInterface = () => {
field.element.first().val(field.value.minimum)
field.element.last().val(field.value.maximum)
}
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addRulesetField(name)
{
let field = this._createField(CONFIG_TYPE_RULESET, name, [], null)
field.rows = 3
field.optimized = null
field.onTranslateFromUI = field.onTranslateFromUI ?? null
field.onFormatForUI = field.onFormatForUI ?? null
field.onOptimize = field.onOptimize ?? null
field.sortRules = false
field.onMatchRule = null
field.setRules = (lines) => {
if (field.onTranslateFromUI) {
lines = Utilities.callEventHandler(field.onTranslateFromUI, [lines], lines)
}
if (field.sortRules) {
let sortKey = (rule) => {
if (typeof rule === 'string') {
return rule
}
let formatted = Utilities.callEventHandler(field.onFormatForUI, [[rule]], [])
return formatted[0] ?? ''
}
lines = lines.sort((left, right) =>
sortKey(left).localeCompare(sortKey(right), undefined, {sensitivity: 'base'}))
}
field.value = lines
field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
if (field.element) {
field.updateUserInterface()
}
}
field.findRuleIndex = (target) => {
let rules = field.value ?? []
for (let index = 0; index < rules.length; index++) {
let matches = field.onMatchRule
? Utilities.callEventHandler(field.onMatchRule, [rules[index], target])
: rules[index] === target
if (matches) {
return index
}
}
return -1
}
field.hasRule = (target) => field.findRuleIndex(target) >= 0
field.addRule = (rule, target = rule) => {
let lines = (field.value ?? []).filter((storedRule) => {
return field.onMatchRule
? !Utilities.callEventHandler(field.onMatchRule, [storedRule, target])
: storedRule !== target
})
lines.push(rule)
field.setRules(lines)
}
field.removeRule = (target) => {
let lines = (field.value ?? []).filter((storedRule) => {
return field.onMatchRule
? !Utilities.callEventHandler(field.onMatchRule, [storedRule, target])
: storedRule !== target
})
field.setRules(lines)
}
field.toggleRule = (rule, target = rule) => {
if (field.hasRule(target)) {
field.removeRule(target)
} else {
field.addRule(rule, target)
}
}
field.clearRules = () => {
field.setRules([])
}
field.createElement = () => {
let inputGroup = this._uiGen.createFormTextAreaGroup(field.title, field.rows, field.helpText)
field.element = inputGroup.find('textarea')
return inputGroup
}
field.setFromUserInterface = () => {
let value = Utilities.trimAndKeepNonEmptyStrings(field.element.val().split(REGEX_LINE_BREAK))
if (field.sortRules) {
value = value.sort((a, b) => a.localeCompare(b, undefined, {sensitivity: "base"}))
}
value = Utilities.callEventHandler(field.onTranslateFromUI, [value], value)
let deduplicated = this._deduplicateRulesetRules(value)
field.value = deduplicated
field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
if (deduplicated.length !== value.length) {
field.updateUserInterface()
}
}
field.updateUserInterface = () => {
field.element.val(Utilities.callEventHandler(field.onFormatForUI, [field.value], field.value).join('\n'))
}
field.applyFromBackup = (blob) => {
field.value = blob
field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value])
}
return field
}
/**
* @param {string} name
* @param {array} keyValuePairs
* @return {ConfigurationField}
*/
addSelectField(name, keyValuePairs)
{
let field = this._createField(CONFIG_TYPE_SELECT, name, keyValuePairs[0][1], null)
field.options = keyValuePairs
field.createElement = () => {
let inputGroup = this._uiGen.createFormRadiosGroupSection(field.title, field.options, field.helpText)
field.element = inputGroup.find('select')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = field.element.val()
}
field.updateUserInterface = () => {
field.element.val(field.value).trigger('change')
}
return field
}
/**
* @param {string} key
* @param {boolean} useSelectors
* @return {ConfigurationField}
*/
addTagRulesetField(key, useSelectors)
{
let field = this.addRulesetField(key)
field.useSelectors = useSelectors
field.setOptimize((rules) => {
if (field.formatter) {
rules = field.formatter(rules)
}
return this._optimizeTagRuleset(rules, useSelectors)
})
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addTextField(name)
{
let field = this._createField(CONFIG_TYPE_TEXT, name, '', null)
field.textDefault = ''
field.createElement = () => {
let inputGroup = this._uiGen.createFormInputGroup(field.title, 'text', field.helpText)
field.element = inputGroup.find('input')
return inputGroup
}
field.setFromUserInterface = () => {
let value = field.element.val()
field.value = value === '' ? field.textDefault : value
}
field.updateUserInterface = () => {
field.element.val(field.value)
}
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addActionField(name)
{
let field = this._createField(CONFIG_TYPE_ACTION, name, null, null)
field.persist = false
field.createElement = () => {
if (this._dockActive && field.dock) {
field.element = null
return $()
}
field.element = this._uiGen.createFormButton(field.title, field.helpText ?? '', () => {
Utilities.callEventHandler(field.onClick, [])
})
return field.element
}
field.setFromUserInterface = () => {}
field.updateUserInterface = () => {}
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addBookmarksField(name)
{
let field = this._createField(CONFIG_TYPE_BOOKMARKS, name, [], null)
field.persist = false
field.storage = field.storage ?? STORAGE_DRIVER_GM
field.widget = null
field.showAddButton = true
field._storageMigrated = false
field._bookmarksStorageKey = () => field.customStorageKey ?? this._scriptPrefix + 'bookmarks'
field._bookmarksDriver = () => this._drivers[field.storage]
field._normalizeBookmarks = () => field.normalizeBookmarks ?? ((bookmarks) => bookmarks)
field._bookmarksFieldKey = () => field.key
field._bookmarksLegacyStorageKeys = () => {
let keys = [...(field.legacyStorageKeys ?? [])]
if (this._legacyScriptPrefix && this._legacyScriptPrefix !== this._scriptPrefix) {
let defaultLegacyKey = this._legacyScriptPrefix + 'bookmarks'
if (!keys.includes(defaultLegacyKey)) {
keys.unshift(defaultLegacyKey)
}
}
return keys
}
field._collectLegacyBookmarkData = () => {
let driver = field._bookmarksDriver()
for (let legacyKey of field._bookmarksLegacyStorageKeys()) {
let legacyStored = this._coerceBookmarkArray(driver.read(legacyKey, null))
if (legacyStored.length) {
return legacyStored
}
}
if (this._legacyScriptPrefix) {
let fieldKey = field._bookmarksFieldKey()
let legacySettings = driver.read(this._legacyScriptPrefix + 'settings', null)
let legacyBookmarks = this._coerceBookmarkArray(legacySettings?.[fieldKey])
if (legacyBookmarks.length) {
return legacyBookmarks
}
let currentSettings = driver.read(this._scriptPrefix + 'settings', null)
let currentBookmarks = this._coerceBookmarkArray(currentSettings?.[fieldKey])
if (currentBookmarks.length) {
return currentBookmarks
}
}
return null
}
field._hasLegacyBookmarkStorage = () => !!field._collectLegacyBookmarkData()
field._purgeBookmarkLegacyStorage = () => {
let driver = field._bookmarksDriver()
let driverKey = field.storage ?? STORAGE_DRIVER_GM
let canonicalKey = field._bookmarksStorageKey()
for (let legacyKey of field._bookmarksLegacyStorageKeys()) {
if (!legacyKey || legacyKey === canonicalKey) {
continue
}
this._safeRemoveDriverKey(driver, legacyKey)
}
let fieldKey = field._bookmarksFieldKey()
for (let prefix of [this._scriptPrefix, this._legacyScriptPrefix]) {
if (!prefix) {
continue
}
let settingsKey = prefix + 'settings'
let settings = driver.read(settingsKey, null)
if (!settings || !(fieldKey in settings)) {
continue
}
delete settings[fieldKey]
driver.write(settingsKey, settings)
}
}
field._loadCanonicalBookmarks = () => {
let storageKey = field._bookmarksStorageKey()
let stored = field._bookmarksDriver().read(storageKey, null)
field.value = field._normalizeBookmarks()(this._coerceBookmarkArray(stored))
return field.value
}
field._migrateBookmarksToCanonical = () => {
if (field._storageMigrated) {
field._loadCanonicalBookmarks()
if (field.value.length || !field._hasLegacyBookmarkStorage?.()) {
return field.value
}
field._storageMigrated = false
}
let storageKey = field._bookmarksStorageKey()
let driver = field._bookmarksDriver()
let stored = driver.read(storageKey, null)
let canonical = field._normalizeBookmarks()(this._coerceBookmarkArray(stored))
let migrated = canonical.length > 0
if (!canonical.length) {
let legacyData = field._collectLegacyBookmarkData()
if (legacyData) {
canonical = field._normalizeBookmarks()(legacyData)
if (canonical.length) {
driver.write(storageKey, canonical)
let verified = driver.read(storageKey, null)
migrated = field._normalizeBookmarks()(this._coerceBookmarkArray(verified)).length > 0
}
}
}
field.value = canonical
if (migrated) {
field._purgeBookmarkLegacyStorage()
field._storageMigrated = true
console.log('Brazen bookmarks migrated:', canonical.length, '→', storageKey)
}
return canonical
}
field._applyBookmarkValues = (stored) => {
let normalized = field._normalizeBookmarks()(this._coerceBookmarkArray(stored))
field.value = normalized
return normalized
}
field.reload = () => {
try {
if (field._storageMigrated && !field.value.length && field._hasLegacyBookmarkStorage?.()) {
field._storageMigrated = false
}
field._loadCanonicalBookmarks()
if (!field.value.length) {
field._migrateBookmarksToCanonical()
}
if (!field.value.length && field._storageMigrated) {
field._storageMigrated = false
}
} catch (error) {
console.log('Failed to load bookmarks:', error)
}
return field
}
field.save = () => {
try {
field._bookmarksDriver().write(field._bookmarksStorageKey(), field.value)
} catch (error) {
console.log('Failed to save bookmarks:', error)
}
return field
}
field.serializeForBackup = () => {
field.reload()
return field.value.length ? field.value : null
}
field.applyFromBackup = (data) => {
if (!Array.isArray(data)) {
return
}
field.value = field._normalizeBookmarks()(data)
field.save()
field.widget?.render(field.value)
}
field.createElement = () => {
field.reload()
field.widget = this._uiGen.createBookmarksPanel({
addHelpText: field.helpText ?? '',
showAddButton: field.showAddButton !== false,
pageMatch: field.pageMatch,
onAdd: () => Utilities.callEventHandler(field.onAdd, []),
onSort: () => Utilities.callEventHandler(field.onSort, []),
onRemove: (id) => Utilities.callEventHandler(field.onRemove, [id]),
onNavigate: (url) => {
if (field.onNavigate) {
field.onNavigate(url)
} else {
location.href = url
}
},
getRowActions: (bookmark) => Utilities.callEventHandler(field.getRowActions, [bookmark], []),
})
field.element = field.widget.element
field.widget.render(field.value)
return field.element
}
field.setFromUserInterface = () => {}
field.updateUserInterface = () => {
if (!field.widget) {
return
}
field.reload()
field.widget.render(field.value)
field.widget.checkPageMatch?.()
}
return field
}
/**
* @returns {string}
*/
backup()
{
let drivers = {}
for (let driverKey of this._getUsedDrivers()) {
let section = {}
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if ((field.storage ?? STORAGE_DRIVER_LOCAL) !== driverKey) {
continue
}
if (field.persist === false && !field.serializeForBackup) {
continue
}
let blob = field.serializeForBackup()
if (blob !== null && blob !== undefined) {
section[fieldKey] = blob
}
}
if (Object.keys(section).length) {
drivers[driverKey] = section
}
}
let backupConfig = {
version: CONFIG_BACKUP_VERSION,
id: this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL],
drivers,
}
backupConfig = JSON.stringify(backupConfig)
let link = document.createElement('a')
link.download = this._scriptPrefix + 'backup.json'
link.href = URL.createObjectURL(new Blob([backupConfig], {
type: 'application/json',
}))
link.click()
}
/**
* @return {ConfigurationField[]}
*/
getDockRootFields()
{
let fields = []
for (let key in this._config) {
let field = this._config[key]
if (field.dock && !field.dock.parent) {
fields.push(field)
}
}
return fields.sort((left, right) => (left.dockOrder ?? 100) - (right.dockOrder ?? 100))
}
/**
* @param {string} name
* @returns {JQuery}
*/
createElement(name)
{
let field = this.getFieldOrFail(name)
if (this._dockActive && field.dock) {
field.element = null
return $()
}
return field.createElement()
}
/**
* @param {string} name
* @return {JQuery|null}
*/
createDockElement(name)
{
let field = this.getFieldOrFail(name)
if (!field.dock) {
return null
}
if (field.dock.parent) {
return null
}
if (!this._evaluateDockInclude(field)) {
return null
}
let dockButton = this._buildDockButtonNode(field)
let childFields = this._getDockChildFields(field.key)
let childButtons = []
if (childFields.length && this._shouldShowDockSlideOut(field)) {
for (let childField of childFields) {
let childNode = this._createDockChildElement(childField)
if (childNode) {
childButtons.push(childNode)
}
}
if (childButtons.length) {
dockButton = this._uiGen.createDockSlideOut(dockButton, childButtons)
}
}
field._dockShowsSlideOut = childButtons.length > 0
field.dockElement = dockButton
return dockButton
}
/**
* @param {ConfigurationField} childField
* @return {JQuery|null}
* @private
*/
_createDockChildElement(childField)
{
if (!childField.dock) {
return null
}
if (!this._evaluateDockInclude(childField)) {
return null
}
let childButton = this._buildDockButtonNode(childField)
childField.dockElement = childButton
return childButton
}
/**
* @param {ConfigurationField} field
* @return {JQuery}
* @private
*/
_buildDockButtonNode(field)
{
let stateClass = this._resolveDockStateClass(field)
let tooltip = this._resolveDockTooltip(field)
let icon = typeof field.dock.icon === 'function' ? field.dock.icon(field.value, field) : field.dock.icon
let button = this._uiGen.createDockButton({
icon: icon,
tooltip: tooltip,
stateClass: stateClass,
onClick: () => this._handleDockButtonClick(field),
})
if (field.dock.buttonClass) {
button.addClass(field.dock.buttonClass)
}
return button
}
/**
* @param {ConfigurationField} field
* @return {string}
* @private
*/
_resolveDockStateClass(field)
{
if (field.dock.getState) {
return field.dock.getState(field.value, field) ?? ''
}
if (field.dock.isActive !== undefined) {
let active = typeof field.dock.isActive === 'function' ? field.dock.isActive(field.value, field) : field.dock.isActive
return active ? 'bv-dock-btn-active' : ''
}
if (field.type === CONFIG_TYPE_FLAG) {
return field.value ? 'bv-dock-btn-active' : ''
}
return ''
}
/**
* @param {ConfigurationField} field
* @return {string}
* @private
*/
_resolveDockTooltip(field)
{
if (typeof field.dock.tooltip === 'function') {
return field.dock.tooltip(field.value, field) ?? field.title
}
return field.dock.tooltip ?? field.helpText ?? field.title
}
/**
* @param {ConfigurationField} field
* @private
*/
_handleDockButtonClick(field)
{
if (field.dock.onClick) {
Utilities.callEventHandler(field.dock.onClick, [field])
this.updateDockInterface()
return
}
if (field.type === CONFIG_TYPE_FLAG) {
field.value = !field.value
Utilities.callEventHandler(this._onDockToggle, [field])
this.updateDockInterface()
return
}
if (field.type === CONFIG_TYPE_ACTION && field.onClick) {
Utilities.callEventHandler(field.onClick, [])
this.updateDockInterface()
}
}
/**
* @param {string} parentTitle
* @return {ConfigurationField[]}
* @private
*/
_getDockChildFields(parentKey)
{
let parentField = this.getField(parentKey)
let children = []
if (parentField?.dockSlideOutChildren) {
for (let childName of parentField.dockSlideOutChildren) {
let child = this.getField(childName)
if (child && child.dock?.parent === parentKey) {
children.push(child)
}
}
}
for (let key in this._config) {
let field = this._config[key]
if (field.dock?.parent === parentKey && !children.includes(field)) {
children.push(field)
}
}
return children
}
/**
* @param {ConfigurationField} field
* @return {boolean}
* @private
*/
_shouldShowDockSlideOut(field)
{
if (field.dock.slideOutWhen) {
return !!Utilities.callEventHandler(field.dock.slideOutWhen, [field.value, field])
}
if (field.type === CONFIG_TYPE_FLAG) {
return !!field.value
}
return true
}
/**
* @param {boolean} active
* @return {BrazenConfigurationManager}
*/
setDockActive(active)
{
this._dockActive = active
return this
}
/**
* @param {object|null} context
* @return {BrazenConfigurationManager}
*/
setDockIncludeContext(context)
{
this._dockIncludeContext = context ?? null
return this
}
/**
* @param {Function} callback
* @return {BrazenConfigurationManager}
*/
onDockToggle(callback)
{
this._onDockToggle = callback
return this
}
/**
* @param {ConfigurationField} field
* @return {boolean}
* @private
*/
_computeDockShowsSlideOut(field)
{
if (!this._shouldShowDockSlideOut(field)) {
return false
}
for (let childField of this._getDockChildFields(field.key)) {
if (this._evaluateDockInclude(childField)) {
return true
}
}
return false
}
/**
* @param {ConfigurationField} field
* @return {boolean}
* @private
*/
_evaluateDockInclude(field)
{
if (field.dock?.include === undefined) {
return true
}
if (typeof field.dock.include === 'function') {
if (this._dockIncludeContext) {
return !!field.dock.include.call(this._dockIncludeContext)
}
return !!field.dock.include()
}
return !!field.dock.include
}
/**
* @param {JQuery} railBody
* @private
*/
_syncDockRootIncludes(railBody)
{
if (!railBody?.length || !this._dockIncludeContext) {
return
}
let fields = this.getDockRootFields()
for (let field of fields) {
let shouldShow = this._evaluateDockInclude(field)
if (shouldShow && !field.dockElement?.length) {
let node = this.createDockElement(field.key)
if (node?.length) {
this._insertDockRootNode(railBody, node, field, fields)
}
continue
}
if (!shouldShow && field.dockElement?.length) {
field.dockElement.remove()
field.dockElement = null
field._dockShowsSlideOut = false
}
}
}
/**
* @param {JQuery} railBody
* @param {JQuery} node
* @param {ConfigurationField} field
* @param {ConfigurationField[]} fields
* @private
*/
_insertDockRootNode(railBody, node, field, fields)
{
let order = field.dockOrder ?? 100
let insertBefore = null
for (let other of fields) {
if ((other.dockOrder ?? 100) <= order || other === field || !other.dockElement?.length) {
continue
}
insertBefore = other.dockElement[0]
break
}
if (insertBefore) {
$(insertBefore).before(node)
return
}
railBody.append(node)
}
/**
* @param {ConfigurationField} field
* @private
*/
_refreshDockRootSlot(field)
{
let oldNode = field.dockElement
if (!oldNode?.length) {
return
}
let newNode = this.createDockElement(field.key)
if (!newNode?.length) {
oldNode.remove()
field.dockElement = null
field._dockShowsSlideOut = false
return
}
oldNode.replaceWith(newNode)
}
/**
* @return {BrazenConfigurationManager}
*/
updateDockInterface(railBody)
{
railBody = railBody ?? $('.bv-dock .bv-dock-rail-body').first()
if (railBody?.length) {
this._syncDockRootIncludes(railBody)
}
for (let field of this.getDockRootFields()) {
let showsSlideOut = this._computeDockShowsSlideOut(field)
if (field.dockElement && field._dockShowsSlideOut !== showsSlideOut) {
this._refreshDockRootSlot(field)
}
field._dockShowsSlideOut = showsSlideOut
}
for (let key in this._config) {
let field = this._config[key]
if (!field.dock || !field.dockElement) {
continue
}
this._updateDockFieldButton(field)
}
return this
}
/**
* @param {ConfigurationField} field
* @private
*/
_updateDockFieldButton(field)
{
if (!field.dockElement) {
return
}
let button = field.dockElement.hasClass('bv-dock-slot') ? field.dockElement.find('> .bv-dock-btn').first() : field.dockElement
if (!button.length) {
return
}
button.removeClass('bv-dock-btn-active')
let stateClass = this._resolveDockStateClass(field)
if (stateClass) {
button.addClass(stateClass)
}
let tooltip = this._resolveDockTooltip(field)
if (tooltip) {
button.attr('title', tooltip)
}
let icon = typeof field.dock.icon === 'function' ? field.dock.icon(field.value, field) : field.dock.icon
if (typeof icon === 'string') {
button.empty().append(BrazenViewLayer.getDockIcon(icon))
}
}
/**
* @param {string} configKey
* @returns {function(*): boolean}
*/
generateValidationCallback(configKey)
{
let validationCallback
switch (this.getField(configKey).type) {
case CONFIG_TYPE_FLAG:
case CONFIG_TYPE_RADIOS_GROUP:
case CONFIG_TYPE_SELECT:
validationCallback = (value) => value
break
case CONFIG_TYPE_CHECKBOXES_GROUP:
validationCallback = (valueKeys) => valueKeys.length
break
case CONFIG_TYPE_NUMBER:
validationCallback = (value) => value > 0
break
case CONFIG_TYPE_RANGE:
validationCallback = (range) => range.minimum > 0 || range.maximum > 0
break
case CONFIG_TYPE_RULESET:
validationCallback = (rules) => rules.length
break
case CONFIG_TYPE_TEXT:
validationCallback = (value) => value.length
break
default:
throw new Error('Associated config type requires explicit validation callback definition.')
}
return validationCallback
}
/**
* @param {string} name
* @return {ConfigurationField|null}
*/
getField(name)
{
return this._config[this._formatFieldKey(name)]
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
getFieldOrFail(name)
{
let field = this._config[this._formatFieldKey(name)]
if (field) {
return field
}
throw new Error('Field named "' + name + '" could not be found')
}
/**
* @param {string} name
* @returns {*}
*/
getValue(name)
{
return this.getFieldOrFail(name).value
}
/**
* @param {string} name
* @return {boolean}
*/
hasField(name)
{
return this.getField(name) !== undefined
}
/**
* @param {string} legacyScriptPrefix
* @return {BrazenConfigurationManager}
*/
setLegacyScriptPrefix(legacyScriptPrefix)
{
this._legacyScriptPrefix = legacyScriptPrefix
return this
}
/**
* @param {*} stored
* @return {Array}
* @private
*/
_coerceBookmarkArray(stored)
{
return Utilities.coerceBookmarkArray(stored)
}
/**
* @param {ConfigurationField} field
* @private
*/
_finalizeBookmarkMigrationState(field)
{
if (field.type !== CONFIG_TYPE_BOOKMARKS || field._storageMigrated) {
return
}
field._loadCanonicalBookmarks()
if (field.value.length) {
field._storageMigrated = true
return
}
if (!field._hasLegacyBookmarkStorage?.()) {
let storageKey = field._bookmarksStorageKey()
let stored = field._bookmarksDriver().read(storageKey, null)
if (stored === null || stored === undefined) {
field._storageMigrated = true
}
}
}
/**
* @return {BrazenConfigurationManager}
*/
reloadBookmarkFields()
{
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type !== CONFIG_TYPE_BOOKMARKS) {
continue
}
field.reload()
if (field.widget) {
field.widget.render(field.value)
field.widget.checkPageMatch?.()
}
}
return this
}
/**
* @param {*} driver
* @param {string} storeKey
* @private
*/
_safeRemoveDriverKey(driver, storeKey)
{
try {
driver.remove(storeKey)
} catch (error) {
console.log('Failed to remove storage key:', storeKey, error)
}
}
/**
* @param {*} store
* @param {Object} data
* @private
*/
_saveDriverStoreSilent(store, data)
{
this._suppressStoreNotifications = true
try {
store.save(data)
} finally {
this._suppressStoreNotifications = false
}
}
/**
* @return {Promise<void>}
* @private
*/
_yieldToBrowser()
{
return new Promise((resolve) => {
window.setTimeout(resolve, 0)
})
}
/**
* @param {string|null} message
* @private
*/
_setMigrationStatus(message)
{
Utilities.callEventHandler(this._onMigrationStatusChange, [message])
}
/**
* @param {Function} eventHandler
* @return {BrazenConfigurationManager}
*/
onMigrationStatusChange(eventHandler)
{
this._onMigrationStatusChange = eventHandler
return this
}
/**
* @return {boolean}
*/
needsStorageMigration()
{
if (this._hasLegacyPrefixStorage()) {
return true
}
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_LEDGER && !field._storageMigrated && (field._hasLegacyLedgerStorage?.() || field._hasLegacyLedgerEntryKeys?.())) {
return true
}
if (field.type === CONFIG_TYPE_BOOKMARKS && !field._storageMigrated && field._hasLegacyBookmarkStorage?.()) {
return true
}
}
return false
}
/**
* @return {Promise<void>}
*/
async runStorageMigrationAsync()
{
if (this._storageMigrationComplete || this._storageMigrationRunning) {
return
}
if (!this.needsStorageMigration()) {
this._storageMigrationComplete = true
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_LEDGER && !field._hasLegacyLedgerStorage?.() && !field._hasLegacyLedgerEntryKeys?.()) {
field._storageMigrated = true
}
if (field.type === CONFIG_TYPE_BOOKMARKS) {
this._finalizeBookmarkMigrationState(field)
}
}
return
}
this._storageMigrationRunning = true
this._suppressStoreNotifications = true
this._setMigrationStatus('Migrating storage…')
try {
this._recoverPrefixSettingsFromLegacy()
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_BOOKMARKS && !field._storageMigrated && field._hasLegacyBookmarkStorage?.()) {
this._setMigrationStatus('Migrating bookmarks…')
await this._yieldToBrowser()
field._migrateBookmarksToCanonical()
}
}
let ledgerMigrated = true
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_LEDGER && !field._storageMigrated && (field._hasLegacyLedgerStorage?.() || field._hasLegacyLedgerEntryKeys?.())) {
await field._migrateLedgerToCanonical((message) => this._setMigrationStatus(message))
if (!field._storageMigrated) {
ledgerMigrated = false
}
} else if (field.type === CONFIG_TYPE_LEDGER && !field._storageMigrated && !field._hasLegacyLedgerEntryKeys?.()) {
field._storageMigrated = true
}
}
if (ledgerMigrated) {
this._migrateLegacyScriptPrefixStores(true)
this._storageMigrationComplete = true
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_LEDGER) {
field._storageMigrated = true
}
if (field.type === CONFIG_TYPE_BOOKMARKS) {
this._finalizeBookmarkMigrationState(field)
}
}
this._syncDriverStores()
}
} catch (error) {
console.log('Storage migration failed:', error)
} finally {
this.reloadBookmarkFields()
this._suppressStoreNotifications = false
this._storageMigrationRunning = false
this._setMigrationStatus(null)
}
}
/**
* @param {*} legacy
* @param {*} current
* @return {Object}
* @private
*/
_mergePrefixSettings(legacy, current)
{
let legacyObj = legacy && typeof legacy === 'object' && !Array.isArray(legacy) ? legacy : {}
let currentObj = current && typeof current === 'object' && !Array.isArray(current) ? current : {}
if (!Object.keys(legacyObj).length) {
return {...currentObj}
}
if (!Object.keys(currentObj).length) {
return {...legacyObj}
}
return {...legacyObj, ...currentObj}
}
/**
* @param {*} settings
* @return {boolean}
* @private
*/
_settingsHasUserData(settings)
{
return !!(settings && typeof settings === 'object' && !Array.isArray(settings) && Object.keys(settings).length)
}
/**
* @return {boolean}
* @private
*/
_hasLegacyPrefixStorage()
{
let legacyPrefix = this._legacyScriptPrefix
if (!legacyPrefix || legacyPrefix === this._scriptPrefix) {
return false
}
for (let driverKey of [STORAGE_DRIVER_LOCAL, STORAGE_DRIVER_GM]) {
let driver = this._drivers[driverKey]
if (!driver) {
continue
}
if (driver.read(legacyPrefix + 'settings', null) !== null) {
return true
}
if (driver.read(legacyPrefix + 'settings-id', null) !== null) {
return true
}
}
return false
}
/**
* Merge legacy prefix settings into the canonical prefix without deleting legacy copies.
* @private
*/
_recoverPrefixSettingsFromLegacy()
{
this._migrateLegacyScriptPrefixStores(false)
}
/**
* Aggregate settings use per-driver canonical blobs (`<prefix>settings`). Each driver is
* independent — legacy prefix data is renamed/merged only on the driver where it exists.
* @param {boolean} [deleteLegacy]
* @private
*/
_migrateLegacyScriptPrefixStores(deleteLegacy = false)
{
let legacyPrefix = this._legacyScriptPrefix
if (!legacyPrefix || legacyPrefix === this._scriptPrefix) {
return
}
let localDriver = this._drivers[STORAGE_DRIVER_LOCAL]
let gmDriver = this._drivers[STORAGE_DRIVER_GM]
let newSettingsKey = this._scriptPrefix + 'settings'
let legacySettingsKey = legacyPrefix + 'settings'
let legacyIdKey = legacyPrefix + 'settings-id'
let newIdKey = this._scriptPrefix + 'settings-id'
for (let driver of [localDriver, gmDriver]) {
let legacySettings = driver.read(legacySettingsKey, null)
if (legacySettings !== null) {
let current = driver.read(newSettingsKey, null)
driver.write(newSettingsKey, this._mergePrefixSettings(legacySettings, current))
}
let legacyId = driver.read(legacyIdKey, null)
if (legacyId !== null && legacyId !== undefined) {
let currentId = driver.read(newIdKey, null)
if (currentId === null || currentId === undefined) {
driver.write(newIdKey, legacyId)
}
}
if (deleteLegacy) {
if (legacySettings !== null) {
let merged = driver.read(newSettingsKey, null)
if (this._settingsHasUserData(merged)) {
this._safeRemoveDriverKey(driver, legacySettingsKey)
}
}
if (legacyId !== null && legacyId !== undefined) {
let mergedId = driver.read(newIdKey, null)
if (mergedId !== null && mergedId !== undefined) {
this._safeRemoveDriverKey(driver, legacyIdKey)
}
}
}
}
}
/**
* @return {BrazenConfigurationManager}
*/
initialize()
{
this._recoverPrefixSettingsFromLegacy()
for (let driverKey of this._getPersistedStoreDrivers()) {
this._ensureDriverStore(driverKey)
}
if (!this._driverStores[STORAGE_DRIVER_LOCAL]) {
this._ensureDriverStore(STORAGE_DRIVER_LOCAL)
}
this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL] = this._driverStoreIds.local.get().id
this._syncDriverStores()
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_BOOKMARKS) {
field.reload()
}
}
$(document).on('visibilitychange', () => {
if (document.hidden) {
return
}
let localId = this._driverStoreIds.local.get().id
if (this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL] !== localId) {
this._syncDriverStores(STORAGE_DRIVER_LOCAL)
this._syncedDriverStoreIds[STORAGE_DRIVER_LOCAL] = localId
Utilities.callEventHandler(this._onExternalConfigurationChange, [this])
}
})
return this
}
/**
* @param {ExternalConfigurationChangeCallback} eventHandler
* @return {BrazenConfigurationManager}
*/
onExternalConfigurationChange(eventHandler)
{
this._onExternalConfigurationChange = eventHandler
return this
}
/**
* @param {Response} response
*/
async restore(response)
{
try {
let backupConfig = await response.json()
if (backupConfig.version === CONFIG_BACKUP_VERSION && backupConfig.drivers) {
this._restoreVersionedBackup(backupConfig)
} else {
this._restoreLegacyBackup(backupConfig)
}
alert('Brazen script - Backup restored!')
} catch {
alert('Brazen script - The supplied backup file seems to have been corrupted!')
}
}
revertChanges()
{
this._syncDriverStores()
}
/**
* @return {BrazenConfigurationManager}
*/
save()
{
this.update()
for (let driverKey of this._getUsedDrivers()) {
if (this._driverStores[driverKey]) {
this._driverStores[driverKey].save(this._toStoreObject(driverKey))
this._updateDriverStoreId(driverKey)
}
}
return this
}
/**
* @return {BrazenConfigurationManager}
*/
update()
{
let field
for (let fieldName in this._config) {
field = this._config[fieldName]
if (field.element) {
field.setFromUserInterface()
}
}
return this
}
/**
* @return {BrazenConfigurationManager}
*/
updateInterface()
{
let field
for (let fieldName in this._config) {
field = this._config[fieldName]
if (field.element) {
field.updateUserInterface()
}
}
if (this._dockActive) {
this.updateDockInterface()
}
return this
}
}