Configuration management for the Brazen user scripts framework
Dieses Skript sollte nicht direkt installiert werden. Es handelt sich hier um eine Bibliothek für andere Skripte, welche über folgenden Befehl in den Metadaten eines Skriptes eingebunden wird // @require https://update.greasyfork.org/scripts/418665/1875786/Brazen%20Framework%20-%20Configuration%20Manager.js
// ==UserScript==
// @name Brazen Framework - Configuration Manager
// @namespace brazenvoid
// @version 4.0.1
// @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
}
/**
* @param {StorageDriver} driver
* @return {boolean}
* @private
*/
_migrateSettingsKeyFormatForDriver(driver)
{
if (!driver) {
return false
}
let settingsKey = this._scriptPrefix + 'settings'
let settings = driver.read(settingsKey, null)
if (!settings || typeof settings !== 'object' || Array.isArray(settings)) {
return false
}
let registeredKeys = new Set(Object.keys(this._config))
let changed = false
for (let storedKey of Object.keys(settings)) {
let normalizedKey = Utilities.toKebabCase(storedKey)
if (normalizedKey === storedKey || !registeredKeys.has(normalizedKey) || settings[normalizedKey] !== undefined) {
continue
}
settings[normalizedKey] = settings[storedKey]
delete settings[storedKey]
changed = true
}
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.persist === false) {
continue
}
if (settings[field.key] !== undefined || !field.title || field.title === field.key || settings[field.title] === undefined) {
continue
}
settings[field.key] = settings[field.title]
delete settings[field.title]
changed = true
}
if (changed) {
driver.write(settingsKey, settings)
}
return changed
}
/**
* @private
*/
_migrateSettingsKeyFormat()
{
for (let driverKey of this._getPersistedStoreDrivers()) {
this._migrateSettingsKeyFormatForDriver(this._drivers[driverKey])
}
}
/**
* Merge legacy prefix settings into the canonical prefix without deleting legacy copies.
* @private
*/
_recoverPrefixSettingsFromLegacy()
{
this._migrateLegacyScriptPrefixStores(false)
this._migrateSettingsKeyFormat()
}
/**
* 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
}
}