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/1901979/Brazen%20Framework%20-%20Configuration%20Manager.js
// ==UserScript==
// @name Brazen Framework - Configuration Manager
// @namespace brazenvoid
// @version 7.1.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Configuration management for the Brazen user scripts framework
// ==/UserScript==
const CONFIG_BACKUP_VERSION = 5
/** Legacy-compatible zip shape for pre-migration safety exports (downgrade + restore path). */
const PRE_MIGRATION_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_DEFAULTS = {
flag: false,
color: false,
text: '',
number: null,
range: null,
checkboxes: [],
select: null,
radios: null,
}
const RULESET_USER_CONFIG_KEYS = ['autoSort', 'sortMode', 'groupingEnabled', 'hideTagTypes']
const DEFAULT_RULESET_USER_CONFIG = {
autoSort: true,
sortMode: 'natural-asc',
groupingEnabled: false,
hideTagTypes: false,
}
/**
* @param {string} subject
* @return {string}
*/
function pluralizeRulesetSubject(subject)
{
let word = String(subject ?? 'rule').trim().toLowerCase() || 'rule'
if (word.endsWith('s')) {
return word
}
if (word.endsWith('y') && !/[aeiou]y$/i.test(word)) {
return word.slice(0, -1) + 'ies'
}
if (/([sxz]|ch|sh)$/.test(word)) {
return word + 'es'
}
return word + 's'
}
/**
* @param {string} subject
* @return {string}
*/
function titleCaseRulesetSubject(subject)
{
let word = String(subject ?? '').trim()
if (!word) {
return ''
}
return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()
}
/** @type {Function[]} */
const RULESET_MUTATION_LISTENERS = []
/**
* Simple pub/sub for ruleset row mutations (sidebar toggles, cross-tab sync, etc.).
*/
class RulesetMutationBus
{
/**
* @param {Function} listener
*/
static subscribe(listener)
{
if (typeof listener === 'function') {
RULESET_MUTATION_LISTENERS.push(listener)
}
}
/**
* @param {{fieldKeys?: string[], tagNames?: string[], entryIds?: number[]}} detail
*/
static notify(detail = {})
{
for (let listener of RULESET_MUTATION_LISTENERS) {
try {
listener(detail)
} catch (error) {
console.log('[RulesetMutationBus]', error)
}
}
}
}
class BrazenConfigurationManager
{
/**
* @typedef {{key: string, title: string, type: string, element: null|HTMLElement, value: *, maximum: int, minimum: int, options: string[], help: *,
* 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 {HTMLElement[]}
* @private
*/
_dockElements = []
/**
* @type {Error|null}
* @private
*/
_migrationError = null
/**
* @type {function(*): (void|Promise<void>)|null}
* @private
*/
_reportMigrationProgress = null
/**
* @return {Error|null}
*/
getMigrationError()
{
return this._migrationError
}
/**
* @param {function(MigrationProgress|string): void|Promise<void>|null|undefined} callback
* @return {function(MigrationProgress|string): Promise<void>}
* @private
*/
_bindMigrationProgress(callback)
{
if (typeof callback !== 'function') {
return async () => {}
}
return async (progress) => {
let normalized = typeof progress === 'string'
? {phase: 'generic', label: progress, indeterminate: true}
: progress
await callback(normalized)
}
}
/**
* @param {object|null} meta
* @return {Promise<boolean>}
* @private
*/
async _isMigrationWorkNeeded(meta = null)
{
meta = meta ?? await this._repos.meta.get()
if (meta?.setupInProgress) {
return true
}
let localWork = await this._getLocalMigrationWork(meta)
return localWork.hasLocalWork
}
/**
* @param {object|null} meta
* @return {Promise<{schemaRebuild: boolean, backfill: boolean, rulesetMigration: boolean, hasLocalWork: boolean}>}
* @private
*/
async _getLocalMigrationWork(meta = null)
{
meta = meta ?? await this._repos.meta.get()
let schemaRebuild = !await this._repos.storage.isHealthy()
let backfill = !!meta?.pendingIsDiscoveredBackfill
let rulesetConfigDefaultsV9 = !!meta?.pendingRulesetConfigDefaultsV9
let rulesetHideTagTypesV10 = !!meta?.pendingRulesetHideTagTypesResetV10
let rulesetMigration = !meta?.rulesetMigrated
let bookmarksMigration = !meta?.bookmarksMigrated
return {
schemaRebuild,
backfill,
rulesetConfigDefaultsV9,
rulesetHideTagTypesV10,
rulesetMigration,
bookmarksMigration,
hasLocalWork: schemaRebuild || backfill || rulesetConfigDefaultsV9 ||
rulesetHideTagTypesV10 || rulesetMigration || bookmarksMigration,
}
}
/**
* @return {Promise<{installed: number, supported: number}|null>}
*/
async getSchemaVersionConflict()
{
if (!this._repos.storage.available) {
return null
}
return this._repos.storage.getSchemaVersionConflict()
}
/**
* @param {Error|*} error
* @return {Promise<{installed: number, supported: number}|null>}
*/
async resolveSchemaVersionConflictFromError(error)
{
if (error?.code === 'SCHEMA_TOO_NEW' && error.schemaConflict) {
return error.schemaConflict
}
if (this._repos.storage.isVersionError?.(error)) {
return this.getSchemaVersionConflict()
}
return null
}
/**
* @return {Promise<{consentRequired: boolean, peerWaitOnly: boolean, schemaTooNew: boolean, installedSchemaVersion: number|null, supportedSchemaVersion: number|null, steps: string[], backupFilename: string|null}>}
*/
async getPendingMigrationPlan()
{
let emptyPlan = {
consentRequired: false,
peerWaitOnly: false,
schemaTooNew: false,
idbUnavailable: false,
installedSchemaVersion: null,
supportedSchemaVersion: null,
steps: [],
backupFilename: null,
}
if (!this._repos.storage.available) {
return {...emptyPlan, idbUnavailable: true}
}
let wipeFlagKey = this._scriptPrefix + 'pending-idb-wipe'
try {
if (sessionStorage.getItem(wipeFlagKey) === '1') {
return emptyPlan
}
} catch (e) {
}
let schemaConflict = await this.getSchemaVersionConflict()
if (schemaConflict) {
return {
...emptyPlan,
schemaTooNew: true,
installedSchemaVersion: schemaConflict.installed,
supportedSchemaVersion: schemaConflict.supported,
}
}
try {
await this._repos.storage.open()
} catch (error) {
let conflict = await this.resolveSchemaVersionConflictFromError(error)
if (conflict) {
return {
...emptyPlan,
schemaTooNew: true,
installedSchemaVersion: conflict.installed,
supportedSchemaVersion: conflict.supported,
}
}
throw error
}
let meta = await this._repos.meta.get()
if (!(await this._isMigrationWorkNeeded(meta))) {
return emptyPlan
}
let localWork = await this._getLocalMigrationWork(meta)
if (meta?.setupInProgress && !localWork.hasLocalWork) {
return {...emptyPlan, peerWaitOnly: true}
}
let backupFilename = this._scriptPrefix + 'pre-migration-backup.zip'
let steps = [
'Download a safety backup (' + backupFilename + ') to your Downloads folder.',
]
let migrationParts = []
if (localWork.schemaRebuild) {
migrationParts.push('rebuild the database schema')
}
if (localWork.backfill) {
migrationParts.push('backfill tag metadata')
}
if (localWork.rulesetConfigDefaultsV9) {
migrationParts.push('apply ruleset configuration defaults')
}
if (localWork.rulesetMigration) {
migrationParts.push('migrate tag rules')
}
steps.push(
migrationParts.length
? 'Update the local database (' + migrationParts.join(', ') + ').'
: 'Update the local database (settings, tags, bookmarks, and download history).',
)
steps.push('Finish loading the script.')
return {
...emptyPlan,
consentRequired: true,
steps,
backupFilename,
}
}
/**
* @param {function(MigrationProgress|string): Promise<void>} report
* @return {Promise<void>}
* @private
*/
async _waitPeerSetupWithProgress(report)
{
let started = Date.now()
while (true) {
let meta = await this._repos.meta.get()
if (meta?.setupComplete && !meta?.setupInProgress) {
return
}
if (!meta?.setupInProgress) {
return
}
let elapsedSec = Math.floor((Date.now() - started) / 1000)
let detail = elapsedSec >= 120
? 'Still waiting… you can Retry or Reset database.'
: elapsedSec + 's elapsed'
await report({
phase: 'peer-setup',
label: 'Waiting for another tab to finish setup…',
detail,
indeterminate: true,
})
if (Date.now() - started > 120000) {
throw new Error('Timed out waiting for setup to complete')
}
await Utilities.sleep(500)
}
}
/**
* @param {string} storeName
* @return {Promise<*|null>}
* @private
*/
async _readLegacyStoreForBackup(storeName)
{
await this._repos.storage.open()
let db = this._repos.storage._db
if (!db?.objectStoreNames.contains(storeName)) {
return null
}
if (storeName === 'tagRuleSets') {
return this._repos.storage.get(storeName, 'tagRuleSets')
}
return this._repos.storage.getAll(storeName)
}
/**
* @param {BrazenZipWriter} zip
* @param {string} filename
* @return {void}
* @private
*/
_triggerBackupZipDownload(zip, filename)
{
let link = document.createElement('a')
link.download = filename
let objectUrl = URL.createObjectURL(zip.build())
link.href = objectUrl
link.click()
setTimeout(() => URL.revokeObjectURL(objectUrl), 60_000)
}
/**
* Shared zip assembly for user backup and pre-migration safety export.
* @param {{ purpose: 'preMigrationSafety'|'userBackup', stores?: string[], onProgress?: function(MigrationProgress|string): void|Promise<void>|null }} options
* @return {Promise<{zip: BrazenZipWriter, filename: string, meta: object|null}>}
* @private
*/
async _buildBackupZip({purpose, stores = null, onProgress = null})
{
await this._repos.storage.open()
let meta = await this._repos.meta.get()
let zip = new BrazenZipWriter()
if (purpose === 'preMigrationSafety') {
let manifestStores = [...(stores ?? [
'meta', 'settings', 'apis', 'tagTypes', 'tags', 'bookmarks', 'ledgerEntries',
])]
let tagRules = await this._readLegacyStoreForBackup('tagRules')
if (tagRules != null) {
manifestStores.push('tagRules')
}
let tagRuleSets = await this._readLegacyStoreForBackup('tagRuleSets')
if (tagRuleSets != null) {
manifestStores.push('tagRuleSets')
}
let tagPartCount = await addStoreJsonPartsToZip(
zip, this._repos.storage, IDB_STORE_TAGS, BACKUP_JSON_PART_SIZE, onProgress,
{phase: 'safety-backup', label: 'Creating safety backup…'},
)
let bookmarkPartCount = await addStoreJsonPartsToZip(
zip, this._repos.storage, IDB_STORE_BOOKMARKS, BACKUP_JSON_PART_SIZE, onProgress,
{phase: 'safety-backup', label: 'Creating safety backup…'},
)
let ledgerPartCount = await addStoreJsonPartsToZip(
zip, this._repos.storage, IDB_STORE_LEDGER, BACKUP_JSON_PART_SIZE, onProgress,
{phase: 'safety-backup', label: 'Creating safety backup…'},
)
zip.addFile('manifest.json', JSON.stringify({
version: PRE_MIGRATION_BACKUP_VERSION,
purpose: 'preMigrationSafety',
scriptPrefix: this._scriptPrefix,
dbName: this._repos.storage.dbName,
revisionId: meta?.revisionId,
exportedAt: Date.now(),
stores: manifestStores,
chunkedStores: {
tags: tagPartCount,
bookmarks: bookmarkPartCount,
ledgerEntries: ledgerPartCount,
},
}, 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))
if (tagRules != null) {
zip.addFile('tagRules.json', JSON.stringify(tagRules, null, 2))
}
if (tagRuleSets != null) {
zip.addFile('tagRuleSets.json', JSON.stringify(tagRuleSets, null, 2))
}
return {
zip,
filename: this._scriptPrefix + 'pre-migration-backup.zip',
meta,
safetyBackupCounts: {
tags: tagPartCount,
bookmarks: bookmarkPartCount,
ledgerEntries: ledgerPartCount,
},
}
}
let manifestStores = stores ?? IDB_ALL_STORES
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: manifestStores,
}, 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('rulesetFields.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_RULESET_FIELDS), null, 2))
zip.addFile('rulesetEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_RULESET_ENTRIES), null, 2))
zip.addFile('bookmarks.json', JSON.stringify(await this._exportBookmarksBackupRows(), null, 2))
zip.addFile('ledgerEntries.json', JSON.stringify(await this._repos.storage.getAllChunked(IDB_STORE_LEDGER), null, 2))
return {
zip,
filename: this._scriptPrefix + 'backup.zip',
meta,
}
}
/**
* Auto-download a legacy-compatible safety backup before destructive migration.
* @param {function(MigrationProgress|string): void|Promise<void>|null} [onProgress]
* @return {Promise<string>} Download filename
*/
async createPreMigrationSafetyBackup(onProgress = null)
{
let {zip, filename, meta, safetyBackupCounts} = await this._buildBackupZip({
purpose: 'preMigrationSafety',
onProgress,
})
this._triggerBackupZipDownload(zip, filename)
meta = meta ?? await this._repos.storage.createDefaultMeta()
meta.migrationSafetyBackupAt = Date.now()
meta.migrationSafetyBackupRevisionId = meta.revisionId ?? null
meta.migrationSafetyBackupCounts = safetyBackupCounts
await this._repos.meta.put(meta)
try {
sessionStorage.setItem(this._scriptPrefix + 'pre-migration-backup-file', filename)
} catch (error) {
}
return filename
}
/**
* @return {string|null}
*/
getPreMigrationBackupFilenameHint()
{
try {
return sessionStorage.getItem(this._scriptPrefix + 'pre-migration-backup-file')
} catch (error) {
return null
}
}
/**
* @type {((event: PageTransitionEvent) => void)|null}
* @private
*/
_idbPageshowHandler = null
/**
* @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._ledgerImportRevisionPending = false
this._syncedRevisionId = null
/** @type {string|null} revisionId after last ensureRulesetFieldsCompiled boot pass */
this._bootRulesetCompiledRevisionId = null
this._workingSet = null
/** @type {Map<string, *>} */
this._fieldSeeds = new Map()
RulesetMutationBus.subscribe((detail) => {
void this._handleRulesetMutation(detail)
})
}
// -------------------------------------------------------------------------
// Private class methods
// -------------------------------------------------------------------------
/**
* @param {ConfigurationField} field
* @return {*|undefined}
* @private
*/
_deriveStructuralDefault(field)
{
if (field.type === CONFIG_TYPE_SELECT || field.type === CONFIG_TYPE_RADIOS_GROUP) {
return field.options?.[0]?.[1] ?? null
}
if (field.type === CONFIG_TYPE_NUMBER) {
return field.minimum ?? null
}
if (field.type === CONFIG_TYPE_RANGE) {
return field.minimum == null ? null : {minimum: field.minimum, maximum: field.minimum}
}
return undefined
}
/**
* @param {string} fieldKey
* @return {*|undefined}
* @private
*/
_resolveSeedDefault(fieldKey)
{
let field = this._config[fieldKey]
let seed = this._fieldSeeds.get(fieldKey)
if (seed !== undefined) {
return (seed && typeof seed === 'object' && 'default' in seed) ? seed.default : seed
}
let structural = field ? this._deriveStructuralDefault(field) : undefined
if (structural !== undefined) {
return structural
}
return field ? CONFIG_TYPE_DEFAULTS[field.type] : undefined
}
/**
* @param {string} key
* @return {void}
* @private
*/
_reresolveField(key)
{
// Seeds feed _seedAllFields / migration only — never push defaults into field.value at registration.
}
/**
* @param {string} key
* @param {*} spec
* @return {BrazenConfigurationManager}
*/
registerFieldSeed(key, spec)
{
this._fieldSeeds.set(key, spec)
let field = this._config[key]
if (field?.type === CONFIG_TYPE_RULESET) {
if (!field.templateId && spec?.templateId) {
field.templateId = spec.templateId
}
this._syncRulesetSubjectFromTemplate(field)
field.widget?.updatePanelCopy?.(this._resolveRulesetPanelCopy(field))
}
return this
}
/**
* @param {object} map
* @return {BrazenConfigurationManager}
*/
registerFieldSeeds(map)
{
for (let [key, spec] of Object.entries(map ?? {})) {
this.registerFieldSeed(key, spec)
}
return this
}
/**
* @return {Map<string, *>}
*/
getFieldSeeds()
{
return this._fieldSeeds
}
/**
* @param {ConfigurationField} field
* @return {object}
* @private
*/
_sanitizeRulesetUserConfig(config = {})
{
let out = {}
for (let key of RULESET_USER_CONFIG_KEYS) {
if (config[key] !== undefined) {
out[key] = config[key]
}
}
return out
}
/**
* Merge stored ruleset user config into the field object without replacing the reference
* (ruleset panel widgets hold the same `field.config` object).
* @param {ConfigurationField} field
* @param {object|null|undefined} storedConfig
* @return {void}
* @private
*/
_applyRulesetUserConfig(field, storedConfig)
{
if (!field.config || typeof field.config !== 'object') {
field.config = {}
}
let sanitized = this._sanitizeRulesetUserConfig(storedConfig ?? {})
for (let key of Object.keys(field.config)) {
if (!(key in sanitized)) {
delete field.config[key]
}
}
Object.assign(field.config, sanitized)
}
/**
* @return {Promise<void>}
* @private
*/
async _seedAllFields()
{
for (let field of Object.values(this._config)) {
if (field.persist === false && field.type !== CONFIG_TYPE_RULESET) {
continue
}
if (field.type === CONFIG_TYPE_RULESET) {
let spec = this._fieldSeeds.get(field.key)
if (!spec || !spec.templateId) {
continue
}
let existing = await this._repos.rulesetFields.get(field.key)
let serializedTemplateConfig = this._serializeRulesetTemplateConfig(spec.templateConfig ?? {})
if (!existing) {
await this._repos.rulesetFields.upsert({
fieldKey: field.key,
templateId: spec.templateId,
templateConfig: serializedTemplateConfig,
config: {...DEFAULT_RULESET_USER_CONFIG, ...(spec.config ?? {})},
})
} else {
let templateIdChanged = existing.templateId !== spec.templateId
let templateConfigChanged = JSON.stringify(existing.templateConfig ?? {}) !==
JSON.stringify(serializedTemplateConfig)
if (templateIdChanged || templateConfigChanged) {
await this._repos.rulesetFields.upsert({
...existing,
templateId: spec.templateId,
templateConfig: serializedTemplateConfig,
})
}
}
continue
}
let current = await this._repos.settings.getField(field.key)
if (current === undefined) {
await this._repos.settings.putField(field.key, this._resolveSeedDefault(field.key))
}
}
}
/**
* @param {string} type
* @param {string} name
* @param {*} value
* @param {string|null} help
* @return ConfigurationField
* @private
*/
_createField(type, name, value, help)
{
let fieldKey = this._formatFieldKey(name)
let field = this._config[fieldKey]
if (field) {
field.key = field.key ?? fieldKey
if (help) {
field.help = help
}
} else {
field = {
key: fieldKey,
element: null,
help: help,
title: name,
type: type,
value: undefined,
createElement: null,
setFromUserInterface: null,
updateUserInterface: null,
}
this._config[fieldKey] = field
}
this._lastFieldKey = fieldKey
this._wireDefaultFieldHooks(field)
this._wireFieldBuilder(field)
return field
}
/**
* @param {ConfigurationField} field
* @private
*/
_wireFieldBuilder(field)
{
field.setTitle = (title) => {
field.title = title
return field
}
field.setHelp = (help) => {
field.help = help
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
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.setReadOnly = (readOnly = true) => {
field.readOnly = !!readOnly
return field
}
field.setSubstitutionComposer = (options = {}) => {
field.substitutionComposer = typeof options === 'function'
? {normalize: options}
: {...options}
field.readOnly = true
return field
}
field.setFormatter = (formatter) => {
field.formatter = formatter
return field
}
field.setPrimaryField = (primaryField) => {
field.ledgerPrimaryField = primaryField
return field
}
field.setIsValidId = (isValidId) => {
field.ledgerIsValidId = isValidId
return field
}
field.setShowAddButton = (showAddButton) => {
field.showAddButton = showAddButton
return field
}
field.setSortHelpText = (sortHelpText) => {
field.sortHelpText = sortHelpText
return field
}
field.setRulesetSubject = (rulesetSubject) => {
field.rulesetSubject = rulesetSubject
field._rulesetSubjectExplicit = true
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.setOnBeforeRender = (onBeforeRender) => {
field.onBeforeRender = onBeforeRender
return field
}
field.setAttributeActionsConfig = (config) => {
field.attributeActionsConfig = config ?? null
field.rebuildRulesetTagIndex?.()
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
}
field.setDetailFormTemplate = (factory) => {
field.detailFormTemplate = factory
return field
}
field.openDetailForm = (mode, ctx = {}) => {
if (typeof field.detailFormTemplate === 'function' && mode !== 'help') {
let content = field.detailFormTemplate(mode, {
...ctx,
field,
fieldKey: field.key,
help: field.help,
repos: this._repos,
})
let title = ctx.title
if (title == null && field.type === CONFIG_TYPE_RULESET) {
title = this._resolveRulesetDetailTitle(field, mode)
} else if (title == null) {
title = mode === 'create' ? 'Add rule' : 'Edit rule'
}
BrazenViewLayer.openSettingsDetailPane({
title,
content,
onClose: ctx.onClose ?? null,
})
return field
}
if (field.help != null && field.help !== '') {
let content = typeof field.help === 'string' ?
Utilities.makeEl('div', {class: 'bv-field-help-detail', html: field.help}) :
field.help
BrazenViewLayer.openSettingsDetailPane({
title: ctx.title ?? field.title ?? '',
content,
onClose: ctx.onClose ?? null,
})
}
return field
}
field.setTemplate = (templateId) => {
field.templateId = templateId
this._syncRulesetSubjectFromTemplate(field)
field.widget?.updatePanelCopy?.(this._resolveRulesetPanelCopy(field))
return field
}
field.setTemplateConfig = (config) => {
field.templateConfig = {...(field.templateConfig ?? {}), ...(config ?? {})}
return field
}
field.setConfigOptionHelp = (partial) => {
field.configOptionHelp = {...(field.configOptionHelp ?? {}), ...(partial ?? {})}
return field
}
field.setGroupingAvailable = (available) => {
field.capabilities = {...(field.capabilities ?? {}), grouping: !!available}
return field
}
field.setSortModes = (modes) => {
field.sortModes = modes
return field
}
field.setCrudOptions = (options) => {
field.crudOptions = {...(field.crudOptions ?? {}), ...(options ?? {})}
return field
}
field.setAlwaysRefresh = (alwaysRefresh = true) => {
field.alwaysRefresh = !!alwaysRefresh
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.getOptimized) {
void field.getOptimized()
return
}
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|string[]|null|undefined} tags
* @param {string|string[]|null|undefined} fieldKeys
* @return {{tags: string[], fieldKeys: string[]}|null}
*/
createTagsChangeDetail(tags = null, fieldKeys = null)
{
let tagList = []
let seenTags = new Set()
for (let tag of Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])) {
let name = String(tag ?? '').trim()
if (!name || seenTags.has(name)) {
continue
}
seenTags.add(name)
tagList.push(name)
}
let keyList = []
let seenKeys = new Set()
for (let key of Array.isArray(fieldKeys) ? fieldKeys : (fieldKeys != null && fieldKeys !== '' ? [fieldKeys] : [])) {
let fieldKey = String(key ?? '').trim()
if (!fieldKey || seenKeys.has(fieldKey)) {
continue
}
seenKeys.add(fieldKey)
keyList.push(fieldKey)
}
if (!tagList.length && !keyList.length) {
return null
}
return {tags: tagList, fieldKeys: keyList}
}
/**
* @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} left
* @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} right
* @return {{tags: string[], fieldKeys: string[]}|null}
*/
mergeChangeDetails(left, right)
{
if (!left) {
return right ? this.createTagsChangeDetail(right.tags, right.fieldKeys) : null
}
if (!right) {
return this.createTagsChangeDetail(left.tags, left.fieldKeys)
}
return this.createTagsChangeDetail(
[...(left.tags ?? []), ...(right.tags ?? [])],
[...(left.fieldKeys ?? []), ...(right.fieldKeys ?? [])],
)
}
/**
* @param {string} source
* @param {boolean} local
* @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
* @return {BrazenConfigurationManager}
*/
notifyConfigurationChange(source = 'all', local = true, detail = null)
{
Utilities.callEventHandler(this._onConfigurationChange, [{
manager: this,
source,
local,
detail: detail ?? null,
}])
return this
}
/**
* @param {string} source
* @param {boolean} local
* @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
* @return {void}
* @private
*/
_notifyConfigurationChange(source = 'all', local = true, detail = null)
{
this.notifyConfigurationChange(source, local, detail)
}
/**
* @param {ConfigurationField} field
* @return {ConfigurationField}
* @private
*/
_overlayFieldFromCache(field)
{
if (!field || !this._storageReady) {
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._storageReady) {
let cached = this._getCachedSetting(field.key)
if (cached) {
return cached.value
}
}
return field.value
}
/**
* @return {Promise<void>}
* @private
*/
async _refreshSettingCacheFromIdb()
{
if (!this._storageReady) {
return
}
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_RULESET) {
let compiled = await this._compileRulesetFieldFromIdb(fieldKey)
this._cacheSetting(fieldKey, compiled?.rawLines ?? [], compiled?.optimized ?? null)
continue
}
if (field.persist === false) {
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) {
if (field.type === CONFIG_TYPE_RULESET) {
continue
}
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 {ConfigurationField} field
* @return {{help: *}}
* @private
*/
_fieldHelpLabelOptions(field)
{
return {
help: field.help ?? '',
}
}
/**
* @param {ConfigurationField} field
* @private
*/
_warnIfMissingHelp(field)
{
if (field.help != null && field.help !== '') {
return
}
console.warn('[BrazenCM] Missing help for field:', field.key)
}
/**
* @param {string[]} rules
* @return {string[]}
* @private
*/
_deduplicateRulesetRules(rules)
{
if (!Array.isArray(rules)) {
return rules
}
if (!rules.length) {
return 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 {ConfigurationField} field
* @return {string}
* @private
*/
/**
* @param {ConfigurationField} field
* @return {string}
* @private
*/
_resolveRulesetTemplateId(field)
{
let seed = this._fieldSeeds.get(field.key)
if (seed?.templateId) {
return seed.templateId
}
let spec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
if (spec?.templateId) {
return spec.templateId
}
if (field.templateId) {
return field.templateId
}
return field._rulesetMain?.templateId ?? ''
}
/**
* @param {ConfigurationField} field
* @return {void}
* @private
*/
_syncRulesetSubjectFromTemplate(field)
{
if (field._rulesetSubjectExplicit) {
return
}
let templateId = this._resolveRulesetTemplateId(field)
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(templateId) : null
if (template?.subject) {
field.rulesetSubject = template.subject
return
}
let spec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
if (spec?.rulesetSubject) {
field.rulesetSubject = spec.rulesetSubject
return
}
delete field.rulesetSubject
}
/**
* @param {ConfigurationField} field
* @return {string}
* @private
*/
_resolveRulesetSubject(field)
{
if (field._rulesetSubjectExplicit && field.rulesetSubject) {
return String(field.rulesetSubject).trim().toLowerCase() || 'rule'
}
this._syncRulesetSubjectFromTemplate(field)
if (field.rulesetSubject) {
return String(field.rulesetSubject).trim().toLowerCase() || 'rule'
}
return 'rule'
}
/**
* @param {ConfigurationField} field
* @return {{subject: string, plural: string, emptyMessage: string, noMatchMessage: string, searchPlaceholder: string, sortHelpText: string, addEntryCaption: string, submitCaption: string}}
* @private
*/
_resolveRulesetPanelCopy(field)
{
let subject = this._resolveRulesetSubject(field)
let plural = pluralizeRulesetSubject(subject)
let subjectTitle = titleCaseRulesetSubject(subject)
let pluralTitle = titleCaseRulesetSubject(plural)
return {
subject,
plural,
emptyMessage: `No ${plural} yet.`,
noMatchMessage: `No matching ${plural}.`,
searchPlaceholder: `Search ${plural}…`,
sortHelpText: field.sortHelpText ?? `Sort ${plural}`,
addEntryCaption: `Add ${subjectTitle}`,
createDetailTitle: `${field.title ? `${field.title} - ` : ''}Add ${pluralTitle}`,
submitCaption: `Add ${pluralTitle}`,
}
}
/**
* @param {ConfigurationField} field
* @param {'create'|'edit'} mode
* @return {string}
* @private
*/
_resolveRulesetDetailTitle(field, mode)
{
let copy = this._resolveRulesetPanelCopy(field)
let prefix = field.title ? `${field.title} - ` : ''
if (mode === 'edit') {
return `${prefix}Edit ${titleCaseRulesetSubject(copy.subject)}`
}
return `${prefix}Add ${titleCaseRulesetSubject(copy.plural)}`
}
/**
* @param {ConfigurationField} field
* @return {{fieldKey: string, templateId: string, templateConfig: object, config: object, repos: *}}
* @private
*/
_buildRulesetTemplateCtx(field)
{
let storedConfig = field._rulesetMain?.config ?? field.config ?? {}
return {
fieldKey: field.key,
templateId: field.templateId ?? field._rulesetMain?.templateId ?? '',
templateConfig: field.templateConfig ?? field._rulesetMain?.templateConfig ?? {},
capabilities: {...(field.capabilities ?? {})},
config: this._sanitizeRulesetUserConfig(storedConfig),
repos: this._repos,
rulesetSubject: this._resolveRulesetSubject(field),
}
}
/**
* IDB-safe subset of template config (runtime callbacks such as `normalizeLine` stay in RAM only).
* @param {object|null|undefined} config
* @return {object}
* @private
*/
_serializeRulesetTemplateConfig(config)
{
let out = {}
for (let [key, value] of Object.entries(config ?? {})) {
if (typeof value === 'function') {
continue
}
out[key] = value
}
return out
}
/**
* Merge stored IDB template config with in-memory runtime hooks from app registration.
* @param {object|null|undefined} stored
* @param {object|null|undefined} runtime
* @return {object}
* @private
*/
_mergeRulesetTemplateConfig(stored, runtime)
{
return {...(stored ?? {}), ...(runtime ?? {})}
}
/**
* @param {string} templateId
* @param {string} tagName
* @return {*}
* @private
*/
_rulesetTemplateRequiresTagEntryId(templateId)
{
return templateId === 'tag-blacklist' || templateId === 'explored-tags' ||
templateId === 'tag-sole-ignore'
}
/**
* @param {string} templateId
* @param {string} tagName
* @return {*}
* @private
*/
_rulesetMatchTargetForTag(templateId, tagName)
{
if (templateId === 'tag-sole-ignore') {
return {tagName}
}
if (templateId === 'substitution') {
return {subjectName: tagName}
}
if (this._rulesetTemplateRequiresTagEntryId(templateId)) {
console.warn('[BrazenCM] _rulesetMatchTargetForTag: use _rulesetPayloadForTag with a resolved tag entry for', templateId, tagName)
}
return {variant: 'sole', tagName: String(tagName ?? '').trim()}
}
/**
* @param {string} templateId
* @param {*} tag
* @return {*}
* @private
*/
_rulesetPayloadForTag(templateId, tag)
{
if (tag?.entryId == null) {
console.warn('[BrazenCM] _rulesetPayloadForTag: tag entry missing entryId', templateId, tag)
}
if (templateId === 'tag-sole-ignore') {
return {tagName: tag.name, tagEntryId: tag.entryId}
}
return {variant: 'sole', tagName: tag.name, tagEntryId: tag.entryId}
}
/**
* Repair combo-template rows that were persisted before tag entry ids were hydrated.
* @param {ConfigurationField} field
* @param {*[]} rows
* @return {Promise<void>}
* @private
*/
async _repairRulesetRowTagEntryIds(field, rows)
{
let templateId = this._resolveRulesetTemplateId(field)
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(templateId) : null
if (!template) {
return
}
if (templateId === 'tag-sole-ignore') {
for (let row of rows) {
let payload = row?.payload
if (!payload?.tagName) {
continue
}
let tag = await this._repos.tagRuntime.ensureTag(payload.tagName)
if (tag.entryId == null) {
continue
}
let needsRepair = payload.tagEntryId == null || payload.tagName !== tag.name
if (!needsRepair) {
continue
}
let repaired = {...payload, tagEntryId: tag.entryId, tagName: tag.name}
let formatted = template.formatForUI(repaired)
let updated = {
...row,
payload: repaired,
rawLine: formatted.rawLine ?? row.rawLine,
}
await this._repos.rulesetEntries.update(updated)
row.payload = repaired
row.rawLine = updated.rawLine
}
return
}
if (!this._rulesetTemplateRequiresTagEntryId(templateId)) {
return
}
for (let row of rows) {
let payload = row?.payload
if (!payload) {
continue
}
let needsSoleRepair = payload.variant === 'sole' && payload.tagName && payload.tagEntryId == null
let needsComboRepair = payload.variant === 'combo' && payload.tagNames?.length && !payload.tagEntryIds?.length
if (!needsSoleRepair && !needsComboRepair) {
continue
}
console.warn('[BrazenCM] repairing ruleset row missing tag entry ids', field.key, row.entryId, payload)
let repaired = {...payload}
if (needsSoleRepair) {
let tag = await this._repos.tagRuntime.ensureTag(repaired.tagName)
repaired.tagEntryId = tag.entryId
repaired.tagName = tag.name
}
if (needsComboRepair) {
repaired.tagEntryIds = []
repaired.tagNames = []
for (let tagName of payload.tagNames) {
let tag = await this._repos.tagRuntime.ensureTag(tagName)
repaired.tagEntryIds.push(tag.entryId)
repaired.tagNames.push(tag.name)
}
if (!repaired.rawLine) {
repaired.rawLine = repaired.tagNames.join(' & ')
}
}
let formatted = template.formatForUI(repaired)
let updated = {
...row,
payload: repaired,
rawLine: formatted.rawLine ?? row.rawLine,
}
await this._repos.rulesetEntries.update(updated)
row.payload = repaired
row.rawLine = updated.rawLine
}
}
/**
* @param {ConfigurationField} field
* @param {*} matchTarget
* @return {*|null}
* @private
*/
_findRulesetRowForTarget(field, matchTarget)
{
let templateId = field.templateId ?? field._rulesetMain?.templateId
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(templateId) : null
if (!template) {
return null
}
for (let row of field._rulesetRows ?? []) {
if (template.matchRule(row.payload, matchTarget)) {
return row
}
}
return null
}
/**
* @param {string} fieldKey
* @param {string} tagName
* @param {*} [context]
* @return {Promise<boolean>}
* @private
*/
async _toggleRulesetTagRule(fieldKey, tagName, context = null)
{
let field = this.getField(fieldKey)
let main = await this._repos.rulesetFields.get(fieldKey)
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(main?.templateId) : null
if (!field || !main?.templateId || !template) {
return false
}
let normalized = String(tagName ?? '').trim()
if (!normalized) {
return false
}
let ctx = this._buildRulesetTemplateCtx(field)
this._repos.tagRuntime?.primeOptimisticEntry?.(normalized)
let currentlyActive = this.hasTagSoleAttribute(fieldKey, normalized)
let tagDetail = this.createTagsChangeDetail(normalized, fieldKey)
let previousRows = (field._rulesetRows ?? []).slice()
let previousOptimized = field.optimized
let optimisticEntryId = null
let syncRemovedEntryId = null
if (currentlyActive) {
let syncExisting = this._findSoleAttributeRowForToggle(field, main.templateId, normalized)
if (syncExisting?.entryId != null) {
syncRemovedEntryId = syncExisting.entryId
field.removeRow(syncExisting.entryId)
}
this._patchOptimisticIgnoreCompiled(field, main.templateId, normalized, false)
} else {
optimisticEntryId = typeof crypto !== 'undefined' && crypto.randomUUID ?
crypto.randomUUID() : `optimistic-${Date.now()}`
let formatted = template.formatForUI?.({tagName: normalized}) ?? {rawLine: normalized}
field.patchRow({
entryId: optimisticEntryId,
payload: {tagName: normalized, tagEntryId: null, _optimistic: true},
rawLine: formatted.rawLine ?? normalized,
sortOrder: Date.now(),
_optimistic: true,
})
this._patchOptimisticIgnoreCompiled(field, main.templateId, normalized, true)
}
this.notifyConfigurationChange('tags', true, tagDetail)
try {
let tag = await this._repos.tagRuntime.ensureTag(normalized, context)
if (tag.entryId == null) {
console.warn('[BrazenCM] _toggleRulesetTagRule: ensureTag returned no entryId', fieldKey, normalized)
throw new Error('_toggleRulesetTagRule: ensureTag returned no entryId')
}
let matchTarget = this._rulesetPayloadForTag(main.templateId, tag)
let existing = this._findRulesetRowForTarget(field, matchTarget)
if (!existing || existing._optimistic) {
let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
existing = rows.find((row) => template.matchRule(row.payload, matchTarget)) ?? null
}
let mutationEntryIds = []
let sorted = false
if (currentlyActive) {
if (existing?.entryId != null && !existing._optimistic) {
await this._repos.rulesetEntries.remove(existing.entryId)
field.removeRow(existing.entryId)
mutationEntryIds = [existing.entryId]
}
} else {
if (optimisticEntryId != null) {
field._rulesetRows = (field._rulesetRows ?? []).filter((entry) => entry.entryId !== optimisticEntryId)
}
if (!existing || existing._optimistic) {
let row = await template.persist(matchTarget, ctx)
field.patchRow(row)
if (row?.entryId != null) {
mutationEntryIds = [row.entryId]
}
sorted = await this._applyRulesetAutoSortIfEnabled(field)
}
}
if (!sorted && typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, fieldKey)
}
if (!sorted) {
await field.getOptimized()
}
if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
await this.refreshTagComplianceSpecs([fieldKey])
}
this._refreshRulesetFieldUiIfClean(field)
RulesetMutationBus.notify({
fieldKeys: [fieldKey],
entryIds: mutationEntryIds.length ? mutationEntryIds : (syncRemovedEntryId != null ? [syncRemovedEntryId] : []),
tagNames: [normalized],
})
await this._commitTagFieldChange(tagDetail)
return true
} catch (error) {
field._rulesetRows = previousRows
field.optimized = previousOptimized
this.notifyConfigurationChange('tags', true, tagDetail)
console.log('[BrazenCM] _toggleRulesetTagRule failed:', error)
throw error
}
}
/**
* @param {ConfigurationField} field
* @param {'create'|'edit'} mode
* @param {*} [entry]
* @param {HTMLFormElement} form
* @return {Promise<void>}
* @private
*/
async _submitRulesetDetailForm(field, mode, entry, form)
{
let templateId = this._resolveRulesetTemplateId(field)
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(templateId) : null
if (!template) {
return
}
let ctx = this._buildRulesetTemplateCtx(field)
let values = Object.fromEntries(new FormData(form).entries())
let payload = template.translateFromUI(values, ctx)
if (!payload) {
return
}
let groupLabel = String(values.groupLabel ?? '').trim() || null
let row = await template.persist(payload, ctx, {
entryId: mode === 'edit' ? entry?.entryId : null,
comment: String(values.comment ?? '').trim(),
groupLabel,
})
field.patchRow(row)
if (!await this._applyRulesetAutoSortIfEnabled(field)) {
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, field.key)
}
await field.getOptimized()
}
if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
await this.refreshTagComplianceSpecs([field.key])
}
RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [row.entryId]})
if (this.isTagRegistryField(field.key)) {
let tagNames = this._collectRulesetEntryTagNames(field, row)
if (mode === 'edit' && entry) {
tagNames = [...this._collectRulesetEntryTagNames(field, entry), ...tagNames]
}
await this._commitTagFieldChange(this.createTagsChangeDetail(tagNames, field.key))
} else {
this.notifyConfigurationChange(field.key, true)
}
BrazenViewLayer.closeSettingsDetailPane()
field.updateUserInterface()
}
/**
* @param {ConfigurationField} field
* @param {HTMLFormElement[]} forms
* @return {Promise<void>}
* @private
*/
async _submitRulesetDetailForms(field, forms)
{
let templateId = this._resolveRulesetTemplateId(field)
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(templateId) : null
if (!template || !Array.isArray(forms) || !forms.length) {
return
}
let ctx = this._buildRulesetTemplateCtx(field)
let pending = []
for (let form of forms) {
let values = Object.fromEntries(new FormData(form).entries())
let payload = template.translateFromUI(values, ctx)
if (!payload) {
continue
}
pending.push({
payload,
groupLabel: String(values.groupLabel ?? '').trim() || null,
comment: String(values.comment ?? '').trim(),
})
}
if (!pending.length) {
return
}
let useAutoSort = !!field.config?.autoSort
let maxSortOrder = Math.max(0, ...(field._rulesetRows ?? []).map((row) => row.sortOrder ?? 0))
let sortOrder = maxSortOrder + 1
let rows = []
for (let item of pending) {
let persistMeta = {
comment: item.comment,
groupLabel: item.groupLabel,
}
if (!useAutoSort) {
persistMeta.sortOrder = sortOrder++
}
let row = await template.persist(item.payload, ctx, persistMeta)
field.patchRow(row)
rows.push(row)
}
if (useAutoSort) {
await this._applyRulesetAutoSortIfEnabled(field)
} else if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, field.key)
}
if (!useAutoSort) {
await field.getOptimized()
}
if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
await this.refreshTagComplianceSpecs([field.key])
}
RulesetMutationBus.notify({
fieldKeys: [field.key],
entryIds: rows.map((row) => row.entryId).filter((entryId) => entryId != null),
})
if (this.isTagRegistryField(field.key)) {
let tagNames = rows.flatMap((row) => this._collectRulesetEntryTagNames(field, row))
await this._commitTagFieldChange(this.createTagsChangeDetail(tagNames, field.key))
} else {
this.notifyConfigurationChange(field.key, true)
}
BrazenViewLayer.closeSettingsDetailPane()
field.updateUserInterface()
}
/**
* @param {string} fieldKey
* @return {Promise<{rawLines: string[], optimized: *}>}
* @private
*/
async _compileRulesetFieldFromIdb(fieldKey)
{
try {
let main = await this._repos.rulesetFields.get(fieldKey)
if (main?.templateId) {
let compiled = await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos)
if (compiled) {
return compiled
}
}
} catch (error) {
console.log('[BrazenCM] compileRulesetField failed:', error)
}
let stored = await this._repos.settings.getField(fieldKey)
let field = this._config[fieldKey]
let value = stored?.value ?? field?.value ?? []
let rawLines = Array.isArray(value) ? value.map(String) : []
let optimized = stored?.optimized ?? this._computeFieldOptimized(fieldKey, rawLines)
return {rawLines, optimized}
}
/**
* Collect tag names from visible ruleset rows for TagRuntime warm-before-paint.
* @param {ConfigurationField} field
* @param {string} templateId
* @param {*[]} entries
* @return {string[]}
* @private
*/
_collectRulesetTagNamesForRender(field, templateId, entries = [])
{
let names = new Set()
let ctx = {
...this._buildRulesetTemplateCtx(field),
tokenize: field.templateConfig?.tokenize ?? field.attributeActionsConfig?.tokenize ?? null,
normalizeTag: field.templateConfig?.normalizeTag ?? field.attributeActionsConfig?.normalize ?? null,
}
for (let entry of entries ?? []) {
if (typeof collectRulesetWarmTagNames !== 'function') {
continue
}
for (let name of collectRulesetWarmTagNames(entry, templateId, ctx)) {
if (name) {
names.add(name)
}
}
}
return [...names]
}
/**
* Tag names implicated by a ruleset row (sidebar / discovery chrome refresh).
* @param {ConfigurationField} field
* @param {*} entry
* @return {string[]}
* @private
*/
_collectRulesetEntryTagNames(field, entry)
{
if (!field || !entry) {
return []
}
return this._collectRulesetTagNamesForRender(field, this._resolveRulesetTemplateId(field), [entry])
}
/**
* @param {string} fieldKey
* @param {{query?: string, groupQuery?: string|null, cursor?: number|null}} pageOptions
* @return {Promise<{entries: *[], nextCursor: *}>}
* @private
*/
async _loadRulesetPage(fieldKey, pageOptions = {})
{
let query = String(pageOptions.query ?? '').trim()
let groupQuery = String(pageOptions.groupQuery ?? '').trim()
let cursor = pageOptions.cursor ?? null
if (query || groupQuery) {
return this._repos.rulesetEntries.search(fieldKey, query, {
groupLabel: groupQuery || null,
cursor,
limit: 50,
})
}
if (cursor != null) {
return this._repos.rulesetEntries.search(fieldKey, '', {cursor, limit: 50})
}
return this._repos.rulesetEntries.listInitial(fieldKey, 50)
}
/**
* @param {ConfigurationField} field
* @param {number} entryId
* @return {Promise<void>}
* @private
*/
async _removeRulesetEntry(field, entryId)
{
let removedEntry = (field._rulesetRows ?? []).find((entry) => entry.entryId === entryId) ?? null
await this._repos.rulesetEntries.remove(entryId)
field.removeRow(entryId)
if (field.key === 'tag-blacklist' || field.key === 'explored-tags-tracker') {
await this.refreshTagComplianceSpecs([field.key])
}
await field.getOptimized()
field.updateUserInterface?.()
RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [entryId]})
if (this.isTagRegistryField(field.key)) {
await this._commitTagFieldChange(this.createTagsChangeDetail(
this._collectRulesetEntryTagNames(field, removedEntry), field.key))
} else {
this.notifyConfigurationChange(field.key, true)
}
}
/**
* @param {ConfigurationField} field
* @param {string} groupLabel
* @return {Promise<void>}
* @private
*/
async _removeRulesetGroup(field, groupLabel)
{
await this._repos.rulesetEntries.removeGroup(field.key, groupLabel)
await field.reload()
field.updateUserInterface()
RulesetMutationBus.notify({fieldKeys: [field.key]})
this.notifyConfigurationChange(field.key, true)
}
/**
* @param {string} fieldKey
* @param {string} ruleLabel
* @param {function(string): string} normalizeRuleLine
* @return {boolean}
*/
matchesTagRuleLabel(fieldKey, ruleLabel, normalizeRuleLine)
{
let field = this.getField(fieldKey)
if (!field || typeof normalizeRuleLine !== 'function') {
return false
}
let lines = Array.isArray(field.value) ? field.value : []
return lines.some((line) => this._tagRuleLabelMatchesLine(ruleLabel, line, normalizeRuleLine))
}
/**
* @param {string} ruleLabel
* @param {string} line
* @param {function(string): string} normalizeRuleLine
* @return {boolean}
* @private
*/
_tagRuleLabelMatchesLine(ruleLabel, line, normalizeRuleLine)
{
let normalizedLabel = normalizeRuleLine(ruleLabel)
let rulePart = line
let commentIdx = line.indexOf(' // ')
if (commentIdx >= 0) {
rulePart = line.slice(0, commentIdx)
}
let normalizedRulePart = normalizeRuleLine(rulePart)
if (normalizedRulePart === normalizedLabel) {
return true
}
if (normalizedRulePart.includes(' | ')) {
return normalizedRulePart.split(/\s*\|\s*/).includes(normalizedLabel)
}
return false
}
/**
* Remove a ruleset row matching an Active Hide Rules label.
* @param {string} fieldKey
* @param {string} ruleLabel
* @param {function(string): string} normalizeRuleLine
* @return {Promise<boolean>}
*/
async removeTagRuleByLabel(fieldKey, ruleLabel, normalizeRuleLine)
{
let field = this.getField(fieldKey)
if (!field || typeof normalizeRuleLine !== 'function') {
return false
}
await field.getOptimized?.()
let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
let match = rows.find((row) => this._tagRuleLabelMatchesLine(ruleLabel, row.rawLine ?? '', normalizeRuleLine))
if (!match?.entryId) {
return false
}
await this._removeRulesetEntry(field, match.entryId)
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, fieldKey)
}
if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
await this.refreshTagComplianceSpecs([fieldKey])
}
await this._commitTagFieldChange(this.createTagsChangeDetail(null, fieldKey))
return true
}
/**
* Compile all registered ruleset fields (replaces legacy tagRuleSets compile).
* @param {string[]} [fieldKeys]
* @return {Promise<void>}
*/
async ensureRulesetFieldsCompiled(fieldKeys = null, onProgress = null)
{
let keys = fieldKeys ?? RULESET_MIGRATION_FIELD_SPECS.map((spec) => spec.fieldKey)
let compileKeys = keys.filter((fieldKey) => !!this._config[fieldKey])
for (let index = 0; index < compileKeys.length; index++) {
let fieldKey = compileKeys[index]
if (typeof onProgress === 'function') {
await onProgress({
phase: 'ruleset-compile',
label: 'Compiling rules…',
detail: fieldKey,
current: index,
total: compileKeys.length,
})
}
await this._repos.rulesetFields.getCompiledField(fieldKey, this._repos)
let field = this._config[fieldKey]
if (field?.getOptimized) {
await field.getOptimized()
}
}
let meta = await this._repos.meta.get()
this._bootRulesetCompiledRevisionId = meta?.revisionId ?? null
}
/**
* @param {ConfigurationField} field
* @param {object} partial
* @return {Promise<void>}
* @private
*/
async _updateRulesetFieldConfig(field, partial)
{
let sanitizedPartial = {}
for (let key of RULESET_USER_CONFIG_KEYS) {
if (partial?.[key] !== undefined) {
sanitizedPartial[key] = partial[key]
}
}
let previousConfig = {...(field.config ?? {})}
this._applyRulesetUserConfig(field, {...(field.config ?? {}), ...sanitizedPartial})
let main = field._rulesetMain ?? await this._repos.rulesetFields.get(field.key)
await this._repos.rulesetFields.upsert({
fieldKey: field.key,
templateId: field.templateId ?? main?.templateId ?? '',
templateConfig: this._serializeRulesetTemplateConfig(field.templateConfig ?? main?.templateConfig ?? {}),
config: field.config,
})
let shouldResort = field.config.autoSort &&
(sanitizedPartial.autoSort === true ||
(sanitizedPartial.sortMode !== undefined && previousConfig.sortMode !== field.config.sortMode))
if (shouldResort) {
await this._applyRulesetAutoSortIfEnabled(field)
}
field.updateUserInterface()
this.notifyConfigurationChange(field.key, true)
}
/**
* @param {ConfigurationField} field
* @return {Promise<boolean>}
* @private
*/
async _applyRulesetAutoSortIfEnabled(field)
{
if (!field?.config?.autoSort) {
return false
}
if (typeof applyRulesetFieldSort !== 'function') {
return false
}
await applyRulesetFieldSort(this._repos, field.key, field.config.sortMode ?? 'natural-asc')
await field.reload({skipAutoSortRepair: true})
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, field.key)
}
await field.getOptimized()
return true
}
/**
* @param {{fieldKeys?: string[], tagNames?: string[], entryIds?: number[]}} detail
* @return {Promise<void>}
* @private
*/
async _handleRulesetMutation(detail = {})
{
let keys = detail.fieldKeys ?? []
if (!keys.length && detail.tagNames?.length) {
keys = Object.keys(this._config).filter((fieldKey) => this._config[fieldKey].type === CONFIG_TYPE_RULESET)
}
for (let fieldKey of keys) {
let field = this._config[fieldKey]
if (!field || field.type !== CONFIG_TYPE_RULESET) {
continue
}
if (detail.entryIds?.length && field.patchRow && !field.alwaysRefresh && !field._shouldRefreshWidget?.()) {
for (let entryId of detail.entryIds) {
let row = await this._repos.storage.get(IDB_STORE_RULESET_ENTRIES, entryId)
if (row && row.fieldKey === fieldKey) {
field.patchRow(row)
} else if (row == null) {
field.removeRow(entryId)
}
}
await field.getOptimized()
field.updateUserInterface?.()
continue
}
await field.reload()
field.updateUserInterface()
}
}
/**
* @param {ConfigurationField} field
* @private
*/
_refreshRulesetFieldUiIfClean(field)
{
if (field?.type !== CONFIG_TYPE_RULESET) {
return
}
if (!field.widget) {
return
}
if (field.alwaysRefresh || field._shouldRefreshWidget?.()) {
void field.reload().then(() => field.updateUserInterface())
}
}
// -------------------------------------------------------------------------
// 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, this._fieldHelpLabelOptions(field))
return field.element
}
field.setFromUserInterface = () => {
field.value = []
for (let input of field.element.querySelectorAll('input:checked')) {
field.value.push(input.getAttribute('data-value'))
}
}
field.updateUserInterface = () => {
for (let key of field.value) {
let input = field.element.querySelector('input[data-value="' + key + '"]')
if (input) {
input.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', this._fieldHelpLabelOptions(field))
field.element = inputGroup.querySelector('input')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = field.element.value
}
field.updateUserInterface = () => {
field.element.value = 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', this._fieldHelpLabelOptions(field))
field.element = inputGroup.querySelector('input')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = field.element.checked
}
field.updateUserInterface = () => {
if (field.element) {
field.element.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
/**
* Bounded positive membership cache (recent claims + primed page hits).
* Never holds the full ledger — membership is IndexedDB `postId` index lookups.
* @type {Set<string>}
*/
field.downloadedIds = new Set()
/** @type {string[]} */
field._ledgerCacheOrder = []
field._ledgerPrimaryField = () => field.ledgerPrimaryField ?? 'ids'
field._ledgerIsValidId = () => field.ledgerIsValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)
let rememberHit = (id) => {
if (!id || field.downloadedIds.has(id)) {
return
}
field.downloadedIds.add(id)
field._ledgerCacheOrder.push(id)
let max = (typeof LEDGER_POSITIVE_CACHE_MAX === 'number') ? LEDGER_POSITIVE_CACHE_MAX : 16384
while (field._ledgerCacheOrder.length > max) {
let evict = field._ledgerCacheOrder.shift()
field.downloadedIds.delete(evict)
}
}
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
}
/**
* Invalidates the bounded positive cache. Does **not** load the ledger into RAM.
* @return {Promise<ConfigurationField>}
*/
field.reload = async () => {
field.downloadedIds = new Set()
field._ledgerCacheOrder = []
return field
}
/**
* Batch-prime membership for page tiles / queue ids into the positive cache.
* @param {Iterable<string|number|null|undefined>} ids
* @return {Promise<void>}
*/
field.primeHits = async (ids) => {
let unique = []
let seen = new Set()
for (let raw of ids) {
if (raw === null || raw === undefined) {
continue
}
let id = String(raw).trim()
if (!id || seen.has(id) || field.downloadedIds.has(id)) {
continue
}
seen.add(id)
unique.push(id)
}
if (!unique.length) {
return
}
await cm._ensureStorageReady()
let hits = await cm._repos.ledger.hasMany(unique)
for (let id of hits) {
rememberHit(id)
}
}
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
}
await cm._ensureStorageReady()
for (let id of ids) {
if (!isValidId(id)) {
continue
}
if (!(await cm._repos.ledger.claim(id))) {
return false
}
rememberHit(id)
}
return true
}
field.has = async (id) => {
if (id === null || id === undefined) {
return false
}
id = String(id).trim()
if (field.downloadedIds.has(id)) {
return true
}
await cm._ensureStorageReady()
let hit = await cm._repos.ledger.has(id)
if (hit) {
rememberHit(id)
}
return hit
}
field.merge = async (registry) => {
if (!registry || typeof registry !== 'object') {
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)
for (let id of ids) {
rememberHit(id)
}
}
field.serializeForBackup = async () => {
await cm._ensureStorageReady()
let rows = await cm._repos.storage.getAll(IDB_STORE_LEDGER)
let ids = rows.map((row) => String(row.postId).trim()).filter(Boolean)
return ids.length ? {[field._ledgerPrimaryField()]: ids} : null
}
field.applyFromBackup = (data) => {
void field.merge(data)
}
field.createElement = () => {
field.element = document.createElement('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', this._fieldHelpLabelOptions(field))
let input = inputGroup.querySelector('input')
input.setAttribute('min', String(field.minimum))
input.setAttribute('max', String(field.maximum))
field.element = input
return inputGroup
}
field.setFromUserInterface = () => {
field.value = Number.parseInt(field.element.value.toString())
}
field.updateUserInterface = () => {
field.element.value = 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, this._fieldHelpLabelOptions(field))
field.element = inputGroup
return inputGroup
}
field.setFromUserInterface = () => {
let checked = field.element.querySelector('input:checked')
field.value = checked ? checked.getAttribute('data-value') : null
}
field.updateUserInterface = () => {
let input = field.element.querySelector('input[data-value="' + field.value + '"]')
if (input) {
input.checked = true
input.dispatchEvent(new Event('change', {bubbles: true}))
}
}
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,
this._fieldHelpLabelOptions(field))
field.element = inputGroup
return inputGroup
}
field.setFromUserInterface = () => {
let inputs = field.element.querySelectorAll('.bv-range-inputs input, :scope > .bv-input.bv-text')
field.value = {
minimum: Number.parseInt(inputs[0]?.value, 10) || 0,
maximum: Number.parseInt(inputs[inputs.length - 1]?.value, 10) || 0,
}
}
field.updateUserInterface = () => {
let inputs = field.element.querySelectorAll('.bv-range-inputs input, :scope > .bv-input.bv-text')
if (inputs[0]) {
inputs[0].value = field.value.minimum ?? 0
}
if (inputs.length > 1) {
inputs[inputs.length - 1].value = field.value.maximum ?? 0
}
}
return field
}
/**
* @param {ConfigurationField} field
* @return {*}
* @private
*/
_mountRulesetPanelWidget(field)
{
this._syncRulesetSubjectFromTemplate(field)
let panelCopy = this._resolveRulesetPanelCopy(field)
let templateId = this._resolveRulesetTemplateId(field)
let attrConfig = field.attributeActionsConfig
let host = attrConfig?.host
field.widget = this._uiGen.createRulesetPanel({
title: field.title ?? '',
help: field.help ?? '',
fieldKey: field.key,
addEntryCaption: panelCopy.addEntryCaption,
createDetailTitle: panelCopy.createDetailTitle,
emptyMessage: panelCopy.emptyMessage,
noMatchMessage: panelCopy.noMatchMessage,
searchPlaceholder: panelCopy.searchPlaceholder,
sortHelpText: panelCopy.sortHelpText,
config: field.config,
sortModes: field.sortModes,
crudOptions: {
create: field.showAddButton === false ? false : (field.crudOptions?.create !== false),
edit: templateId === 'bookmarks' ?
false :
(field.crudOptions?.edit !== false),
delete: field.crudOptions?.delete !== false,
},
configControls: {
autoSort: true,
sortMode: true,
groupingEnabled: field.capabilities?.grouping ? true : 'hidden',
hideTagTypes: true,
},
configOptionHelp: field.configOptionHelp ?? {},
pageMatch: field.pageMatch ?? null,
onSort: templateId === 'bookmarks' ?
null :
(field.onSort ? () => Utilities.callEventHandler(field.onSort, []) : null),
loadPage: async (pageOptions = {}) => {
let query = String(pageOptions.query ?? '').trim()
let groupQuery = String(pageOptions.groupQuery ?? '').trim()
if (!query && !groupQuery && pageOptions.cursor == null && field.config?.autoSort &&
typeof applyRulesetFieldSort === 'function') {
await applyRulesetFieldSort(this._repos, field.key, field.config.sortMode ?? 'natural-asc')
}
let page = await this._loadRulesetPage(field.key, pageOptions)
if (!query && !groupQuery && pageOptions.cursor == null) {
field._rulesetRows = page.entries ?? []
field._rulesetNextCursor = page.nextCursor ?? null
}
return page
},
renderEntryContent: (entry, renderOptions = {}) => {
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(templateId) : null
if (template?.renderRowContent) {
let baseCtx = this._buildRulesetTemplateCtx(field)
let ctx = {
...baseCtx,
config: {
...baseCtx.config,
hideTagTypes: !!renderOptions.hideTagTypes,
},
tokenize: field.templateConfig?.tokenize ?? attrConfig?.tokenize ?? null,
normalizeTag: field.templateConfig?.normalizeTag ?? attrConfig?.normalize ?? null,
onNavigate: (url) => {
if (field.onNavigate) {
field.onNavigate(url)
} else {
location.href = url
}
},
}
return template.renderRowContent(entry, ctx)
}
return entry.rawLine ?? ''
},
getRowActions: attrConfig ? (entry) => {
let row = {
entryId: entry.entryId,
label: entry?.payload?.label ?? entry?.rawLine ?? '',
tags: entry?.payload?.tags ?? '',
url: entry?.payload?.url ?? '',
}
let frameworkActions = []
if (host && typeof host.createBookmarkRowAttributeActions === 'function') {
frameworkActions = host.createBookmarkRowAttributeActions(row, attrConfig) ?? []
}
let consumerActions = Utilities.callEventHandler(field.getRowActions, [row], []) ?? []
return [...frameworkActions, ...consumerActions]
} : () => [],
onBeforeRender: (entries) => {
let warmTagNames = this._collectRulesetTagNamesForRender(field, templateId, entries)
let warmPromise = warmTagNames.length && this._repos?.tagRuntime?.ensureNames ?
this._repos.tagRuntime.ensureNames(warmTagNames) :
Promise.resolve()
if (attrConfig) {
let rows = (entries ?? []).map((entry) => ({
entryId: entry.entryId,
label: entry?.payload?.label ?? entry?.rawLine ?? '',
tags: entry?.payload?.tags ?? '',
url: entry?.payload?.url ?? '',
}))
let runConsumer = () => Utilities.callEventHandler(field.onBeforeRender, [rows])
if (!host || typeof host.prepareBookmarkRowAttributeData !== 'function') {
return warmPromise.then(() => runConsumer())
}
return warmPromise.then(() => host.prepareBookmarkRowAttributeData(rows, {
tokenize: attrConfig.tokenize,
normalize: attrConfig.normalize,
attemptedKey: attrConfig.attemptedKey ?? field.key,
blacklist: attrConfig.blacklist,
explore: attrConfig.explore,
}).then(() => runConsumer()))
}
return warmPromise.then(() => Utilities.callEventHandler(field.onBeforeRender, [entries]))
},
onAddGroup: () => {
let groups = [...new Set((field._rulesetRows ?? []).map((row) => row.groupLabel).filter(Boolean))]
let picker = BrazenViewLayer.createGroupCombobox({groups})
BrazenViewLayer.openSettingsDetailPane({
title: 'Add group',
content: Utilities.makeEl('div', {children: [picker]}),
})
},
onAddRule: () => field.openDetailForm('create', {fieldKey: field.key}),
onEdit: (entry) => field.openDetailForm('edit', {fieldKey: field.key, entry}),
onRemove: (entryId) => {
if (field.onRemove) {
Utilities.callEventHandler(field.onRemove, [entryId])
return
}
void this._removeRulesetEntry(field, entryId)
},
onRemoveGroup: (label) => void this._removeRulesetGroup(field, label),
onConfigChange: (partial) => void this._updateRulesetFieldConfig(field, partial),
detailFormTemplate: field.detailFormTemplate,
})
field.element = field.widget.element
return field.widget
}
/**
* @param {string} name
* @return {ConfigurationField}
*/
addRulesetField(name)
{
let field = this._createField(CONFIG_TYPE_RULESET, name, [], null)
let cm = this
field.persist = false
field.widget = null
field._rulesetRows = []
field._rulesetNextCursor = null
field._reloadGeneration = 0
field._rulesetMain = null
field.templateId = ''
field.templateConfig = {}
field.capabilities = {}
field.config = {}
field.configOptionHelp = {}
field.sortModes = [
{value: 'natural-asc', label: 'Natural A→Z'},
{value: 'natural-desc', label: 'Natural Z→A'},
]
field.crudOptions = {create: true, edit: true, delete: true}
field.alwaysRefresh = false
field.detailFormTemplate = null
field.optimized = null
field._rulesetTagIndex = new Map()
let rulesetSpec = typeof RULESET_MIGRATION_FIELD_SPECS !== 'undefined' ?
RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === field.key) : null
if (!field.templateId && rulesetSpec?.templateId) {
field.templateId = rulesetSpec.templateId
}
cm._syncRulesetSubjectFromTemplate(field)
field.rebuildRulesetTagIndex = () => {
let index = new Map()
let attrConfig = field.attributeActionsConfig
let tokenize = attrConfig?.tokenize
let normalize = attrConfig?.normalize ?? ((value) => String(value ?? '').trim())
if (typeof tokenize === 'function') {
for (let entry of field._rulesetRows ?? []) {
let tokens = tokenize(entry?.payload?.tags ?? '')
if (!Array.isArray(tokens)) {
continue
}
for (let token of tokens) {
let tagName = normalize(token)
if (tagName) {
index.set(tagName, true)
}
}
}
}
field._rulesetTagIndex = index
}
field._shouldRefreshWidget = () => {
if (!field.widget) {
return false
}
if (field.alwaysRefresh) {
return true
}
let active = document.activeElement
return !!(active && field.widget.element?.contains(active))
}
field.getOptimized = async () => {
let compiled = await cm._compileRulesetFieldFromIdb(field.key)
field.value = compiled?.rawLines ?? []
field.optimized = compiled?.optimized ?? null
cm._cacheSetting(field.key, field.value, field.optimized)
return field.optimized
}
field.reload = async (options = {}) => {
let generation = ++field._reloadGeneration
try {
let main = await cm._repos.rulesetFields.get(field.key)
if (generation !== field._reloadGeneration) {
return field
}
if (main) {
field._rulesetMain = main
field.templateId = cm._resolveRulesetTemplateId(field)
field.templateConfig = cm._mergeRulesetTemplateConfig(main.templateConfig, field.templateConfig)
cm._applyRulesetUserConfig(field, main.config)
cm._syncRulesetSubjectFromTemplate(field)
field.widget?.updatePanelCopy?.(cm._resolveRulesetPanelCopy(field))
field.widget?.updateConfig?.(field.config)
}
let shouldRepairSort = !options.skipAutoSortRepair &&
!options.query &&
!options.groupQuery &&
options.cursor == null &&
field.config?.autoSort &&
typeof applyRulesetFieldSort === 'function'
if (shouldRepairSort) {
await applyRulesetFieldSort(cm._repos, field.key, field.config.sortMode ?? 'natural-asc')
}
let page
if (options.query || options.groupQuery || options.cursor != null) {
page = await cm._loadRulesetPage(field.key, options)
} else {
page = await cm._repos.rulesetEntries.listInitial(field.key, 50)
}
if (generation !== field._reloadGeneration) {
return field
}
field._rulesetRows = page.entries ?? []
field._rulesetNextCursor = page.nextCursor ?? null
await cm._repairRulesetRowTagEntryIds(field, field._rulesetRows)
await field.getOptimized()
field.rebuildRulesetTagIndex()
} catch (error) {
console.log('Failed to load ruleset field:', error)
}
return field
}
field.patchRow = (row) => {
if (!row || row.entryId == null) {
return field
}
let index = field._rulesetRows.findIndex((entry) => entry.entryId === row.entryId)
if (index >= 0) {
field._rulesetRows[index] = row
} else {
field._rulesetRows.push(row)
}
field._rulesetRows.sort((left, right) => (left.sortOrder ?? 0) - (right.sortOrder ?? 0))
if (field._shouldRefreshWidget()) {
field.updateUserInterface()
}
void field.getOptimized()
return field
}
field.removeRow = (entryId) => {
field._rulesetRows = field._rulesetRows.filter((entry) => entry.entryId !== entryId)
if (field._shouldRefreshWidget()) {
field.updateUserInterface()
}
void field.getOptimized()
return field
}
field.findRuleIndex = (target) => {
let templateId = field.templateId ?? field._rulesetMain?.templateId
let matchTarget
if (typeof target === 'string') {
if (cm._rulesetTemplateRequiresTagEntryId(templateId)) {
let cached = cm.getCachedTagEntry(target)
if (cached?.entryId == null) {
return -1
}
matchTarget = cm._rulesetPayloadForTag(templateId, cached)
} else {
matchTarget = cm._rulesetMatchTargetForTag(templateId, target)
}
} else {
matchTarget = target
}
for (let index = 0; index < (field._rulesetRows ?? []).length; index++) {
let entry = field._rulesetRows[index]
if (typeof RulesetTemplateRegistry !== 'undefined') {
let template = RulesetTemplateRegistry.get(templateId)
if (template?.matchRule?.(entry.payload, matchTarget)) {
return index
}
}
}
return -1
}
field.hasRule = (target) => field.findRuleIndex(target) >= 0
field.setRules = () => {
void field.reload()
}
field.addRule = () => {
void field.reload()
}
field.removeRule = () => {
void field.reload()
}
field.toggleRule = () => {
void field.reload()
}
field.clearRules = () => {
void field.reload()
}
field.serializeForBackup = async () => {
await field.reload()
let main = field._rulesetMain ?? await cm._repos.rulesetFields.get(field.key)
let entries = await cm._repos.rulesetEntries.listAllForField(field.key)
return main || entries.length ? {main, entries} : null
}
field.applyFromBackup = async (data) => {
if (Array.isArray(data)) {
await cm._importTagFieldFromValue(field.key, data)
return
}
if (!data) {
return
}
if (data.main) {
await cm._repos.rulesetFields.upsert(data.main)
} else if (data.config) {
let main = field._rulesetMain ?? await cm._repos.rulesetFields.get(field.key)
await cm._repos.rulesetFields.upsert({
fieldKey: field.key,
templateId: main?.templateId ?? field.templateId ?? '',
templateConfig: main?.templateConfig ?? field.templateConfig ?? {},
config: data.config,
})
}
if (Array.isArray(data.entries)) {
for (let entry of data.entries) {
if (entry.entryId != null) {
await cm._repos.rulesetEntries.update(entry)
} else {
await cm._repos.rulesetEntries.add(field.key, entry)
}
}
}
await field.reload()
field.widget?.render(field._rulesetRows)
}
field.createElement = () => {
field.widget?.dispose?.()
if (typeof cm._uiGen.createRulesetPanel !== 'function') {
field.element = Utilities.makeEl('div', {
class: 'bv-ruleset-panel bv-ruleset-panel-placeholder',
text: field.title ?? 'Ruleset',
})
void field.reload().then(() => field.updateUserInterface())
return field.element
}
cm._mountRulesetPanelWidget(field)
field.element = field.widget.element
void field.reload().then(() => field.updateUserInterface())
return field.element
}
field.setFromUserInterface = () => {}
field.updateUserInterface = () => {
if (!field.widget) {
return
}
field.widget.render(field._rulesetRows, {nextCursor: field._rulesetNextCursor})
field.widget.checkPageMatch?.()
}
field.isCurrentPagePresent = (url) => field.widget?.isCurrentPagePresent?.(url) ?? false
field.dispose = () => {
field.widget?.dispose?.()
field.widget = null
field.element = null
field._rulesetRows = []
field._rulesetNextCursor = null
}
if (!field.detailFormTemplate) {
field.setDetailFormTemplate((mode, ctx) => {
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(cm._resolveRulesetTemplateId(field)) : null
if (!template) {
return Utilities.makeEl('div', {text: 'Ruleset template unavailable.'})
}
let templateCtx = {
...cm._buildRulesetTemplateCtx(field),
existingGroups: [...new Set((field._rulesetRows ?? []).map((row) => row.groupLabel).filter(Boolean))],
entryComment: mode === 'edit' ? String(ctx.entry?.comment ?? '') : '',
}
let panelCopy = cm._resolveRulesetPanelCopy(field)
if (mode === 'create') {
return BrazenViewLayer.createRulesetDetailMultiForm({
addEntryCaption: panelCopy.addEntryCaption,
submitCaption: panelCopy.submitCaption,
createEntryForm: () => template.createForm(templateCtx),
onSubmit: (forms) => void cm._submitRulesetDetailForms(field, forms),
})
}
let form = template.editForm(ctx.entry, templateCtx)
let wrapped = BrazenViewLayer.wrapRulesetDetailEntry(form)
let footer = Utilities.makeEl('div', {
class: 'bv-ruleset-detail-footer',
children: [
Utilities.makeEl('button', {
class: 'bv-button bv-ruleset-detail-submit',
text: 'Save',
attrs: {type: 'button', title: 'Save changes'},
on: {
click: (event) => {
let root = event.currentTarget.closest('.bv-ruleset-detail-single')
let formEl = root?.querySelector('form.bv-ruleset-form, .bv-ruleset-form') ?? form
void cm._submitRulesetDetailForm(field, mode, ctx.entry, formEl)
},
},
}),
],
})
return Utilities.makeEl('div', {
class: 'bv-ruleset-detail-single',
children: [wrapped, footer],
})
})
}
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, this._fieldHelpLabelOptions(field))
field.element = inputGroup.querySelector('select')
return inputGroup
}
field.setFromUserInterface = () => {
field.value = field.element.value
}
field.updateUserInterface = () => {
field.element.value = field.value
field.element.dispatchEvent(new Event('change', {bubbles: true}))
}
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 = () => null
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', this._fieldHelpLabelOptions(field))
field.element = inputGroup.querySelector('input')
return inputGroup
}
field.setFromUserInterface = () => {
let value = field.element.value
field.value = value === '' ? field.textDefault : value
}
field.updateUserInterface = () => {
field.element.value = 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 null
}
field.element = this._uiGen.createFormButton(field.title, typeof field.help === 'string' ? field.help : '', () => {
Utilities.callEventHandler(field.onClick, [])
})
return field.element
}
field.setFromUserInterface = () => {}
field.updateUserInterface = () => {}
return field
}
/**
* True when any cached ruleset row tokenizes to one of the given tag names.
* @param {string|string[]} tags
* @param {string} [fieldKey]
* @return {boolean}
*/
rulesetContainsAnyTag(tags, fieldKey = null)
{
let names = Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])
if (!names.length) {
return false
}
for (let field of Object.values(this._config)) {
if (field.type !== CONFIG_TYPE_RULESET) {
continue
}
if (fieldKey && field.key !== fieldKey) {
continue
}
let index = field._rulesetTagIndex
if (!(index instanceof Map) || !index.size) {
field.rebuildRulesetTagIndex?.()
index = field._rulesetTagIndex
}
if (!(index instanceof Map)) {
continue
}
for (let name of names) {
if (name && index.has(name)) {
return true
}
}
}
return false
}
/**
* @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
}
/**
* Ordered keys of root dock fields that should be mounted under the current include context.
* Used to decide whether a dock rail relayout is required vs state-only refresh.
* @return {string}
*/
getDockRailMembershipSignature()
{
return this.getDockRootFields().
filter((field) => this._evaluateDockInclude(field)).
map((field) => field.key).
join('|')
}
/**
* @param {string} name
* @returns {HTMLElement|null}
*/
createElement(name)
{
let field = this.getFieldOrFail(name)
if (this._dockActive && field.dock) {
field.element = null
return null
}
if (this._storageReady) {
void this._syncFieldFromIdb(this._formatFieldKey(name)).then(() => {
this._overlayFieldFromCache(field)
if (field.element) {
field.updateUserInterface()
}
})
}
let element = field.createElement()
this._warnIfMissingHelp(field)
return element
}
/**
* @param {string} name
* @return {HTMLElement|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 != null) {
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.classList.add('bv-dock-slideout-pinned')
}
}
}
field._dockShowsSlideOut = childButtons.length > 0
field.dockElement = dockButton
return dockButton
}
/**
* @param {ConfigurationField} childField
* @return {HTMLElement|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 {HTMLElement}
* @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) {
for (let cls of field.dock.buttonClass.split(/\s+/)) {
if (cls) {
button.classList.add(cls)
}
}
}
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.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 '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, templateName: name}
}
/**
* @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 != null)) {
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)
{
let included = true
if (field.dock?.include !== undefined) {
if (typeof field.dock.include === 'function') {
if (this._dockIncludeContext) {
included = !!field.dock.include.call(this._dockIncludeContext)
} else {
included = !!field.dock.include()
}
} else {
included = !!field.dock.include
}
}
return included
}
/**
* @param {ConfigurationField} field
* @private
*/
_refreshDockRootSlot(field)
{
let oldNode = field.dockElement
if (oldNode == null) {
return
}
let newNode = this.createDockElement(field.key)
if (newNode == null) {
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 != null) {
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.classList.contains('bv-dock-slot')) {
field.dockElement.classList.toggle('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) => {
if (Array.isArray(rules)) {
return rules.length > 0
}
if (rules && typeof rules === 'object') {
if (Array.isArray(rules.combos)) {
return rules.combos.length > 0
}
if (typeof rules.length === 'number') {
return rules.length > 0
}
}
return !!rules
}
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._storageReady) {
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._storageReady) {
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_RULESET || !field.pageMatch) {
continue
}
tasks.push(field.reload().then(() => field.updateUserInterface()))
}
await Promise.all(tasks)
return this
}
/**
* @return {Promise<BrazenConfigurationManager>}
*/
async reloadRulesetFields()
{
let tasks = []
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type !== CONFIG_TYPE_RULESET) {
continue
}
tasks.push(field.reload().then(() => {
field.updateUserInterface()
}))
}
await Promise.all(tasks)
return this
}
/**
* Tear down ruleset widgets on unload.
* @return {void}
*/
disposeRulesetFields()
{
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type !== CONFIG_TYPE_RULESET) {
continue
}
field.dispose?.()
}
}
/**
* Tear down bookmark widgets (ResizeObserver + pageMatch listeners) on unload.
* @return {void}
*/
disposeBookmarkFields()
{
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type !== CONFIG_TYPE_RULESET || !field.pageMatch) {
continue
}
field.dispose?.()
}
}
/**
* @param {Function} handler
* @return {BrazenConfigurationManager}
*/
setScriptSetup(handler)
{
this._scriptSetupHandler = handler
return this
}
/**
* @param {{onMigrationProgress?: function(MigrationProgress): void|Promise<void>, onMigrationStatus?: function(string): void|Promise<void>}|null} [options]
* Optional progress callback — invoked during safety backup, peer setup wait, schema rebuild, backfill, and ruleset migration.
* @return {Promise<BrazenConfigurationManager>}
*/
async initialize(options = null)
{
this._migrationError = null
if (!this._repos.storage.available) {
let error = new Error('This script requires IndexedDB and cannot run without it.')
error.code = 'IDB_UNAVAILABLE'
throw error
}
let report = this._bindMigrationProgress(options?.onMigrationProgress ?? options?.onMigrationStatus ?? null)
this._reportMigrationProgress = report
try {
let wipeFlagKey = this._scriptPrefix + 'pending-idb-wipe'
let pendingWipe = false
try {
pendingWipe = sessionStorage.getItem(wipeFlagKey) === '1'
} catch (e) {
pendingWipe = false
}
if (pendingWipe) {
await this._repos.storage.deleteDatabase()
try {
sessionStorage.removeItem(wipeFlagKey)
} catch (e) {
}
}
let schemaConflict = await this.getSchemaVersionConflict()
if (schemaConflict) {
let schemaError = new Error(
'Local database schema v' + schemaConflict.installed +
' is newer than this script supports (v' + schemaConflict.supported + ').')
schemaError.code = 'SCHEMA_TOO_NEW'
schemaError.schemaConflict = schemaConflict
throw schemaError
}
await this._repos.storage.open()
let existingMeta = await this._repos.meta.get()
let migrationNeeded = await this._isMigrationWorkNeeded(existingMeta)
if (migrationNeeded) {
let prevCounts = existingMeta?.migrationSafetyBackupCounts
let backupWasEmpty = prevCounts &&
!prevCounts.tags && !prevCounts.bookmarks && !prevCounts.ledgerEntries
let skipBackup = !backupWasEmpty &&
existingMeta?.migrationSafetyBackupRevisionId === existingMeta?.revisionId &&
existingMeta?.migrationSafetyBackupAt
if (skipBackup) {
let hint = this.getPreMigrationBackupFilenameHint() ??
this._scriptPrefix + 'pre-migration-backup.zip'
await report({
phase: 'safety-backup',
label: 'Safety backup already saved',
detail: hint,
current: 1,
total: 1,
})
} else {
await report({phase: 'safety-backup', label: 'Creating safety backup…', current: 0, total: 1})
await this.createPreMigrationSafetyBackup(report)
await report({phase: 'safety-backup', label: 'Safety backup saved', current: 1, total: 1})
}
}
if (existingMeta?.setupInProgress) {
await this._waitPeerSetupWithProgress(report)
}
let healthy = await this._repos.storage.isHealthy()
if (!healthy) {
await report({phase: 'schema-rebuild', label: 'Rebuilding database schema…', indeterminate: true})
await this._repos.storage.deleteDatabase()
await this._repos.storage.open()
await this._runSetupPhase(report)
} else {
await this._repos.storage.runPendingIsDiscoveredBackfill(report)
if (typeof migrateRulesetConfigDefaultsV9 === 'function') {
await migrateRulesetConfigDefaultsV9(this._repos, report)
}
if (typeof migrateRulesetHideTagTypesV10 === 'function') {
await migrateRulesetHideTagTypesV10(this._repos, report)
}
let meta = await this._repos.meta.get()
if (!meta?.rulesetMigrated) {
await this._runPostOpenRulesetMigration(report)
}
meta = await this._repos.meta.get()
if (!meta?.bookmarksMigrated && typeof migrateBookmarksToRuleset === 'function') {
await migrateBookmarksToRuleset(this._repos, report)
}
await this._seedAllFields()
}
await this._runNormalPhase()
if (!(await this._repos.storage.isHealthy())) {
throw new Error('Database health check failed after migration')
}
} catch (error) {
console.log('[BrazenCM] initialize failed:', error)
this._migrationError = error
throw error
} finally {
this._reportMigrationProgress = null
}
return this
}
/**
* @param {function(MigrationProgress|string): Promise<void>} report
* @return {Promise<void>}
* @private
*/
async _runPostOpenRulesetMigration(report)
{
if (this._scriptSetupHandler) {
await report({phase: 'script-seed', label: 'Applying site configuration…', indeterminate: true})
await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
return
}
if (typeof migrateRulesetFromLegacy === 'function') {
await migrateRulesetFromLegacy(this._repos, this, report)
}
}
/**
* @return {Promise<void>}
* @private
*/
async _runSetupPhase(report = null)
{
let progress = report ?? (async () => {})
await progress({phase: 'schema-rebuild', label: 'Initializing database…', current: 0, total: 1})
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())
let sources = []
if (this._legacyDataExists()) {
await progress({phase: 'legacy-import', label: 'Importing legacy settings…', indeterminate: true})
let importer = new BrazenLegacyImporter(this._repos, this)
sources = await importer.run()
}
if (this._scriptSetupHandler) {
await progress({phase: 'script-seed', label: 'Applying site configuration…', indeterminate: true})
await Utilities.callEventHandler(this._scriptSetupHandler, [this._repos, this])
}
await this._seedAllFields()
await this._importTagDomainFromLegacySettings()
await this._recompileAllTagRuleSets(progress)
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_LEDGER) {
await field.reload()
}
if (field.type === CONFIG_TYPE_RULESET) {
await field.reload()
}
}
if (this._idbVisibilityHandler) {
document.removeEventListener('visibilitychange', this._idbVisibilityHandler)
}
this._idbVisibilityHandler = () => {
if (document.hidden) {
return
}
void this._syncFromForeignRevisionIfNeeded()
}
document.addEventListener('visibilitychange', this._idbVisibilityHandler)
// bfcache restore may not flip visibility; same foreign-revision check as focus.
if (this._idbPageshowHandler) {
window.removeEventListener('pageshow', this._idbPageshowHandler)
}
this._idbPageshowHandler = (event) => {
let persisted = !!event.persisted
if (persisted || document.visibilityState === 'visible') {
void this._syncFromForeignRevisionIfNeeded()
}
}
window.addEventListener('pageshow', this._idbPageshowHandler)
}
/**
* Reload settings / tags from IDB when another tab advanced `meta.revisionId`.
* Clears TagRuntime RAM so sidebar / filters cannot keep stale attribute rows
* (`ensureNames` skips names already cached).
* @return {Promise<void>}
* @private
*/
async _syncFromForeignRevisionIfNeeded()
{
if (!this._storageReady) {
return
}
let revisionId = await this._repos.meta.get().then((m) => m?.revisionId)
if (this._syncedRevisionId === revisionId) {
return
}
this._syncedRevisionId = revisionId
this._workingSet = null
this._repos.tagRuntime?.clearCache()
await this._syncFieldsFromIdb()
await this._repos.tagRuntime.warmCache()
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_RULESET) {
await field.reload()
field.updateUserInterface()
}
}
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 (field.type === CONFIG_TYPE_RULESET) {
let compiled = await this._compileRulesetFieldFromIdb(fieldKey)
this._cacheSetting(fieldKey, compiled?.rawLines ?? [], compiled?.optimized ?? null)
if (field.reload) {
await field.reload()
}
this._overlayFieldFromCache(field)
return
}
if (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 spec of RULESET_MIGRATION_FIELD_SPECS) {
if (aggregate[spec.fieldKey] === undefined) {
continue
}
let value = aggregate[spec.fieldKey]
if (Array.isArray(value) && value.length === 0) {
continue
}
await this._importTagFieldFromValue(spec.fieldKey, value)
}
}
/**
* @param {string} fieldKey
* @param {*} value
* @return {Promise<void>}
* @private
*/
async _importTagFieldFromValue(fieldKey, value, options = {})
{
let field = this._config[fieldKey]
if (!field) {
return
}
let spec = RULESET_MIGRATION_FIELD_SPECS.find((entry) => entry.fieldKey === fieldKey)
if (!spec) {
return
}
let seed = this._fieldSeeds.get(fieldKey)
let existingMain = await this._repos.rulesetFields.get(fieldKey)
let backupConfig = options.config ??
(value && typeof value === 'object' && !Array.isArray(value) ? value.config : null)
let importConfig
if (backupConfig && typeof backupConfig === 'object') {
importConfig = backupConfig
} else if (existingMain?.config && Object.keys(existingMain.config).length) {
importConfig = existingMain.config
} else {
importConfig = {...DEFAULT_RULESET_USER_CONFIG, ...(seed?.config ?? spec.config ?? {})}
}
await this._repos.rulesetFields.upsert({
fieldKey: spec.fieldKey,
templateId: spec.templateId,
templateConfig: spec.templateConfig,
config: importConfig,
})
field.templateId = spec.templateId
field.templateConfig = spec.templateConfig
field._rulesetMain = await this._repos.rulesetFields.get(fieldKey)
let lines = []
let lineSource = Array.isArray(value) ? value : (value?.lines ?? value?.entries ?? [])
if (Array.isArray(lineSource)) {
if (lineSource.length && typeof lineSource[0] === 'object' && lineSource[0]?.subject) {
for (let entry of lineSource) {
if (entry?.subject) {
lines.push(entry.subject + ' → ' + (entry.replacement ?? ''))
}
}
} else {
lines = lineSource.map(String)
}
}
let expanded = []
for (let line of lines) {
if (line.includes('|')) {
expanded.push(...expandOrRuleLine(line))
} else {
expanded.push(line)
}
}
if (!expanded.length) {
return
}
let existing = await this._repos.rulesetEntries.listAllForField(fieldKey)
for (let row of existing) {
await this._repos.rulesetEntries.remove(row.entryId)
}
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get(spec.templateId) : null
if (!template) {
return
}
let ctx = this._buildRulesetTemplateCtx(field)
let sortOrder = 0
for (let rawLine of expanded) {
let parsed = template.parseImportLine?.(String(rawLine), ctx)
if (!parsed) {
continue
}
await template.persist(parsed.payload, ctx, {
sortOrder: sortOrder++,
comment: parsed.comment ?? '',
})
}
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, fieldKey)
}
await this._syncFieldFromIdb(fieldKey)
}
/**
* @return {Promise<void>}
* @private
*/
async _recompileAllTagRuleSets(report = null)
{
await this.ensureRulesetFieldsCompiled(null, report)
}
/**
* @return {Promise<void>}
* @private
*/
async _ensureStorageReady()
{
if (!this._storageReady) {
await this.initialize()
}
}
/**
* @return {boolean}
*/
isStorageReady()
{
return this._storageReady
}
/**
* @return {boolean}
*/
isIndexedDBAvailable()
{
return this._repos.storage.available
}
/**
* Count post ids in the download duplicate ledger (IndexedDB row count; no full-table RAM load).
* @return {Promise<number>}
*/
async countDownloadLedger()
{
if (!this.getField('download-ledger')) {
return 0
}
await this._ensureStorageReady()
return this._repos.ledger.countLedgerEntries()
}
/**
* Wipe the download duplicate ledger (all `ledgerEntries` rows) and reload the in-memory Set.
* @return {Promise<void>}
*/
async clearDownloadLedger()
{
await this._ensureStorageReady()
await this._repos.ledger.replaceAll([])
let field = this.getField('download-ledger')
if (field?.reload) {
await field.reload()
}
}
/**
* Replace the download duplicate ledger with the supplied post ids (atomic wipe + write).
* @param {Iterable<string|number|null|undefined>} ids
* @return {Promise<number>} count of ids written
*/
async replaceDownloadLedgerIds(ids)
{
await this._ensureStorageReady()
let field = this.getField('download-ledger')
let isValidId = field?._ledgerIsValidId?.() ??
((value) => typeof value === 'string' && value.trim().length > 0)
let written = 0
await this.beginDownloadLedgerFolderImport(true)
let batch = []
for (let raw of ids) {
if (raw === null || raw === undefined || !String(raw).length) {
continue
}
let postId = String(raw).trim()
if (!postId || !isValidId(postId)) {
continue
}
batch.push(postId)
if (batch.length >= LEDGER_FOLDER_IMPORT_BATCH_SIZE) {
written += await this.mergeDownloadLedgerImportBatch(batch)
batch = []
}
}
if (batch.length) {
written += await this.mergeDownloadLedgerImportBatch(batch)
}
await this.finalizeDownloadLedgerFolderImport()
return written
}
/**
* Prepare ledger folder import: optionally wipe the ledger (replace mode).
* Pair with {@link mergeDownloadLedgerImportBatch} and {@link finalizeDownloadLedgerFolderImport}.
*
* @param {boolean} [replace=false]
* @return {Promise<boolean>}
*/
async beginDownloadLedgerFolderImport(replace = false)
{
await this._ensureStorageReady()
if (replace) {
await this._repos.ledger.replaceAll([])
let field = this.getField('download-ledger')
if (field?.reload) {
await field.reload()
}
}
this._ledgerImportRevisionPending = true
return true
}
/**
* Upsert one batch of post ids during folder import (no full-table RAM mirror).
*
* @param {Iterable<string|number|null|undefined>} ids
* @return {Promise<number>} rows accepted for write
*/
async mergeDownloadLedgerImportBatch(ids)
{
await this._ensureStorageReady()
let field = this.getField('download-ledger')
let isValidId = field?._ledgerIsValidId?.() ??
((value) => typeof value === 'string' && value.trim().length > 0)
let claimedAt = Date.now()
/** @type {{postId: string, claimedAt: number}[]} */
let rows = []
for (let raw of ids) {
if (raw === null || raw === undefined || !String(raw).length) {
continue
}
let postId = String(raw).trim()
if (!postId || !isValidId(postId)) {
continue
}
rows.push({postId, claimedAt})
}
if (!rows.length) {
return 0
}
await this._repos.ledger.mergeRows(rows, {deferRevision: true})
return rows.length
}
/**
* Commit a deferred folder import (single revision bump + cache reload).
* @return {Promise<void>}
*/
async finalizeDownloadLedgerFolderImport()
{
if (this._ledgerImportRevisionPending) {
await this._repos.ledger.commitMergeRows()
this._ledgerImportRevisionPending = false
}
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
await this._repos.storage.deleteDatabase()
}
/**
* Clear {@link TagEntry.isDiscovered} for all tags (`true` → `null`). Types and rulesets unchanged.
* @param {function({current?: number, total?: number, label?: string}): void|Promise<void>|null} [onProgress]
* @return {Promise<{updated: number, total: number}>}
*/
async resetAllTagsDiscovered(onProgress = null)
{
if (!this._repos?.tags) {
return {updated: 0, total: 0}
}
let result = await this._repos.tags.resetAllTagsDiscovered(onProgress)
this._repos.tagRuntime?.clearCache()
await this._commitTagFieldChange()
return result
}
/**
* Cached compliance specs keyed by field (`combos`).
* @type {Record<string, {combos: {tagEntryIds: number[]}[]}>}
* @private
*/
_tagComplianceSpecs = {}
/**
* @return {boolean}
*/
canPersist()
{
return this._storageReady
}
/**
* Read a settings field from IndexedDB (source of truth via `_settingCache`).
* @param {string} fieldKey
* @return {Promise<*>}
*/
async readSetting(fieldKey)
{
await this._ensureStorageReady()
let stored = await this._repos.settings.getField(fieldKey)
if (!stored) {
return this._getCachedSetting(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)
{
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)
{
let field = this.getField(fieldKey)
if (field?.type !== CONFIG_TYPE_RULESET) {
return false
}
let templateId = field.templateId ?? field._rulesetMain?.templateId
return templateId === 'tag-blacklist' ||
templateId === 'explored-tags' ||
templateId === 'tag-sole-ignore' ||
templateId === 'substitution'
}
/**
* @param {string} tagName Normalized tag name.
* @return {*|null}
*/
getCachedTagEntry(tagName)
{
return this.getTagRuntime()?.resolveCachedByName(tagName) ?? null
}
/**
* Filename-ignore sole attribute — same row lookup as download join / Aa button.
* @param {string} tagName
* @return {boolean}
*/
isFilenameTagIgnored(tagName)
{
return this.hasTagSoleAttribute('filename-tag-ignore-list', tagName)
}
/**
* @param {ConfigurationField} field
* @param {number} entryId
* @return {boolean}
* @private
*/
_compiledIgnoreHasEntryId(field, entryId)
{
if (entryId == null) {
return false
}
let ids = field?.optimized?.tagEntryIds
return Array.isArray(ids) && ids.includes(entryId)
}
/**
* Entry ids to consult for filename-ignore reads (full name + optional strip-series base).
* @param {string} normalized
* @param {string} fieldKey
* @return {number[]}
* @private
*/
_ignoreCandidateEntryIds(normalized, fieldKey)
{
let entryIds = []
let seen = new Set()
let add = (name) => {
if (!name) {
return
}
let cached = this.getCachedTagEntry(name)
if (cached?.entryId != null && !seen.has(cached.entryId)) {
seen.add(cached.entryId)
entryIds.push(cached.entryId)
}
}
add(normalized)
if (fieldKey === 'filename-tag-ignore-list') {
let stripped = normalized.replace(/_\([^)]*\)$/, '')
if (stripped && stripped !== normalized) {
add(stripped)
}
}
return entryIds
}
/**
* @param {ConfigurationField} field
* @param {string} templateId
* @param {string} normalized
* @param {string} fieldKey
* @return {boolean}
* @private
*/
_hasTagSoleAttributeByEntryId(field, templateId, normalized, fieldKey)
{
if (templateId === 'tag-sole-ignore') {
for (let entryId of this._ignoreCandidateEntryIds(normalized, fieldKey)) {
if (this._compiledIgnoreHasEntryId(field, entryId)) {
return true
}
let ignoreIds = this.getTagRuntime()?._downloadIgnoreEntryIds
if (ignoreIds?.has(entryId)) {
return true
}
}
}
for (let entryId of this._ignoreCandidateEntryIds(normalized, fieldKey)) {
let cached = this.getTagRuntime()?.getCachedByEntryId?.(entryId) ??
{name: normalized, entryId}
if (this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))) {
return true
}
}
let cached = this.getCachedTagEntry(normalized)
if (cached?.entryId != null) {
if (this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))) {
return true
}
}
return !!this._findRulesetRowForTarget(
field,
this._rulesetMatchTargetForTag(templateId, normalized))
}
/**
* @param {ConfigurationField} field
* @param {string} templateId
* @param {string} normalized
* @param {boolean} present
* @private
*/
_patchOptimisticIgnoreCompiled(field, templateId, normalized, present)
{
if (templateId !== 'tag-sole-ignore') {
return
}
if (!field.optimized) {
field.optimized = {tagEntryIds: []}
} else if (!field.optimized.tagEntryIds) {
field.optimized = {...field.optimized, tagEntryIds: []}
}
let ids = new Set(field.optimized.tagEntryIds)
for (let entryId of this._ignoreCandidateEntryIds(normalized, field.key)) {
if (entryId == null) {
continue
}
if (present) {
ids.add(entryId)
} else {
ids.delete(entryId)
}
}
field.optimized = {...field.optimized, tagEntryIds: [...ids]}
}
/**
* @param {ConfigurationField} field
* @param {string} templateId
* @param {string} normalized
* @return {*|null}
* @private
*/
_findSoleAttributeRowForToggle(field, templateId, normalized)
{
for (let entryId of this._ignoreCandidateEntryIds(normalized, field.key)) {
let cached = this.getTagRuntime()?.getCachedByEntryId?.(entryId) ??
{name: normalized, entryId}
let row = this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))
if (row) {
return row
}
}
let cached = this.getCachedTagEntry(normalized)
if (cached?.entryId != null) {
return this._findRulesetRowForTarget(field, this._rulesetPayloadForTag(templateId, cached))
}
return this._findRulesetRowForTarget(field, this._rulesetMatchTargetForTag(templateId, normalized))
}
/**
* @param {string} fieldKey
* @param {string} tagName Normalized tag name.
* @return {boolean}
*/
hasTagSoleAttribute(fieldKey, tagName)
{
let normalized = String(tagName ?? '').trim()
if (!normalized) {
return false
}
let field = this.getField(fieldKey)
if (field?.type === CONFIG_TYPE_RULESET) {
let templateId = field.templateId ?? field._rulesetMain?.templateId
if (templateId) {
if (this._rulesetTemplateRequiresTagEntryId(templateId)) {
return this._hasTagSoleAttributeByEntryId(field, templateId, normalized, fieldKey)
}
let matchTarget = this._rulesetMatchTargetForTag(templateId, normalized)
return !!this._findRulesetRowForTarget(field, matchTarget)
}
}
return field?.hasRule?.(normalized) ?? false
}
/**
* @param {ConfigurationField} field
* @param {string} subjectName Normalized subject tag name.
* @return {*|null}
* @private
*/
_findSubstitutionSubjectRow(field, subjectName)
{
let normalized = String(subjectName ?? '').trim()
if (!normalized || !field) {
return null
}
let cached = this.getCachedTagEntry(normalized)
let cachedEntryId = cached?.entryId ?? null
let cachedName = cached?.name ?? null
for (let row of field._rulesetRows ?? []) {
let payload = row.payload ?? {}
if (payload.subjectName === normalized || (cachedName && payload.subjectName === cachedName)) {
return row
}
if (cachedEntryId != null && payload.subjectTagEntryId === cachedEntryId) {
return row
}
}
return null
}
/**
* @param {ConfigurationField} field
* @param {string} subjectName Normalized subject tag name.
* @return {boolean}
* @private
*/
_compiledSubstitutionHasSubject(field, subjectName)
{
let optimized = field?.optimized
if (!Array.isArray(optimized)) {
return false
}
let cached = this.getCachedTagEntry(subjectName)
let cachedEntryId = cached?.entryId ?? null
let cachedName = cached?.name ?? null
for (let row of optimized) {
if (cachedEntryId != null && row?.subjectTagEntryId === cachedEntryId) {
return true
}
if (row?.subjectName === subjectName || (cachedName && row?.subjectName === cachedName)) {
return true
}
}
return false
}
/**
* @param {string} tagName Normalized tag name.
* @param {string} [fieldKey='filename-tag-substitutions']
* @return {boolean}
*/
hasTagSubstitutionSubject(tagName, fieldKey = 'filename-tag-substitutions')
{
let normalized = String(tagName ?? '').trim()
if (!normalized) {
return false
}
let field = this.getField(fieldKey)
if (field?.type === CONFIG_TYPE_RULESET && (field.templateId === 'substitution' || field._rulesetMain?.templateId === 'substitution')) {
if (this._compiledSubstitutionHasSubject(field, normalized)) {
return true
}
return !!this._findSubstitutionSubjectRow(field, normalized)
}
return field?.hasRule?.(normalized) ?? false
}
/**
* Persist a filename-tag substitution from the Subject → Alias composer.
* @param {ConfigurationField} field
* @param {string} subject
* @param {string} alias
* @return {Promise<boolean>}
* @private
*/
async _addSubstitutionFromComposer(field, subject, alias)
{
let normalize = field.substitutionComposer?.normalize
let subjectName = normalize
? Utilities.callEventHandler(normalize, [subject], String(subject ?? '').trim())
: String(subject ?? '').trim()
let aliasName = normalize
? Utilities.callEventHandler(normalize, [alias], String(alias ?? '').trim())
: String(alias ?? '').trim()
if (!subjectName || !aliasName || subjectName === aliasName) {
return false
}
return this.setTagSubstitution(subjectName, aliasName, field.key)
}
/**
* 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']
* @param {{subjectTypeName?: string|null, replacementTypeName?: string|null, source?: string|null}} [typeContext]
* @return {Promise<boolean>}
*/
async setTagSubstitution(subject, replacement, fieldKey = 'filename-tag-substitutions', typeContext = null)
{
let field = this.getField(fieldKey)
if (!field) {
return false
}
let subjectName = String(subject ?? '').trim()
let replacementName = String(replacement ?? '').trim()
if (!subjectName || !replacementName) {
return false
}
let main = await this._repos.rulesetFields.get(fieldKey)
if (main?.templateId === 'substitution' && typeof RulesetTemplateRegistry !== 'undefined') {
let template = RulesetTemplateRegistry.get('substitution')
let ctx = this._buildRulesetTemplateCtx(field)
let existing = this._findSubstitutionSubjectRow(field, subjectName)
if (!existing) {
let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
existing = rows.find((row) => template.matchRule(row.payload, {subjectName})) ?? null
}
if (existing?.entryId != null) {
await this._repos.rulesetEntries.remove(existing.entryId)
field.removeRow(existing.entryId)
}
let subjectCtx = this.createTagContext({
typeName: typeContext?.subjectTypeName ?? null,
replacementName,
source: typeContext?.source ?? 'tag-action',
})
await this._repos.tagRuntime.ensureTag(subjectName, subjectCtx)
if (typeContext?.replacementTypeName) {
await this._repos.tagRuntime.ensureTag(replacementName, this.createTagContext({
typeName: typeContext.replacementTypeName,
source: typeContext?.source ?? 'tag-action',
}))
}
let payload = {subjectName, replacementName}
let row = await template.persist(payload, ctx)
field.patchRow(row)
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, fieldKey)
}
await field.getOptimized()
field.updateUserInterface?.()
this._refreshRulesetFieldUiIfClean(field)
// Parity with other ruleset mutations: repaint the settings panel widget and any
// mounted mirror (sidebar / discovery) without waiting on the full config-change pass.
RulesetMutationBus.notify({
fieldKeys: [fieldKey],
entryIds: row?.entryId != null ? [row.entryId] : [],
tagNames: [subjectName, replacementName],
})
await this._commitTagFieldChange(this.createTagsChangeDetail([subjectName, replacementName], fieldKey))
return true
}
return false
}
/**
* 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)
if (!field) {
return false
}
let subjectName = String(subject ?? '').trim()
if (!subjectName) {
return false
}
let main = await this._repos.rulesetFields.get(fieldKey)
if (main?.templateId === 'substitution' && typeof RulesetTemplateRegistry !== 'undefined') {
let template = RulesetTemplateRegistry.get('substitution')
let matchTarget = {subjectName}
let existing = this._findSubstitutionSubjectRow(field, subjectName)
if (!existing) {
let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
existing = rows.find((row) => template.matchRule(row.payload, matchTarget)) ?? null
}
let removedEntryId = null
if (existing?.entryId != null) {
removedEntryId = existing.entryId
await this._repos.rulesetEntries.remove(existing.entryId)
field.removeRow(existing.entryId)
}
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, fieldKey)
}
await field.getOptimized()
field.updateUserInterface?.()
this._refreshRulesetFieldUiIfClean(field)
// Parity with other ruleset mutations: repaint mounted panel / mirrors immediately.
RulesetMutationBus.notify({
fieldKeys: [fieldKey],
entryIds: removedEntryId != null ? [removedEntryId] : [],
tagNames: [subjectName],
})
await this._commitTagFieldChange(this.createTagsChangeDetail(subjectName, fieldKey))
return true
}
return false
}
/**
* @param {string} fieldKey
* @return {boolean}
*/
hasTagComplianceRules(fieldKey)
{
let spec = this.getTagComplianceSpec(fieldKey)
if (spec?.combos?.length) {
return true
}
let field = this.getField(fieldKey)
if (field?.optimized?.combos?.length) {
return true
}
let cached = this._settingCache?.[fieldKey]?.optimized
return !!(cached?.combos?.length)
}
/**
* @param {string[]} [fieldKeys] Compliance ruleset fields.
* @return {Promise<void>}
*/
async refreshTagComplianceSpecs(fieldKeys = null)
{
let tagRuntime = this.getTagRuntime()
if (!tagRuntime) {
return
}
let keys = fieldKeys ?? ['tag-blacklist', 'explored-tags-tracker']
for (let fieldKey of keys) {
this._tagComplianceSpecs[fieldKey] = await tagRuntime.getComplianceSpecForField(fieldKey)
}
}
/**
* @param {string} fieldKey
* @return {{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
}
/**
* Resolve any tag-type descriptor (canonical name, site class/label, or numeric API category id)
* to the canonical type name using the `tagTypes` schema. Consumer scripts use this to type tags
* from site payloads (e.g. autocomplete responses) without hardcoding label→type tables.
*
* @see TagRepository#resolveCanonicalTypeName
* @param {string|number|null|undefined} descriptor
* @return {Promise<string|null>}
*/
async resolveCanonicalTypeName(descriptor)
{
return await this._repos?.tags?.resolveCanonicalTypeName?.(descriptor) ?? null
}
/**
* 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) {
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()
{
let meta = await this._repos.meta.get()
let revisionId = meta?.revisionId ?? null
if (this._bootRulesetCompiledRevisionId !== revisionId) {
await this.ensureRulesetFieldsCompiled(['tag-blacklist', 'explored-tags-tracker'])
}
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._storageReady && tagRuntime) {
return {
...resolver,
tagRuntime,
ignore: null,
substitutions: null,
}
}
return resolver
}
/**
* @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
* @return {Promise<void>}
* @private
*/
async _commitTagFieldChange(detail = null)
{
await this._repos.meta.bumpRevision()
// Notify first so attribute UIs (discovery panel, sidebar) paint from the writer-updated
// cache instead of waiting on a full-table warmCache reload.
this.notifyConfigurationChange('tags', true, detail)
await this._repos.tagRuntime?.warmCache()
}
/**
* @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]
if (!field) {
return false
}
return this._toggleRulesetTagRule(fieldKey, tagName, context)
}
/**
* @param {string} fieldKey
* @return {Promise<boolean>}
*/
async clearTagRules(fieldKey)
{
let field = this._config[fieldKey]
if (!field || field.type !== CONFIG_TYPE_RULESET) {
return false
}
let rows = await this._repos.rulesetEntries.listAllForField(fieldKey)
for (let row of rows) {
await this._repos.rulesetEntries.remove(row.entryId)
}
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._repos, fieldKey)
}
await field.reload()
this._refreshRulesetFieldUiIfClean(field)
if (fieldKey === 'tag-blacklist' || fieldKey === 'explored-tags-tracker') {
await this.refreshTagComplianceSpecs([fieldKey])
}
await this._commitTagFieldChange(this.createTagsChangeDetail(null, fieldKey))
return true
}
/**
* @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._restoreIdbBackup(backupConfig)
} else if (backupConfig.version === 3 && (backupConfig.stores || backupConfig.meta)) {
await this._restoreIdbBackup(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()
{
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_RULESET) {
await field.reload()
field.updateUserInterface()
}
}
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()
{
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
}
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)
}
for (let fieldKey in this._config) {
let field = this._config[fieldKey]
if (field.type === CONFIG_TYPE_RULESET && field.getOptimized) {
await field.getOptimized()
}
}
await this._repos.meta.bumpRevision()
this.notifyConfigurationChange('all', true)
return this
}
/**
* @return {Promise<object[]>}
* @private
*/
async _exportBookmarksBackupRows()
{
let rows = await this._repos.rulesetEntries.listAllForField('bookmarks')
return rows.map((entry) => ({
entryId: entry.entryId,
label: entry?.payload?.label ?? entry?.rawLine ?? '',
tags: entry?.payload?.tags ?? '',
url: entry?.payload?.url ?? '',
sortOrder: entry.sortOrder ?? 0,
}))
}
/**
* @return {Promise<void>}
*/
async backup()
{
await this._ensureStorageReady()
let {zip, filename} = await this._buildBackupZip({purpose: 'userBackup'})
this._triggerBackupZipDownload(zip, filename)
}
/**
* @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: collectBackupJsonArrayFromZipFiles(files, 'tags'),
rulesetFields: JSON.parse(files['rulesetFields.json'] ?? '[]'),
rulesetEntries: JSON.parse(files['rulesetEntries.json'] ?? '[]'),
bookmarks: collectBackupJsonArrayFromZipFiles(files, 'bookmarks'),
ledgerEntries: collectBackupJsonArrayFromZipFiles(files, 'ledgerEntries'),
},
}
}
/**
* @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)
}
}
/**
* Import a legacy flat bookmarks array into a ruleset field via the bookmarks template.
* @param {string} fieldKey
* @param {Array} data
* @return {Promise<void>}
* @private
*/
async _importLegacyBookmarksArray(fieldKey, data)
{
if (!Array.isArray(data)) {
return
}
let field = this.getField(fieldKey)
if (!field || field.type !== CONFIG_TYPE_RULESET) {
return
}
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get('bookmarks') : null
if (!template) {
return
}
let rows = data.
map((bookmark) => {
if (!bookmark || typeof bookmark !== 'object') {
return null
}
let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
if (!tags.length) {
return null
}
let url = typeof bookmark.url === 'string' ? bookmark.url.trim() : ''
let label = typeof bookmark.label === 'string' && bookmark.label.trim() ?
bookmark.label.trim() : tags.replaceAll('_', ' ')
return {
entryId: bookmark.entryId ?? null,
label,
tags,
url,
sortOrder: bookmark.sortOrder ?? 0,
}
}).
filter(Boolean)
let existing = await this._repos.rulesetEntries.listAllForField(fieldKey)
for (let row of existing) {
await this._repos.rulesetEntries.remove(row.entryId)
}
let ctx = this._buildRulesetTemplateCtx(field)
for (let index = 0; index < rows.length; index++) {
let row = rows[index]
await template.persist({
label: row.label ?? '',
tags: row.tags ?? '',
url: row.url ?? '',
}, ctx, {
entryId: row.entryId,
sortOrder: row.sortOrder ?? index,
})
}
await field.reload?.()
field.updateUserInterface?.()
}
/**
* @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_LEDGER) {
await field.merge(value)
} else if (field.persist === false) {
if (field.type === CONFIG_TYPE_RULESET && Array.isArray(value)) {
if (field.templateId === 'bookmarks' || field._rulesetMain?.templateId === 'bookmarks') {
await this._importLegacyBookmarksArray(fieldKey, value)
} else {
await this._importTagFieldFromValue(fieldKey, value)
}
} else {
await field.applyFromBackup?.(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 _restoreIdbBackup(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.tags) {
await this._repos.storage.clearStore(IDB_STORE_TAGS)
await this._repos.storage.putMany(IDB_STORE_TAGS, stores.tags)
}
if (stores.rulesetFields?.length) {
await this._repos.storage.clearStore(IDB_STORE_RULESET_FIELDS)
for (let row of stores.rulesetFields) {
await this._repos.storage.put(IDB_STORE_RULESET_FIELDS, row)
}
}
if (stores.rulesetEntries?.length) {
await this._repos.storage.clearStore(IDB_STORE_RULESET_ENTRIES)
await this._repos.storage.putMany(IDB_STORE_RULESET_ENTRIES, stores.rulesetEntries)
}
if (stores.bookmarks?.length) {
await this._importLegacyBookmarksArray('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.type === CONFIG_TYPE_RULESET || field.type === CONFIG_TYPE_LEDGER ||
field.persist === false) {
continue
}
if (field.element) {
field.setFromUserInterface()
if (this._storageReady && 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
}
}
BrazenConfigurationManager.DEFAULT_RULESET_USER_CONFIG = DEFAULT_RULESET_USER_CONFIG
BrazenConfigurationManager.RULESET_USER_CONFIG_KEYS = RULESET_USER_CONFIG_KEYS