Configuration management for the Brazen user scripts framework
Script này sẽ không được không được cài đặt trực tiếp. Nó là một thư viện cho các script khác để bao gồm các chỉ thị meta
// @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