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/1876579/Brazen%20Framework%20-%20Configuration%20Manager.js
// ==UserScript==
// @name Brazen Framework - Configuration Manager
// @namespace brazenvoid
// @version 5.0.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Configuration management for the Brazen user scripts framework
// ==/UserScript==
const CONFIG_BACKUP_VERSION = 3
const CONFIG_TYPE_CHECKBOXES_GROUP = 'checkboxes'
const CONFIG_TYPE_COLOR = 'color'
const CONFIG_TYPE_FLAG = 'flag'
const CONFIG_TYPE_LEDGER = 'ledger'
const CONFIG_TYPE_NUMBER = 'number'
const CONFIG_TYPE_RADIOS_GROUP = 'radios'
const CONFIG_TYPE_RANGE = 'range'
const CONFIG_TYPE_RULESET = 'ruleset'
const CONFIG_TYPE_SELECT = 'select'
const CONFIG_TYPE_TEXT = 'text'
const CONFIG_TYPE_ACTION = 'action'
const CONFIG_TYPE_BOOKMARKS = 'bookmarks'
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 {Map<string, {value: *, optimized: *}>}
* @private
*/
_settingCache = new Map()
/**
* @type {string|null}
* @private
*/
_lastFieldKey = null
/**
* @type {boolean}
* @private
*/
_dockActive = false
/**
* User-script instance used to evaluate dock `include` callbacks.
* @type {object|null}
* @private
*/
_dockIncludeContext = null
/**
* @type {JQuery[]}
* @private
*/
_dockElements = []
/**
* @type {Function|null}
* @private
*/
_onDockToggle = null
/**
* @type {string|null}
* @private
*/
_legacyScriptPrefix = null
/**
* @type {Function|null}
* @private
*/
_onConfigurationChange = null
/**
* @param {string} scriptPrefix
* @param {BrazenViewLayer} uiGenerator
* @param {SearchEnhancerTagSelectorGeneratorCallback} tagSelectorGenerator
*/
constructor(scriptPrefix, uiGenerator, tagSelectorGenerator)
{
this._scriptPrefix = scriptPrefix
this._tagSelectorGenerator = tagSelectorGenerator
this._uiGen = uiGenerator
this._repos = new BrazenStorageRepositories(scriptPrefix, (source) => {
this.notifyConfigurationChange(source, true)
})
this._scriptSetupHandler = null
this._storageReady = false
this._idbBlocked = false
this._syncedRevisionId = null
this._workingSet = null
}
// -------------------------------------------------------------------------
// 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
this._cacheSetting(fieldKey, value, this._computeFieldOptimized(fieldKey, value))
} else {
field = {
key: fieldKey,
element: null,
helpText: helpText,
title: name,
type: type,
value: value,
createElement: null,
setFromUserInterface: null,
updateUserInterface: null,
}
this._config[fieldKey] = field
}
this._lastFieldKey = fieldKey
this._cacheSetting(fieldKey, value, this._computeFieldOptimized(fieldKey, value))
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
}
if (field.key) {
this._cacheSetting(field.key, value, this._computeFieldOptimized(field.key, 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.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
}
}
/**
* @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])
}
}
}
}
/**
* @param {string} fieldKey
* @param {*} value
* @param {*} optimized
* @private
*/
_cacheSetting(fieldKey, value, optimized)
{
this._settingCache.set(fieldKey, {value, optimized})
}
/**
* @param {string} fieldKey
* @return {{value: *, optimized: *}|undefined}
* @private
*/
_getCachedSetting(fieldKey)
{
return this._settingCache.get(fieldKey)
}
/**
* @param {string} source
* @param {boolean} local
* @return {BrazenConfigurationManager}
*/
notifyConfigurationChange(source = 'all', local = true)
{
Utilities.callEventHandler(this._onConfigurationChange, [{
manager: this,
source,
local,
}])
return this
}
/**
* @param {string} source
* @param {boolean} local
* @return {void}
* @private
*/
_notifyConfigurationChange(source = 'all', local = true)
{
this.notifyConfigurationChange(source, local)
}
/**
* @param {ConfigurationField} field
* @return {ConfigurationField}
* @private
*/
_overlayFieldFromCache(field)
{
if (!field || !this.canPersist()) {
return field
}
let cached = this._getCachedSetting(field.key)
if (cached) {
field.value = cached.value
field.optimized = cached.optimized
}
return field
}
/**
* @param {ConfigurationField} field
* @return {*}
* @private
*/
_getEffectiveFieldValue(field)
{
if (this.canPersist()) {
let cached = this._getCachedSetting(field.key)
if (cached) {
return cached.value
}
}
return field.value
}
/**
* @return {Promise<void>}
* @private
*/
async _refreshSettingCacheFromIdb()
{
if (!this.canPersist()) {
return
}
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.persist === false) {
continue
}
if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
let compiled = await this._repos.tags.getCompiledField(fieldKey)
if (compiled) {
this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
}
continue
}
if (field.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_TEXT || field.type === CONFIG_TYPE_FLAG ||
field.type === CONFIG_TYPE_NUMBER || field.type === CONFIG_TYPE_RANGE || field.type === CONFIG_TYPE_SELECT ||
field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_CHECKBOXES_GROUP || field.type === CONFIG_TYPE_COLOR) {
let stored = await this._repos.settings.getField(fieldKey)
if (stored) {
this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
}
}
}
}
/**
* @param {string} name
* @return {string}
* @private
*/
_formatFieldKey(name)
{
return Utilities.toKebabCase(name)
}
/**
* @param {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('&')) {
growRuleset(iteratedRuleset, andTag.trim())
}
optimizedRuleset = optimizedRuleset.concat(iteratedRuleset)
}
return optimizedRuleset.sort((a, b) => a.length - b.length)
}
// -------------------------------------------------------------------------
// Public class methods
// -------------------------------------------------------------------------
/**
* @param {string} name
* @param {array} keyValuePairs
* @return {ConfigurationField}
*/
addCheckboxesGroup(name, keyValuePairs)
{
let field = this._createField(CONFIG_TYPE_CHECKBOXES_GROUP, name, [], null)
field.options = keyValuePairs
field.createElement = () => {
field.element = this._uiGen.createFormCheckBoxesGroupSection(field.title, field.options, 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)
let cm = this
field.persist = false
field.downloadedIds = new Set()
field._ledgerPrimaryField = () => field.ledgerPrimaryField ?? 'ids'
field._ledgerIsValidId = () => field.ledgerIsValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)
field.readIds = (registry) => {
if (!registry || typeof registry !== 'object') {
return []
}
let primaryField = field._ledgerPrimaryField()
let ids = []
let seen = new Set()
let addValue = (value) => {
if (value === null || value === undefined) {
return
}
let text = String(value).trim()
if (!text.length) {
return
}
if (!field._ledgerIsValidId()(text)) {
let match = text.match(/[?&]id=(\d+)/)
text = match ? match[1] : null
}
if (!text || seen.has(text)) {
return
}
seen.add(text)
ids.push(text)
}
if (Array.isArray(registry[primaryField])) {
for (let value of registry[primaryField]) {
addValue(value)
}
}
return ids
}
field.reload = async () => {
field.downloadedIds = new Set()
if (!cm.canPersist()) {
return field
}
try {
let rows = await cm._repos.storage.getAll(IDB_STORE_LEDGER)
field.downloadedIds = new Set(rows.map((row) => String(row.postId).trim()).filter(Boolean))
} catch (error) {
console.log('Failed to load ledger from IndexedDB:', error)
}
return field
}
field.claim = async (downloadIds) => {
let isValidId = field._ledgerIsValidId()
let ids = [...new Set(downloadIds
.filter((id) => id !== null && id !== undefined && String(id).length)
.map((id) => String(id).trim()))]
if (!ids.length) {
return true
}
if (!cm.canPersist()) {
return true
}
await cm._ensureStorageReady()
for (let id of ids) {
if (!isValidId(id)) {
continue
}
if (await cm._repos.ledger.has(id)) {
return false
}
}
for (let id of ids) {
if (!isValidId(id)) {
continue
}
await cm._repos.ledger.claim(id)
field.downloadedIds.add(id)
}
return true
}
field.has = async (id) => {
if (id === null || id === undefined) {
return false
}
id = String(id).trim()
if (!cm.canPersist()) {
return false
}
if (field.downloadedIds.has(id)) {
return true
}
return cm._repos.ledger.has(id)
}
field.merge = async (registry) => {
if (!registry || typeof registry !== 'object' || !cm.canPersist()) {
return
}
let ids = field.readIds(registry)
if (!ids.length) {
return
}
let rows = ids.map((postId) => ({postId, claimedAt: Date.now()}))
await cm._repos.ledger.mergeRows(rows)
await field.reload()
}
field.serializeForBackup = async () => {
await field.reload()
let ids = [...field.downloadedIds]
return ids.length ? {[field._ledgerPrimaryField()]: ids} : null
}
field.applyFromBackup = (data) => {
void 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)
let cm = this
field.persist = false
field.widget = null
field.showAddButton = true
field._bookmarkRows = []
field._normalizeBookmarks = () => field.normalizeBookmarks ?? ((bookmarks) => bookmarks)
field.reload = async () => {
field._bookmarkRows = []
if (!cm.canPersist()) {
return field
}
try {
field._bookmarkRows = await cm._repos.bookmarks.listAll()
} catch (error) {
console.log('Failed to load bookmarks:', error)
}
return field
}
field.serializeForBackup = async () => {
await field.reload()
return field._bookmarkRows.length ? field._bookmarkRows : null
}
field.applyFromBackup = async (data) => {
if (!Array.isArray(data) || !cm.canPersist()) {
return
}
let rows = field._normalizeBookmarks()(data)
await cm._repos.bookmarks.replaceAll(rows)
await field.reload()
field.widget?.render(field._bookmarkRows)
}
field.createElement = () => {
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
void field.reload().then(() => {
field.widget.render(field._bookmarkRows)
field.widget.checkPageMatch?.()
})
return field.element
}
field.setFromUserInterface = () => {}
field.updateUserInterface = () => {
if (!field.widget) {
return
}
void field.reload().then(() => {
field.widget.render(field._bookmarkRows)
field.widget.checkPageMatch?.()
})
}
return field
}
/**
* @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 $()
}
if (this.canPersist()) {
void this._syncFieldFromIdb(this._formatFieldKey(name)).then(() => {
this._overlayFieldFromCache(field)
if (field.element) {
field.updateUserInterface()
}
})
}
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 value = this._getEffectiveFieldValue(field)
let stateClass = this._resolveDockStateClass(field, value)
let tooltip = this._resolveDockTooltip(field, value)
let icon = typeof field.dock.icon === 'function' ? field.dock.icon(value, field) : field.dock.icon
let button = this._uiGen.createDockButton({
icon: icon,
tooltip: tooltip,
stateClass: stateClass,
onClick: () => this._handleDockButtonClick(field),
})
if (field.dock.buttonClass) {
button.addClass(field.dock.buttonClass)
}
return button
}
/**
* @param {ConfigurationField} field
* @return {string}
* @private
*/
_resolveDockStateClass(field, value = null)
{
value = value ?? this._getEffectiveFieldValue(field)
if (field.dock.getState) {
return field.dock.getState(value, field) ?? ''
}
if (field.dock.isActive !== undefined) {
let active = typeof field.dock.isActive === 'function' ? field.dock.isActive(value, field) : field.dock.isActive
return active ? 'bv-dock-btn-active' : ''
}
if (field.type === CONFIG_TYPE_FLAG) {
return value ? 'bv-dock-btn-active' : ''
}
return ''
}
/**
* @param {ConfigurationField} field
* @param {*} value
* @return {string}
* @private
*/
_resolveDockTooltip(field, value = null)
{
value = value ?? this._getEffectiveFieldValue(field)
if (typeof field.dock.tooltip === 'function') {
return field.dock.tooltip(value, field) ?? field.title
}
return field.dock.tooltip ?? field.helpText ?? field.title
}
/**
* Release focus from slide-out child buttons so the slot can collapse (CSS uses :focus-within).
* @private
*/
_blurDockSlideOutFocus()
{
let active = document.activeElement
if (active instanceof HTMLElement && active.closest('.bv-dock-slot')) {
active.blur()
}
}
/**
* @param {ConfigurationField} field
* @private
*/
_handleDockButtonClick(field)
{
if (field.dock.onClick) {
Utilities.callEventHandler(field.dock.onClick, [field])
this._blurDockSlideOutFocus()
this.updateDockInterface()
return
}
if (field.type === CONFIG_TYPE_FLAG) {
this._blurDockSlideOutFocus()
void this._toggleDockFlagFromIdb(field)
return
}
if (field.type === CONFIG_TYPE_ACTION && field.onClick) {
Utilities.callEventHandler(field.onClick, [])
this._blurDockSlideOutFocus()
this.updateDockInterface()
}
}
/**
* @param {ConfigurationField} field
* @private
*/
async _toggleDockFlagFromIdb(field)
{
let current = await this.readSetting(field.key)
if (current === null || current === undefined) {
current = !!field.value
}
await this.writeSetting(field.key, !current)
Utilities.callEventHandler(this._onDockToggle, [field])
this.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)
{
if (!this._onDockToggle) {
this._onDockToggle = callback
} else {
let existing = this._onDockToggle
this._onDockToggle = (field) => {
Utilities.callEventHandler(existing, [field])
Utilities.callEventHandler(callback, [field])
}
}
return this
}
/**
* @param {ConfigurationField} field
* @return {boolean}
* @private
*/
_computeDockShowsSlideOut(field)
{
if (!this._shouldShowDockSlideOut(field)) {
return false
}
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
}
this._overlayFieldFromCache(field)
let value = this._getEffectiveFieldValue(field)
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, value)
if (stateClass) {
button.addClass(stateClass)
}
let tooltip = this._resolveDockTooltip(field, value)
if (tooltip) {
button.attr('title', tooltip)
}
let icon = typeof field.dock.icon === 'function' ? field.dock.icon(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)
{
let field = this._config[this._formatFieldKey(name)]
if (field) {
this._overlayFieldFromCache(field)
}
return field
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
getFieldOrFail(name)
{
let field = this.getField(name)
if (field) {
return field
}
throw new Error('Field named "' + name + '" could not be found')
}
/**
* @param {string} name
* @returns {*}
*/
getValue(name)
{
let fieldKey = this._formatFieldKey(name)
if (this.canPersist()) {
let cached = this._getCachedSetting(fieldKey)
if (cached) {
return cached.value
}
}
return this.getFieldOrFail(name).value
}
/**
* @param {string} name
* @returns {*}
*/
getOptimized(name)
{
let fieldKey = this._formatFieldKey(name)
if (this.canPersist()) {
let cached = this._getCachedSetting(fieldKey)
if (cached) {
return cached.optimized
}
}
let field = this.getFieldOrFail(name)
return field.optimized ?? this._computeFieldOptimized(fieldKey, field.value)
}
/**
* @param {string} name
* @return {boolean}
*/
hasField(name)
{
return this.getField(name) !== undefined
}
/**
* @param {string} legacyScriptPrefix
* @return {BrazenConfigurationManager}
*/
setLegacyScriptPrefix(legacyScriptPrefix)
{
this._legacyScriptPrefix = legacyScriptPrefix
return this
}
/**
* @return {Promise<BrazenConfigurationManager>}
*/
async reloadBookmarkFields()
{
let tasks = []
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type !== CONFIG_TYPE_BOOKMARKS) {
continue
}
tasks.push(field.reload().then(() => {
if (field.widget) {
field.widget.render(field._bookmarkRows)
field.widget.checkPageMatch?.()
}
}))
}
await Promise.all(tasks)
return this
}
/**
* @param {Function} handler
* @return {BrazenConfigurationManager}
*/
setScriptSetup(handler)
{
this._scriptSetupHandler = handler
return this
}
/**
* @return {Promise<BrazenConfigurationManager>}
*/
async initialize()
{
if (!this._repos.storage.available) {
this._idbBlocked = true
console.warn('[BrazenCM] IndexedDB unavailable — persistence features disabled')
return this
}
try {
await this._repos.storage.open()
let existingMeta = await this._repos.meta.get()
if (existingMeta?.setupInProgress) {
await this._repos.meta.waitForSetupComplete()
}
let healthy = await this._repos.storage.isHealthy()
if (!healthy) {
await this._repos.storage.deleteDatabase()
await this._repos.storage.open()
await this._runSetupPhase()
}
await this._runNormalPhase()
} catch (error) {
console.log('[BrazenCM] initialize failed:', error)
this._idbBlocked = true
}
return this
}
/**
* @return {Promise<void>}
* @private
*/
async _runSetupPhase()
{
await this._repos.meta.beginSetup()
await this._repos.storage.put(IDB_STORE_META, await this._repos.storage.createDefaultMeta())
await this._repos.storage.put(IDB_STORE_SETTINGS, await this._repos.storage.createEmptySettingsDocument())
await this._repos.storage.put(IDB_STORE_APIS, await this._repos.storage.createEmptyApisDocument())
await this._repos.storage.put(IDB_STORE_TAG_TYPES, await this._repos.storage.createEmptyTagTypesDocument())
await this._repos.storage.put(IDB_STORE_TAG_RULE_SETS, await this._repos.storage.createEmptyTagRuleSetsDocument())
let sources = []
if (this._legacyDataExists()) {
let importer = new BrazenLegacyImporter(this._repos, this)
sources = await importer.run()
}
if (this._scriptSetupHandler) {
await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
}
await this._importTagDomainFromLegacySettings()
await this._recompileAllTagRuleSets()
await this._repos.meta.completeSetup(sources)
this._storageReady = true
await this._syncFieldsFromIdb()
}
/**
* @return {Promise<void>}
* @private
*/
async _runNormalPhase()
{
await this._repos.meta.waitForSetupComplete()
this._storageReady = true
let meta = await this._repos.meta.get()
this._syncedRevisionId = meta?.revisionId ?? null
await this._syncFieldsFromIdb()
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_BOOKMARKS || field.type === CONFIG_TYPE_LEDGER) {
await field.reload()
}
}
$(document).off('visibilitychange.brazenIdb').on('visibilitychange.brazenIdb', async () => {
if (document.hidden) {
return
}
let revisionId = await this._repos.meta.get().then((m) => m?.revisionId)
if (this._syncedRevisionId !== revisionId) {
this._syncedRevisionId = revisionId
this._workingSet = null
await this._syncFieldsFromIdb()
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_BOOKMARKS) {
await field.reload()
}
}
this.notifyConfigurationChange('all', false)
}
})
}
/**
* @return {boolean}
* @private
*/
_legacyDataExists()
{
return legacyStorageHasData(this._scriptPrefix, this._legacyScriptPrefix)
}
/**
* @return {Promise<void>}
* @private
*/
async _syncFieldsFromIdb()
{
await this._refreshSettingCacheFromIdb()
}
/**
* @param {string} fieldKey
* @return {Promise<void>}
* @private
*/
async _syncFieldFromIdb(fieldKey)
{
let field = this._config[fieldKey]
if (!field) {
return
}
if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
let compiled = await this._repos.tags.getCompiledField(fieldKey)
if (compiled) {
this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
this._overlayFieldFromCache(field)
}
return
}
if (field.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_TEXT || field.type === CONFIG_TYPE_FLAG ||
field.type === CONFIG_TYPE_NUMBER || field.type === CONFIG_TYPE_RANGE || field.type === CONFIG_TYPE_SELECT ||
field.type === CONFIG_TYPE_RADIOS_GROUP || field.type === CONFIG_TYPE_CHECKBOXES_GROUP || field.type === CONFIG_TYPE_COLOR) {
let stored = await this._repos.settings.getField(fieldKey)
if (stored) {
this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
}
}
}
/**
* @param {string} fieldKey
* @param {*} value
* @return {*}
* @private
*/
_computeFieldOptimized(fieldKey, value)
{
let field = this._config[fieldKey]
if (!field) {
return null
}
return Utilities.callEventHandler(field.onOptimize, [value], value)
}
/**
* @return {Promise<void>}
* @private
*/
async _importTagDomainFromLegacySettings()
{
let aggregate = readLegacySettingsAggregate(this._scriptPrefix)
if (!aggregate || typeof aggregate !== 'object') {
return
}
for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
if (aggregate[fieldKey] === undefined) {
continue
}
await this._importTagFieldFromValue(fieldKey, aggregate[fieldKey])
}
}
/**
* @param {string} fieldKey
* @param {*} value
* @return {Promise<void>}
* @private
*/
async _importTagFieldFromValue(fieldKey, value)
{
let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
if (!group) {
return
}
let lines = []
if (Array.isArray(value)) {
if (value.length && typeof value[0] === 'object' && value[0]?.subject) {
for (let entry of value) {
if (entry?.subject) {
await this._repos.tags.registerTag(entry.subject)
lines.push(entry.subject + ' → ' + (entry.replacement ?? ''))
}
}
} else {
lines = value.map(String)
}
}
let expanded = []
for (let line of lines) {
if (line.includes('|')) {
expanded.push(...expandOrRuleLine(line))
} else {
expanded.push(line)
}
}
let sortOrder = 0
for (let rawLine of expanded) {
let tagName = String(rawLine).split('//')[0].split('&')[0].trim()
if (tagName) {
await this._repos.tags.registerTag(tagName)
}
await this._repos.tagRules.addRule(group, {
sortOrder: sortOrder++,
rawLine,
mode: fieldKey.includes('tag') && this._config[fieldKey]?.useSelectors ? 'selectors' : 'tags',
})
}
}
/**
* @return {Promise<void>}
* @private
*/
async _recompileAllTagRuleSets()
{
for (let fieldKey of Object.keys(TAG_RULESET_PROPERTY_BY_FIELD)) {
if (!this._config[fieldKey]) {
continue
}
let field = this._config[fieldKey]
let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
if (!group) {
continue
}
await this._repos.tags.compileGroup(group, (lines) => this._optimizeTagRuleset(lines, !!field.useSelectors), !!field.useSelectors)
}
await this._repos.tags.compilePendingGroups(
(lines, useSelectors) => this._optimizeTagRuleset(lines, useSelectors),
{},
)
}
/**
* @return {Promise<void>}
* @private
*/
async _ensureStorageReady()
{
if (!this._storageReady) {
await this.initialize()
}
}
/**
* @return {boolean}
*/
isIdbBlocked()
{
return this._idbBlocked
}
/**
* @return {boolean}
*/
isStorageReady()
{
return this._storageReady && !this._idbBlocked
}
/**
* @type {boolean}
* @private
*/
_tagCompileQueued = false
/**
* @return {boolean}
*/
canPersist()
{
return this._storageReady && !this._idbBlocked
}
/**
* Read a settings field from IndexedDB (source of truth via `_settingCache`).
* @param {string} fieldKey
* @return {Promise<*>}
*/
async readSetting(fieldKey)
{
if (!this.canPersist()) {
return this.getField(fieldKey)?.value ?? null
}
await this._ensureStorageReady()
let stored = await this._repos.settings.getField(fieldKey)
if (!stored) {
return this._getCachedSetting(fieldKey)?.value ?? this.getField(fieldKey)?.value ?? null
}
this._cacheSetting(fieldKey, stored.value, stored.optimized ?? this._computeFieldOptimized(fieldKey, stored.value))
return stored.value
}
/**
* Write a settings field directly to IndexedDB (headless / dock-only fields). `save()` only persists mounted panel fields.
* @param {string} fieldKey
* @param {*} value
* @return {Promise<BrazenConfigurationManager>}
*/
async writeSetting(fieldKey, value)
{
if (!this.canPersist()) {
return this
}
await this._ensureStorageReady()
let field = this.getFieldOrFail(fieldKey)
let optimized = Utilities.callEventHandler(field.onOptimize, [value], value)
await this._repos.settings.putField(fieldKey, value, optimized)
this._cacheSetting(fieldKey, value, optimized)
this._overlayFieldFromCache(field)
await this._repos.meta.bumpRevision()
this._syncedRevisionId = await this._repos.storage.getRevisionId()
this.notifyConfigurationChange('settings', true)
return this
}
/**
* @return {BrazenStorageRepositories}
*/
getRepos()
{
return this._repos
}
/**
* @return {Promise<void>}
*/
async ensureTagRuleSetsCompiled()
{
if (!this.canPersist()) {
return
}
await this._compilePendingTagRuleGroups()
}
/**
* @param {string[]} groups
* @return {Promise<void>}
*/
async scheduleTagRuleCompile(groups)
{
if (!this.canPersist() || !groups?.length) {
return
}
await this._repos.meta.markCompilePending(groups)
if (this._tagCompileQueued) {
return
}
this._tagCompileQueued = true
queueMicrotask(async () => {
this._tagCompileQueued = false
try {
await this._compilePendingTagRuleGroups()
} catch (error) {
console.log('[BrazenCM] tag rule compile failed:', error)
}
})
}
/**
* @param {string} fieldKey
* @return {Promise<void>}
* @private
*/
async _commitTagFieldChange()
{
await this._repos.meta.bumpRevision()
this._syncedRevisionId = await this._repos.storage.getRevisionId()
this.notifyConfigurationChange('tags', true)
}
/**
* @param {string} fieldKey
* @param {string} tagName
* @return {Promise<boolean>}
*/
async toggleTagRule(fieldKey, tagName)
{
let field = this._config[fieldKey]
let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
if (!field || !group) {
return false
}
if (!this.canPersist()) {
field.toggleRule(tagName, tagName)
return true
}
await this._syncFieldFromIdb(fieldKey)
let normalized = String(tagName)
if (field.hasRule?.(normalized)) {
await this._repos.tagRules.removeSoleTagRules(group, normalized)
} else {
await this._repos.tags.registerTag(normalized)
let rules = await this._repos.tagRules.listByGroup(group)
let sortOrder = rules.length ? Math.max(...rules.map((rule) => rule.sortOrder ?? 0)) + 1 : 0
await this._repos.tagRules.addRule(group, {
sortOrder,
rawLine: normalized,
mode: field.useSelectors ? 'selectors' : 'tags',
})
}
await this.scheduleTagRuleCompile([group])
await this._compilePendingTagRuleGroups()
await this._syncFieldFromIdb(fieldKey)
await this._commitTagFieldChange()
return true
}
/**
* @param {string} fieldKey
* @return {Promise<boolean>}
*/
async clearTagRules(fieldKey)
{
let field = this._config[fieldKey]
let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
if (!field || !group) {
return false
}
if (!this.canPersist()) {
field.clearRules?.()
return true
}
await this._repos.tagRules.removeAllInGroup(group)
await this.scheduleTagRuleCompile([group])
await this._compilePendingTagRuleGroups()
await this._syncFieldFromIdb(fieldKey)
await this._commitTagFieldChange()
return true
}
/**
* Persist tag-domain rules programmatically (sidebar / headless writes). Does not read the panel textarea.
* @param {string} fieldKey
* @param {string[]} rawLines
* @return {Promise<BrazenConfigurationManager>}
*/
async writeTagRuleLines(fieldKey, rawLines)
{
if (!this.canPersist()) {
let field = this.getFieldOrFail(fieldKey)
field.setRules(Array.isArray(rawLines) ? rawLines : [])
return this
}
let field = this.getFieldOrFail(fieldKey)
let lines = Array.isArray(rawLines) ? [...rawLines] : []
if (field.sortRules) {
lines.sort((left, right) => String(left).localeCompare(String(right), undefined, {sensitivity: 'base'}))
}
lines = Utilities.callEventHandler(field.onTranslateFromUI, [lines], lines)
field.value = this._deduplicateRulesetRules(lines)
await this._persistTagFieldFromUI(fieldKey, field)
await this._commitTagFieldChange()
return this
}
/**
* @return {Promise<void>}
* @private
*/
async _compilePendingTagRuleGroups()
{
if (!this.canPersist()) {
return
}
let useSelectorsByGroup = {}
for (let [fieldKey, group] of Object.entries(TAG_RULE_GROUP_BY_FIELD)) {
useSelectorsByGroup[group] = !!this._config[fieldKey]?.useSelectors
}
await this._repos.tags.compilePendingGroups(
(lines, useSelectors) => this._optimizeTagRuleset(lines, useSelectors),
useSelectorsByGroup,
)
for (let fieldKey of Object.keys(TAG_RULE_GROUP_BY_FIELD)) {
if (!this._config[fieldKey]) {
continue
}
let compiled = await this._repos.tags.getCompiledField(fieldKey)
if (compiled) {
this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
}
}
}
/**
* @return {BrazenConfigurationManager}
*/
initializeSyncCompat()
{
void this.initialize()
return this
}
/**
* @param {Function} eventHandler
* @return {BrazenConfigurationManager}
*/
onConfigurationChange(eventHandler)
{
if (this._onConfigurationChange && this._onConfigurationChange !== eventHandler) {
console.warn('[BrazenCM] onConfigurationChange already registered — replacing handler')
}
this._onConfigurationChange = eventHandler
return this
}
/**
* @param {ExternalConfigurationChangeCallback} eventHandler
* @return {BrazenConfigurationManager}
* @deprecated Use Framework `onConfigurationChange` for script hooks; only Framework should register on CM.
*/
onExternalConfigurationChange(eventHandler)
{
return this.onConfigurationChange(eventHandler)
}
/**
* @param {Response} response
*/
async restore(response)
{
try {
await this._ensureStorageReady()
let backupConfig
if (response instanceof Response) {
let buffer = await response.arrayBuffer()
if (buffer.byteLength >= 4 && new DataView(buffer).getUint32(0, true) === 0x04034b50) {
backupConfig = await this._parseZipBackup(buffer)
} else {
backupConfig = JSON.parse(new TextDecoder().decode(buffer))
}
} else {
backupConfig = await new Response(response).json()
}
if (backupConfig.version === CONFIG_BACKUP_VERSION && (backupConfig.stores || backupConfig.meta)) {
await this._restoreV3Backup(backupConfig)
} else if (backupConfig.version === 2 && backupConfig.drivers) {
await this._importV2BackupToIdb(backupConfig)
await this._recompileAllTagRuleSets()
} else {
await this._importLegacyBackupToIdb(backupConfig)
await this._recompileAllTagRuleSets()
}
await this._syncFieldsFromIdb()
await this._repos.meta.bumpRevision()
this.notifyConfigurationChange('all', true)
alert('Brazen script - Backup restored!')
} catch (error) {
console.log('Restore failed:', error)
alert('Brazen script - The supplied backup file seems to have been corrupted!')
}
}
async revertChanges()
{
if (this._idbBlocked) {
return this
}
await this._ensureStorageReady()
this._workingSet = null
await this._syncFieldsFromIdb()
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_BOOKMARKS) {
await field.reload()
}
}
this.notifyConfigurationChange('all', true)
return this
}
refreshMountedFields()
{
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.element) {
this._overlayFieldFromCache(field)
field.updateUserInterface()
}
}
return this
}
/**
* @return {Promise<BrazenConfigurationManager>}
*/
async save()
{
if (this._idbBlocked) {
return this
}
await this._ensureStorageReady()
this.update()
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.persist === false) {
continue
}
if (!field.element) {
continue
}
if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
await this._persistTagFieldFromUI(fieldKey, field)
continue
}
field.optimized = Utilities.callEventHandler(field.onOptimize, [field.value], field.value)
await this._repos.settings.putField(fieldKey, field.value, field.optimized)
this._cacheSetting(fieldKey, field.value, field.optimized)
}
await this._compilePendingTagRuleGroups()
await this._repos.meta.bumpRevision()
this._syncedRevisionId = await this._repos.storage.getRevisionId()
this.notifyConfigurationChange('all', true)
return this
}
/**
* @param {string} fieldKey
* @param {ConfigurationField} field
* @return {Promise<void>}
* @private
*/
async _persistTagFieldFromUI(fieldKey, field)
{
let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
if (!group) {
return
}
let lines = Array.isArray(field.value) ? field.value : []
let existing = await this._repos.tagRules.listByGroup(group)
for (let rule of existing) {
await this._repos.tagRules.removeRule(rule.entryId, group)
}
let sortOrder = 0
for (let rawLine of lines) {
let lineText = rawLine
if (typeof rawLine === 'object' && rawLine !== null) {
if (field.onFormatForUI) {
lineText = Utilities.callEventHandler(field.onFormatForUI, [[rawLine]], [String(rawLine)])[0]
} else {
lineText = String(rawLine)
}
}
if (String(lineText).includes('|')) {
for (let expanded of expandOrRuleLine(String(lineText))) {
await this._importTagRuleLine(group, expanded, sortOrder++, !!field.useSelectors)
}
} else {
await this._importTagRuleLine(group, String(lineText), sortOrder++, !!field.useSelectors)
}
}
await this.scheduleTagRuleCompile([group])
await this._compilePendingTagRuleGroups()
let compiled = await this._repos.tags.getCompiledField(fieldKey)
if (compiled) {
this._cacheSetting(fieldKey, compiled.rawLines ?? [], compiled.optimized)
}
}
/**
* @param {string} group
* @param {string} rawLine
* @param {number} sortOrder
* @param {boolean} useSelectors
* @return {Promise<void>}
* @private
*/
async _importTagRuleLine(group, rawLine, sortOrder, useSelectors)
{
let tagName = rawLine.split('//')[0].split('&')[0].trim()
if (tagName && !tagName.includes('→')) {
await this._repos.tags.registerTag(tagName)
}
await this._repos.tagRules.addRule(group, {sortOrder, rawLine, mode: useSelectors ? 'selectors' : 'tags'})
}
/**
* @return {Promise<void>}
*/
async backup()
{
await this._ensureStorageReady()
let meta = await this._repos.meta.get()
let zip = new BrazenZipWriter()
zip.addFile('manifest.json', JSON.stringify({
version: CONFIG_BACKUP_VERSION,
scriptPrefix: this._scriptPrefix,
dbName: this._repos.storage.dbName,
revisionId: meta?.revisionId,
exportedAt: Date.now(),
stores: IDB_ALL_STORES,
}, null, 2))
zip.addFile('meta.json', JSON.stringify(meta, null, 2))
zip.addFile('settings.json', JSON.stringify(await this._repos.settings.getDocument(), null, 2))
zip.addFile('apis.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_APIS, 'apis'), null, 2))
zip.addFile('tagTypes.json', JSON.stringify(await this._repos.storage.get(IDB_STORE_TAG_TYPES, 'tagTypes'), null, 2))
zip.addFile('tags.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_TAGS), null, 2))
zip.addFile('tagRules.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_TAG_RULES), null, 2))
zip.addFile('tagRuleSets.json', JSON.stringify(await this._repos.tags.getTagRuleSetsDocument(), null, 2))
zip.addFile('bookmarks.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_BOOKMARKS), null, 2))
zip.addFile('ledgerEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_LEDGER), null, 2))
let link = document.createElement('a')
link.download = this._scriptPrefix + 'backup.zip'
link.href = URL.createObjectURL(zip.build())
link.click()
}
/**
* @param {ArrayBuffer} buffer
* @return {Promise<{version: number, stores: object}>}
* @private
*/
async _parseZipBackup(buffer)
{
let files = BrazenZipReader.parse(buffer)
let manifest = JSON.parse(files['manifest.json'] ?? '{}')
return {
version: manifest.version ?? CONFIG_BACKUP_VERSION,
stores: {
meta: JSON.parse(files['meta.json'] ?? 'null'),
settings: JSON.parse(files['settings.json'] ?? 'null'),
apis: JSON.parse(files['apis.json'] ?? 'null'),
tagTypes: JSON.parse(files['tagTypes.json'] ?? 'null'),
tags: JSON.parse(files['tags.json'] ?? '[]'),
tagRules: JSON.parse(files['tagRules.json'] ?? '[]'),
tagRuleSets: JSON.parse(files['tagRuleSets.json'] ?? 'null'),
bookmarks: JSON.parse(files['bookmarks.json'] ?? '[]'),
ledgerEntries: JSON.parse(files['ledgerEntries.json'] ?? '[]'),
},
}
}
/**
* @param {Object} backupConfig
* @return {Promise<void>}
* @private
*/
async _importV2BackupToIdb(backupConfig)
{
let drivers = backupConfig.drivers ?? {}
let aggregate = {}
for (let driverKey in drivers) {
let section = drivers[driverKey]
if (section && typeof section === 'object') {
Object.assign(aggregate, section)
}
}
await this._importFlatBackupFieldsToIdb(aggregate)
if (backupConfig.id !== undefined) {
let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
meta.revisionId = String(backupConfig.id)
await this._repos.meta.put(meta)
}
}
/**
* @param {Object} backupConfig
* @return {Promise<void>}
* @private
*/
async _importLegacyBackupToIdb(backupConfig)
{
let id = backupConfig.id
let blob = {...backupConfig}
delete blob.id
await this._importFlatBackupFieldsToIdb(blob)
if (id !== undefined) {
let meta = await this._repos.meta.get() ?? await this._repos.storage.createDefaultMeta()
meta.revisionId = String(id)
await this._repos.meta.put(meta)
}
}
/**
* @param {Object} blob
* @return {Promise<void>}
* @private
*/
async _importFlatBackupFieldsToIdb(blob)
{
if (!blob || typeof blob !== 'object') {
return
}
for (let fieldKey in blob) {
let field = this._config[fieldKey]
let value = blob[fieldKey]
if (!field) {
continue
}
if (field.type === CONFIG_TYPE_BOOKMARKS) {
await field.applyFromBackup(value)
} else if (field.type === CONFIG_TYPE_LEDGER) {
await field.merge(value)
} else if (field.persist === false) {
field.applyFromBackup?.(value)
} else if (TAG_DOMAIN_FIELD_KEYS.has(fieldKey)) {
await this._importTagFieldFromValue(fieldKey, value)
} else {
let optimized = this._computeFieldOptimized(fieldKey, value)
await this._repos.settings.putField(fieldKey, value, optimized)
this._cacheSetting(fieldKey, value, optimized)
}
}
}
/**
* @param {{stores: object}} backupConfig
* @return {Promise<void>}
* @private
*/
async _restoreV3Backup(backupConfig)
{
let stores = backupConfig.stores ?? backupConfig
if (stores.meta) {
await this._repos.storage.put(IDB_STORE_META, stores.meta)
}
if (stores.settings) {
await this._repos.storage.put(IDB_STORE_SETTINGS, stores.settings)
}
if (stores.apis) {
await this._repos.storage.put(IDB_STORE_APIS, stores.apis)
}
if (stores.tagTypes) {
await this._repos.storage.put(IDB_STORE_TAG_TYPES, stores.tagTypes)
}
if (stores.tagRuleSets) {
await this._repos.storage.put(IDB_STORE_TAG_RULE_SETS, stores.tagRuleSets)
}
if (stores.tags) {
await this._repos.storage.clearStore(IDB_STORE_TAGS)
await this._repos.storage.putMany(IDB_STORE_TAGS, stores.tags)
}
if (stores.tagRules) {
await this._repos.storage.clearStore(IDB_STORE_TAG_RULES)
await this._repos.storage.putMany(IDB_STORE_TAG_RULES, stores.tagRules)
}
if (stores.bookmarks) {
await this._repos.storage.clearStore(IDB_STORE_BOOKMARKS)
await this._repos.storage.putMany(IDB_STORE_BOOKMARKS, stores.bookmarks)
}
if (stores.ledgerEntries) {
await this._repos.ledger.mergeRows(stores.ledgerEntries)
}
await this._repos.meta.bumpRevision()
}
/**
* @return {BrazenConfigurationManager}
*/
update()
{
let field
for (let fieldName in this._config) {
field = this._config[fieldName]
if (field.element) {
field.setFromUserInterface()
if (this.canPersist() && field.persist !== false) {
this._cacheSetting(field.key, field.value, this._computeFieldOptimized(field.key, field.value))
}
}
}
return this
}
/**
* @return {BrazenConfigurationManager}
*/
updateInterface()
{
let field
for (let fieldName in this._config) {
field = this._config[fieldName]
if (field.element) {
this._overlayFieldFromCache(field)
field.updateUserInterface()
}
}
if (this._dockActive) {
this.updateDockInterface()
}
return this
}
}