Configuration management for the Brazen user scripts framework
Dette scriptet burde ikke installeres direkte. Det er et bibliotek for andre script å inkludere med det nye metadirektivet // @require https://update.greasyfork.org/scripts/418665/1877004/Brazen%20Framework%20-%20Configuration%20Manager.js
// ==UserScript==
// @name Brazen Framework - Configuration Manager
// @namespace brazenvoid
// @version 5.1.1
// @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)
}, (revisionId) => {
// Track every local revision bump immediately (including mid-write tag putTag bumps)
// so visibilitychange never treats our own writes as foreign and wipes unsaved edits.
this._syncedRevisionId = revisionId
})
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.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
}
/**
* Apply a named dock-button recipe, then merge optional overrides into `field.dock`.
* Framework / Download Manager ship templates on registration; apps only order keys
* (and fully define custom action docks).
*
* @param {string} name
* @param {object} [args]
* @return {ConfigurationField}
*/
field.applyDockTemplate = (name, args = {}) => {
this._applyDockTemplate(field, name, args)
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.setDockSlideOutNodes = (getNodes) => {
field.dock = field.dock ?? {}
field.dock.getSlideOutNodes = getNodes
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 () => {
if (!cm.canPersist()) {
field.downloadedIds = new Set()
return field
}
try {
let rows = await cm._repos.storage.getAll(IDB_STORE_LEDGER)
// Replace atomically — never empty the Set before load completes, or concurrent
// compliance (hide downloaded) runs against an empty ledger and shows everything.
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.claim(id))) {
return false
}
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
}
/**
* Settings field persisted via {@link readSetting}/{@link writeSetting} only — no panel UI.
* @param {string} name
* @param {*} [defaultValue]
* @return {ConfigurationField}
*/
addHeadlessSettingField(name, defaultValue = '')
{
let field = this._createField(CONFIG_TYPE_TEXT, name, defaultValue, null)
field.headless = true
field.textDefault = defaultValue ?? ''
field.createElement = () => $()
field.setFromUserInterface = () => {}
field.updateUserInterface = () => {}
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
}
/**
* @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 || field.dock.getSlideOutNodes) && this._shouldShowDockSlideOut(field)) {
if (field.dock.getSlideOutNodes) {
let customNodes = Utilities.callEventHandler(field.dock.getSlideOutNodes, [field], null)
if (Array.isArray(customNodes)) {
for (let node of customNodes) {
if (node?.length) {
childButtons.push(node)
}
}
}
}
for (let childField of childFields) {
let childNode = this._createDockChildElement(childField)
if (childNode) {
childButtons.push(childNode)
}
}
if (childButtons.length) {
if (field.dock.slideOutInsetGroup) {
childButtons = [this._uiGen.createDockInsetGroup(childButtons)]
}
dockButton = this._uiGen.createDockSlideOut(dockButton, childButtons)
if (this._isDockSlideOutPinned(field)) {
dockButton.addClass('bv-dock-slideout-pinned')
}
}
}
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,
disabled: this._resolveDockDisabled(field, value),
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
}
/**
* @param {ConfigurationField} field
* @param {*} [value]
* @return {boolean}
* @private
*/
_resolveDockDisabled(field, value = null)
{
if (field.dock.isDisabled === undefined) {
return false
}
value = value ?? this._getEffectiveFieldValue(field)
if (typeof field.dock.isDisabled === 'function') {
if (this._dockIncludeContext) {
return !!field.dock.isDisabled.call(this._dockIncludeContext, value, field)
}
return !!field.dock.isDisabled(value, field)
}
return !!field.dock.isDisabled
}
/**
* @param {ConfigurationField} field
* @return {boolean}
* @private
*/
_isDockFieldDisabled(field)
{
this._overlayFieldFromCache(field)
return this._resolveDockDisabled(field, this._getEffectiveFieldValue(field))
}
/**
* 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 (this._isDockFieldDisabled(field)) {
return
}
if (field.dock.onClick) {
void this._finishDockButtonClick(Promise.resolve(Utilities.callEventHandler(field.dock.onClick, [field])))
return
}
if (field.type === CONFIG_TYPE_FLAG) {
this._blurDockSlideOutFocus()
void this._toggleDockFlagFromIdb(field)
return
}
if (field.type === CONFIG_TYPE_ACTION && field.onClick) {
void this._finishDockButtonClick(Promise.resolve(Utilities.callEventHandler(field.onClick, [])))
}
}
/**
* @param {Promise<*>} promise
* @return {Promise<void>}
* @private
*/
async _finishDockButtonClick(promise)
{
try {
await promise
} finally {
this._blurDockSlideOutFocus()
this.refreshDockButtonStates()
}
}
/**
* @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.refreshDockButtonStates()
}
/**
* @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) {
continue
}
if (child.dock?.parent !== parentKey) {
child.dock = {...(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 {boolean} value
* @return {string}
* @private
*/
_dockFlagState(value)
{
return value ? 'bv-dock-btn-active' : ''
}
/**
* @param {string} label
* @param {boolean} value
* @param {string} whenOn
* @param {string} whenOff
* @return {string}
* @private
*/
_dockFlagTooltip(label, value, whenOn, whenOff)
{
return value ? `${label}: on — ${whenOn}` : `${label}: off — ${whenOff}`
}
/**
* @param {{icon: string, label: string, whenOn: string, whenOff: string, include?: Function}} spec
* @return {object}
* @private
*/
_dockFlagToggleRecipe(spec)
{
let recipe = {
icon: spec.icon,
getState: (value) => this._dockFlagState(value),
tooltip: (value) => this._dockFlagTooltip(spec.label, value, spec.whenOn, spec.whenOff),
}
if (spec.include !== undefined) {
recipe.include = spec.include
}
return recipe
}
/**
* Named dock-button recipes with baked copy. Consumers call by name only;
* pass sparse `args` solely when a real override case is added to the API.
*
* @param {ConfigurationField} field
* @param {string} name
* @param {object} [args]
* @private
*/
_applyDockTemplate(field, name, args = {})
{
let searchPageInclude = function() {
return typeof this.isPage === 'function' ? this.isPage('search') : true
}
let downloadManagerInclude = function() {
return typeof this.isDownloadManagerEnabled === 'function' ? this.isDownloadManagerEnabled() : true
}
let recipe = null
switch (name) {
case 'flagToggle':
// Generic escape hatch — prefer named templates with baked copy.
recipe = this._dockFlagToggleRecipe({
icon: args.icon ?? 'block',
label: args.label ?? field.title ?? field.key,
whenOn: args.whenOn ?? 'click to turn off',
whenOff: args.whenOff ?? 'click to turn on',
include: args.include,
})
break
case 'tagBlacklist':
recipe = this._dockFlagToggleRecipe({
icon: 'block',
label: 'Tag blacklist',
whenOn: 'click to stop hiding matching posts',
whenOff: 'click to hide posts with listed tags',
})
break
case 'exploredTags':
recipe = this._dockFlagToggleRecipe({
icon: 'explore',
label: 'Explored tags',
whenOn: 'click to stop tracking while paging',
whenOff: 'click to track while paging',
include: searchPageInclude,
})
break
case 'autoDownload':
recipe = this._dockFlagToggleRecipe({
icon: 'download',
label: 'Auto-download',
whenOn: 'click to stop downloading when opening posts',
whenOff: 'click to download media when opening posts',
include: searchPageInclude,
})
break
case 'removeMediaOnDownload':
recipe = this._dockFlagToggleRecipe({
icon: 'eye-off',
label: 'Remove media on download',
whenOn: 'click to keep the picture/player after save',
whenOff: 'click to remove the picture/player after a successful save',
include: searchPageInclude,
})
break
case 'autoNextPage':
recipe = this._dockFlagToggleRecipe({
icon: 'next',
label: 'Auto next page',
whenOn: 'click to stop advancing when page is empty',
whenOff: 'click to go to next page when all results filtered',
include: searchPageInclude,
})
break
case 'defaultTags':
recipe = this._dockFlagToggleRecipe({
icon: 'inject',
label: 'Default tags',
whenOn: 'click to stop injecting into searches',
whenOff: 'click to inject into every search',
})
break
case 'resolutionFilter':
recipe = this._dockFlagToggleRecipe({
icon: 'resolution',
label: 'Resolution filter',
whenOn: 'click to stop restricting width / height',
whenOff: 'click to limit posts to size range',
})
break
case 'hideOlderPosts':
recipe = this._dockFlagToggleRecipe({
icon: 'history',
label: 'Hide older posts',
whenOn: 'click to show posts below Last ID',
whenOff: 'click to hide posts below Last ID',
})
break
case 'invertedFiltersMaster':
recipe = {
icon: 'filter',
isActive: (value) => !value,
slideOutWhen: (value) => !value,
tooltip: (value) => value ?
'Filters bypassed — click to re-enable' :
'Search filters active — hover for sub-filters',
include: searchPageInclude,
}
break
case 'skipDuplicates':
recipe = {
icon: 'skip-duplicate',
getState: (value) => this._dockFlagState(value),
tooltip: (value) => value ?
'Skip duplicates: on — click to allow re-downloads; hover for Hide Downloaded Media' :
'Skip duplicates: off — click to skip ledger hits; hover for Hide Downloaded Media',
slideOutWhen: () => true,
include: downloadManagerInclude,
}
break
case 'hideDownloaded':
recipe = this._dockFlagToggleRecipe({
icon: 'eye-off',
label: 'Hide downloaded',
whenOn: 'click to show posts already in the download ledger',
whenOff: 'click to hide posts already in the download ledger',
include: downloadManagerInclude,
})
break
default:
console.warn('Unknown dock template:', name)
return
}
// Sparse dock-behavior overrides only (e.g. onClick). Recipe copy stays in the template.
let {
icon: _icon,
label: _label,
whenOn: _whenOn,
whenOff: _whenOff,
...dockOverrides
} = args
field.dock = {...(field.dock ?? {}), ...recipe, ...dockOverrides}
}
/**
* @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 {ConfigurationField} field
* @return {boolean}
* @private
*/
_isDockSlideOutPinned(field)
{
if (field.dock.slideOutPinnedWhen) {
return !!Utilities.callEventHandler(field.dock.slideOutPinnedWhen, [field.value, field])
}
return false
}
/**
* @param {boolean} active
* @return {BrazenConfigurationManager}
*/
setDockActive(active)
{
this._dockActive = active
return this
}
/**
* @return {boolean}
*/
isDockActive()
{
return this._dockActive
}
/**
* @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
}
if (field.dock.getSlideOutNodes) {
let nodes = Utilities.callEventHandler(field.dock.getSlideOutNodes, [field], null)
if (Array.isArray(nodes) && nodes.some((node) => node?.length)) {
return true
}
}
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 {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)
}
/**
* @param {string} name
* @return {boolean}
*/
evaluateDockInclude(name)
{
let field = this.getField(name)
if (!field?.dock) {
return false
}
return this._evaluateDockInclude(field)
}
/**
* Detaches all mounted dock button nodes from the DOM and clears element references.
* @return {BrazenConfigurationManager}
*/
clearDockElements()
{
for (let key in this._config) {
let field = this._config[key]
if (field.dockElement?.length) {
field.dockElement.remove()
field.dockElement = null
field._dockShowsSlideOut = false
}
}
return this
}
/**
* @return {BrazenConfigurationManager}
*/
refreshDockButtonStates()
{
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 icon = typeof field.dock.icon === 'function' ? field.dock.icon(value, field) : field.dock.icon
let disabled = this._resolveDockDisabled(field, value)
this._uiGen.updateDockFieldButton(field, {
stateClass: disabled ? '' : this._resolveDockStateClass(field, value),
tooltip: this._resolveDockTooltip(field, value),
icon: typeof icon === 'string' ? icon : undefined,
disabled: disabled,
})
if (field.dockElement.hasClass('bv-dock-slot')) {
field.dockElement.toggleClass('bv-dock-slideout-pinned', this._isDockSlideOutPinned(field))
}
}
/**
* @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()
await this._repos.tagRuntime.warmCache()
}
/**
* @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()
await this._repos.tagRuntime.warmCache()
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()
await this._repos.tagRuntime.warmCache()
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)) {
if (TAG_FIELD_SPEC[fieldKey]) {
let spec = TAG_FIELD_SPEC[fieldKey]
let rawLines = await this._repos.tags._projectTaggingFieldLines(fieldKey, spec.group, spec)
let compiled = await this._repos.tags.getCompiledField(fieldKey)
this._cacheSetting(fieldKey, rawLines, compiled?.optimized ?? null)
this._overlayFieldFromCache(field)
return
}
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) {
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)
}
}
if (TAG_FIELD_SPEC[fieldKey]) {
await this._repos.tagRuntime.persistTaggingFieldLines(fieldKey, expanded)
if (TAG_FIELD_SPEC[fieldKey].usesCombos) {
await this.scheduleTagRuleCompile([group])
} else {
await this._repos.tags.compileGroup(group, () => null, false)
}
return
}
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
}
/**
* Reload in-memory tag ruleset fields from compiled IDB stores (tagRuleSets).
* Use after tag-registry–only writes so filter rules stay active without a full save.
* @return {Promise<void>}
*/
async reloadTagRuleFieldsFromStorage()
{
if (!this.canPersist()) {
return
}
await this._ensureStorageReady()
for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
if (this._config[fieldKey]) {
await this._syncFieldFromIdb(fieldKey)
}
}
}
/**
* Wipe the download duplicate ledger (all `ledgerEntries` rows) and reload the in-memory Set.
* @return {Promise<void>}
*/
async clearDownloadLedger()
{
if (!this.canPersist()) {
return
}
await this._ensureStorageReady()
await this._repos.ledger.replaceAll([])
let field = this.getField('download-ledger')
if (field?.reload) {
await field.reload()
}
}
/**
* Wipe this script's entire IndexedDB database (settings, bookmarks, ledger, tags,
* download queues, etc.). Caller should reload the page afterward so setup can run again.
* @return {Promise<void>}
*/
async clearScriptDatabase()
{
if (!this._repos?.storage) {
return
}
this._storageReady = false
this._idbBlocked = false
await this._repos.storage.deleteDatabase()
}
/**
* Wipe tag registry, raw rules, and compiled rule sets (testing helper).
* Does not read or re-import panel fields.
* @return {Promise<void>}
*/
async clearTagDomain()
{
if (!this.canPersist()) {
return
}
await this._ensureStorageReady()
await this._repos.tags.clearAll()
await this._repos.tagRules.clearAll()
await this._repos.tags.resetCompiledRuleSets()
this._tagComplianceSpecs = {}
await this._repos.tagRuntime.warmCache()
for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
if (this._config[fieldKey]) {
await this._syncFieldFromIdb(fieldKey)
}
}
await this._commitTagFieldChange()
}
/**
* Wipe tag registry, raw rules, and compiled rule sets; rebuild from current panel fields.
* Temporary testing helper — keeps UI ruleset text as source of truth.
* @return {Promise<void>}
*/
async rebuildTagDomainFromPanel()
{
if (!this.canPersist()) {
return
}
await this._ensureStorageReady()
this.update()
await this._repos.tags.clearAll()
await this._repos.tagRules.clearAll()
await this._repos.tags.resetCompiledRuleSets()
for (let fieldKey of TAG_DOMAIN_FIELD_KEYS) {
let field = this._config[fieldKey]
if (!field) {
continue
}
await this._persistTagFieldFromUI(fieldKey, field)
}
await this._repos.tagRuntime.warmCache()
await this._commitTagFieldChange()
}
/**
* @type {boolean}
* @private
*/
_tagCompileQueued = false
/**
* Cached compliance specs keyed by field (`soleAttribute` + `combos`).
* @type {Record<string, {soleAttribute: {root: string, key: string}|null, combos: {tagEntryIds: number[]}[]}>}
* @private
*/
_tagComplianceSpecs = {}
/**
* @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.notifyConfigurationChange('settings', true)
return this
}
/**
* @return {BrazenStorageRepositories}
*/
getRepos()
{
return this._repos
}
/**
* @return {TagRuntime|null}
*/
getTagRuntime()
{
return this._repos?.tagRuntime ?? null
}
/**
* @param {string} fieldKey
* @return {boolean}
*/
isTagRegistryField(fieldKey)
{
return !!TAG_FIELD_SPEC[fieldKey]?.usesTaggingRegistry
}
/**
* @param {string} tagName Normalized tag name.
* @return {*|null}
*/
getCachedTagEntry(tagName)
{
return this.getTagRuntime()?.getCachedByName(tagName) ?? null
}
/**
* @param {string} fieldKey
* @param {string} tagName Normalized tag name.
* @return {boolean}
*/
hasTagSoleAttribute(fieldKey, tagName)
{
let spec = TAG_FIELD_SPEC[fieldKey]
if (!spec?.soleAttribute) {
return false
}
if (this.canPersist()) {
let entry = this.getCachedTagEntry(tagName)
return entry ? readTagSoleAttribute(entry, spec.soleAttribute) : false
}
return this.getField(fieldKey)?.hasRule(tagName) ?? false
}
/**
* @param {string} tagName Normalized tag name.
* @param {string} [fieldKey='filename-tag-substitutions']
* @return {boolean}
*/
hasTagSubstitutionSubject(tagName, fieldKey = 'filename-tag-substitutions')
{
let spec = TAG_FIELD_SPEC[fieldKey]
if (spec?.substitution && this.canPersist()) {
return !!this.getCachedTagEntry(tagName)?.download?.substitution
}
return this.getField(fieldKey)?.hasRule(tagName) ?? false
}
/**
* Persist a filename-tag substitution (`subject` → `replacement`). Names should already be normalized by the caller.
* @param {string} subject
* @param {string} replacement
* @param {string} [fieldKey='filename-tag-substitutions']
* @return {Promise<boolean>}
*/
async setTagSubstitution(subject, replacement, fieldKey = 'filename-tag-substitutions')
{
let field = this.getField(fieldKey)
let spec = TAG_FIELD_SPEC[fieldKey]
if (!field || !spec?.substitution) {
return false
}
let subjectName = String(subject ?? '').trim()
let replacementName = String(replacement ?? '').trim()
if (!subjectName || !replacementName) {
return false
}
if (this.canPersist()) {
await this._repos.tagRuntime.patchAttributes(subjectName, {
download: {substitution: {replacementName}},
}, {replacementName})
let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
if (group) {
await this._repos.tags.compileGroup(group, () => null, false)
}
await this._repos.tagRuntime.warmCache()
await this._syncFieldFromIdb(fieldKey)
await this._commitTagFieldChange()
return true
}
if (field.hasRule?.(subjectName)) {
field.removeRule(subjectName)
}
field.addRule({subject: subjectName, replacement: replacementName}, subjectName)
await this.save()
return true
}
/**
* Remove a filename-tag substitution subject. Name should already be normalized by the caller.
* @param {string} subject
* @param {string} [fieldKey='filename-tag-substitutions']
* @return {Promise<boolean>}
*/
async clearTagSubstitution(subject, fieldKey = 'filename-tag-substitutions')
{
let field = this.getField(fieldKey)
let spec = TAG_FIELD_SPEC[fieldKey]
if (!field || !spec?.substitution) {
return false
}
let subjectName = String(subject ?? '').trim()
if (!subjectName) {
return false
}
if (this.canPersist()) {
await this._repos.tagRuntime.patchAttributes(subjectName, {
download: {substitution: null},
})
let group = TAG_RULE_GROUP_BY_FIELD[fieldKey]
if (group) {
await this._repos.tags.compileGroup(group, () => null, false)
}
await this._repos.tagRuntime.warmCache()
await this._syncFieldFromIdb(fieldKey)
await this._commitTagFieldChange()
return true
}
field.removeRule(subjectName)
await this.save()
return true
}
/**
* @param {string} fieldKey
* @return {boolean}
*/
hasTagComplianceRules(fieldKey)
{
if (this.isTagRegistryField(fieldKey) && this.canPersist()) {
let field = this.getField(fieldKey)
if (field?.value?.length) {
return true
}
return !!(field?.optimized?.combos?.length)
}
let field = this.getField(fieldKey)
let optimized = field?.optimized
return !!(field?.value?.length || (Array.isArray(optimized) && optimized.length))
}
/**
* @param {string[]} [fieldKeys] Compliance registry fields; defaults to all in `TAG_FIELD_SPEC` with compliance attributes.
* @return {Promise<void>}
*/
async refreshTagComplianceSpecs(fieldKeys = null)
{
let tagRuntime = this.getTagRuntime()
if (!tagRuntime) {
return
}
let keys = fieldKeys ?? Object.keys(TAG_FIELD_SPEC).filter((fieldKey) =>
TAG_FIELD_SPEC[fieldKey].soleAttribute?.root === 'compliance')
for (let fieldKey of keys) {
this._tagComplianceSpecs[fieldKey] = await tagRuntime.getComplianceSpecForField(fieldKey)
}
}
/**
* @param {string} fieldKey
* @return {{soleAttribute: {root: string, key: string}|null, combos: {tagEntryIds: number[]}[]}|null}
*/
getTagComplianceSpec(fieldKey)
{
return this._tagComplianceSpecs[fieldKey] ?? null
}
/**
* @param {string[]} itemTagNames
* @param {string} fieldKey
* @return {{complies: boolean, rule?: string}}
*/
evaluateTagCompliance(itemTagNames, fieldKey)
{
let spec = this.getTagComplianceSpec(fieldKey)
let tagRuntime = this.getTagRuntime()
if (!spec || !tagRuntime?._warmed) {
return {complies: true}
}
return tagRuntime.evaluateComplianceSync(itemTagNames, spec)
}
/**
* @param {{typeName?: string|null, typeEntryId?: number|null, replacementName?: string|null, source?: string|null}} [options]
* @return {{typeName?: string, typeEntryId?: number, replacementName?: string, source?: string}}
*/
createTagContext(options = {})
{
let context = {}
if (options.typeName) {
context.typeName = options.typeName
}
if (options.typeEntryId != null) {
context.typeEntryId = options.typeEntryId
}
if (options.replacementName) {
context.replacementName = options.replacementName
}
if (options.source) {
context.source = options.source
}
return context
}
/**
* Register-on-seen: ensure each tag in typed groups exists in the registry.
* @param {Record<string, string[]>} groups Map of type name → tag names.
* @param {string} [source='media']
* @return {Promise<void>}
*/
async registerTypedTagGroups(groups, source = 'media')
{
let tagRuntime = this.getTagRuntime()
if (!tagRuntime || !this.canPersist()) {
return
}
for (let [typeName, names] of Object.entries(groups)) {
if (!Array.isArray(names)) {
continue
}
for (let name of names) {
if (name) {
await tagRuntime.ensureTag(name, {typeName, source})
}
}
}
}
/**
* Compile tag rules, warm registry cache, and refresh compliance specs.
* @return {Promise<void>}
*/
async prepareTagComplianceRuntime()
{
if (!this.canPersist()) {
return
}
await this.ensureTagRuleSetsCompiled()
await this.getTagRuntime()?.warmCache()
await this.refreshTagComplianceSpecs()
}
/**
* When IndexedDB tagging is active, route download path policy through `TagRuntime`.
* @param {object} resolver Base resolver (chips, tagTypes, ignore Set, substitutions Map, etc.).
* @return {object}
*/
applyTagRegistryDownloadResolver(resolver)
{
let tagRuntime = this.getTagRuntime()
if (this.canPersist() && tagRuntime) {
return {
...resolver,
tagRuntime,
ignore: null,
substitutions: null,
}
}
return resolver
}
/**
* @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()
// UI readers (`hasTagSubstitutionSubject`, `hasTagSoleAttribute`) use the warm cache.
await this._repos.tagRuntime?.warmCache()
this.notifyConfigurationChange('tags', true)
}
/**
* @param {string} fieldKey
* @param {string} tagName
* @return {Promise<boolean>}
*/
/**
* @param {string} fieldKey
* @param {string} tagName
* @param {{typeEntryId?: number|null, typeName?: string|null, replacementName?: string|null}|null} [context]
* @return {Promise<boolean>}
*/
async toggleTagRule(fieldKey, tagName, context = null)
{
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
}
let spec = TAG_FIELD_SPEC[fieldKey]
if (spec?.soleAttribute) {
let normalized = String(tagName)
let entry = await this._repos.tagRuntime.getTag(normalized)
let current = entry ? readTagSoleAttribute(entry, spec.soleAttribute) : false
let patch = spec.soleAttribute.root === 'compliance' ?
{compliance: {[spec.soleAttribute.key]: !current}} :
{download: {[spec.soleAttribute.key]: !current}}
await this._repos.tagRuntime.patchAttributes(normalized, patch, context)
if (spec.usesCombos) {
await this.scheduleTagRuleCompile([group])
await this._compilePendingTagRuleGroups()
} else {
await this._repos.tags.compileGroup(group, () => null, false)
}
await this._repos.tagRuntime.warmCache()
if (spec.soleAttribute?.root === 'compliance') {
await this.refreshTagComplianceSpecs([fieldKey])
}
await this._syncFieldFromIdb(fieldKey)
await this._commitTagFieldChange()
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
}
if (TAG_FIELD_SPEC[fieldKey]) {
await this._repos.tagRuntime.persistTaggingFieldLines(fieldKey, [])
if (TAG_FIELD_SPEC[fieldKey].usesCombos) {
await this.scheduleTagRuleCompile([group])
} else {
await this._repos.tags.compileGroup(group, () => null, false)
}
await this._compilePendingTagRuleGroups()
await this._repos.tagRuntime.warmCache()
if (TAG_FIELD_SPEC[fieldKey].soleAttribute?.root === 'compliance') {
await this.refreshTagComplianceSpecs([fieldKey])
}
await this._syncFieldFromIdb(fieldKey)
await this._commitTagFieldChange()
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.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 : []
if (TAG_FIELD_SPEC[fieldKey]) {
await this._repos.tagRuntime.persistTaggingFieldLines(fieldKey, lines)
if (TAG_FIELD_SPEC[fieldKey].usesCombos) {
await this.scheduleTagRuleCompile([group])
} else {
await this._repos.tags.compileGroup(group, () => null, false)
}
await this._compilePendingTagRuleGroups()
await this._repos.tagRuntime.warmCache()
await this._syncFieldFromIdb(fieldKey)
return
}
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.refreshDockButtonStates()
}
return this
}
}