Configuration management for the Brazen user scripts framework
Tento skript by neměl být instalován přímo. Jedná se o knihovnu, kterou by měly jiné skripty využívat pomocí meta příkazu // @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
}
}