Main class of the Brazen user scripts framework
Versione datata
Questo script non dovrebbe essere installato direttamente. È una libreria per altri script da includere con la chiave // @require https://update.sleazyfork.org/scripts/416105/1881860/Brazen%20Framework%20-%20Framework.js
// ==UserScript==
// @name Brazen Framework - Framework
// @namespace brazenvoid
// @version 12.3.1
// @author brazenvoid
// @license GPL-3.0-only
// @description Main class of the Brazen user scripts framework
// ==/UserScript==
/** Build id for Tampermonkey Resource-override checks (must match local `base-scripts` file). */
const BRAZEN_FRAMEWORK_BUILD = 'local-12.3.1'
const ICON_RECYCLE = '♻'
// Identification Classes
const CLASS_COMPLIANT_ITEM = 'brazen-compliant-item'
const CLASS_NON_COMPLIANT_ITEM = 'brazen-noncompliant-item'
// Preset filter configuration keys
const CONFIG_PAGINATOR_LIMIT = 'pagination-limit'
const CONFIG_PAGINATOR_THRESHOLD = 'pagination-threshold'
const FILTER_DURATION_RANGE = 'duration'
const FILTER_PERCENTAGE_RATING_RANGE = 'rating'
const FILTER_SUBSCRIBED_VIDEOS = 'hide-subscribed-videos'
const FILTER_TAG_BLACKLIST = 'tag-blacklist'
const FILTER_TEXT_BLACKLIST = 'blacklist'
const FILTER_TEXT_SEARCH = 'search'
const FILTER_TEXT_SANITIZATION = 'text-sanitization-rules'
const FILTER_TEXT_WHITELIST = 'whitelist'
const FILTER_UNRATED = 'unrated'
const STORE_SUBSCRIPTIONS = 'account-subscriptions'
// Item preset attributes
const ITEM_NAME = 'name'
const ITEM_PROCESSED_ONCE = 'processedOnce'
// Configuration
const OPTION_ENABLE_TEXT_BLACKLIST = 'enable-text-blacklist'
const OPTION_ENABLE_TAG_BLACKLIST = 'enable-tag-blacklist'
const OPTION_DOCK_POSITION = 'dock-position'
const DOCK_BRAND_PREFIX = 'Brazen Scripts - '
const OPTION_DISABLE_COMPLIANCE_VALIDATION = 'disable-all-filters'
const OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER = 'skip-duplicate-downloads'
/** Default tooltip for {@link OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER}. */
const OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER_HELP =
'Remember prior saves in script storage and skip re-downloads. Off if you removed files and want them again.'
const OPTION_HIDE_DOWNLOADED_MEDIA = 'hide-downloaded-media'
/** Default tooltip for {@link OPTION_HIDE_DOWNLOADED_MEDIA}. */
const OPTION_HIDE_DOWNLOADED_MEDIA_HELP =
'Hide search tiles whose media is already in the download ledger. New downloads are recorded when this option or Skip Duplicate Downloads is on.'
/**
* Canonical `GM_download` conflict policy for Brazen downloads.
* Always `'overwrite'` — user pattern filenames must never be uniquified.
*
* @type {'overwrite'}
*/
const DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION = 'overwrite'
class BrazenItemAttributesResolver
{
/**
* @typedef {{itemLinkSelector: JQuery.Selector, itemDeepAnalysisSelector: JQuery.Selector, requestDelay: number,
* onDeepAttributesResolution: Function}} ItemAttributesResolverConfiguration
*/
/**
* @callback ItemAttributesResolverCallback
* @param {JQuery} item
* @return {*}
*/
// -------------------------------------------------------------------------
// Private class variables
// -------------------------------------------------------------------------
/**
* @type {{}}
* @private
*/
_attributes = {}
/**
* @type {{}}
* @private
*/
_asyncAttributes = {}
/**
* @type {{}}
* @private
*/
_deepAttributes = {}
/**
* @type {boolean}
* @private
*/
_hasDeepAttributes = false
/**
* @type {number}
* @private
*/
_requestIteration = 1
/**
* @type {JQuery<HTMLElement> | jQuery | HTMLElement}
* @private
*/
_sandbox = $('<div id="brazen-item-attributes-resolver-sandbox" hidden/>').appendTo('body')
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {ItemAttributesResolverConfiguration} configuration
*/
constructor(configuration)
{
this._itemLinkSelector = configuration.itemLinkSelector
this._itemDeepAnalysisSelector = configuration.itemDeepAnalysisSelector
this._onDeepAttributesResolution = configuration.onDeepAttributesResolution
this._requestDelay = configuration.requestDelay
}
// -------------------------------------------------------------------------
// Private class methods
// -------------------------------------------------------------------------
/**
* @param {string} attribute
* @returns {string}
* @private
*/
_formatAttributeName(attribute)
{
return attribute.toLowerCase().replaceAll(' ', '_')
}
/**
* @param {JQuery} item
* @param {Object} attributesBag
* @private
*/
_loadDeepAttributes(item, attributesBag)
{
let url = item.find(this._itemLinkSelector).first().attr('href')
if (url) {
Utilities.sleep(this._requestIteration * this._requestDelay).then(() => {
try {
this._sandbox.load(url + ' ' + this._itemDeepAnalysisSelector, () => {
for (const attributeName in this._deepAttributes) {
attributesBag[attributeName] = this._deepAttributes[attributeName](this._sandbox)
}
this._onDeepAttributesResolution(item)
this._sandbox.empty()
})
} catch {
console.error('Deep attributes loading failed.')
}
})
this._requestIteration++
}
}
// -------------------------------------------------------------------------
// Public class methods
// -------------------------------------------------------------------------
/**
* @param {string} attribute
* @param {ItemAttributesResolverCallback} resolutionCallback
* @returns {this}
*/
addAsyncAttribute(attribute, resolutionCallback)
{
this._asyncAttributes[this._formatAttributeName(attribute)] = resolutionCallback
return this
}
/**
* @param {string} attribute
* @param {ItemAttributesResolverCallback} resolutionCallback
* @returns {this}
*/
addAttribute(attribute, resolutionCallback)
{
this._attributes[this._formatAttributeName(attribute)] = resolutionCallback
return this
}
/**
* @param {string} attribute
* @param {ItemAttributesResolverCallback} resolutionCallback
* @returns {this}
*/
addDeepAttribute(attribute, resolutionCallback)
{
this._deepAttributes[this._formatAttributeName(attribute)] = resolutionCallback
this._hasDeepAttributes = true
return this
}
/**
* @returns {BrazenItemAttributesResolver}
*/
completeResolutionRun()
{
this._requestIteration = 1
return this
}
/**
* @param {JQuery} item
* @param {string} attribute
* @returns {*}
*/
get(item, attribute)
{
let attributesBag = item[0].scriptAttributes
if (attributesBag !== undefined) {
let attributeName = this._formatAttributeName(attribute)
let attributeValue = attributesBag[attributeName]
if (attributeValue !== undefined) {
return attributeValue
} else if (this._hasDeepAttributes) {
this._loadDeepAttributes(item, attributesBag)
}
}
return null
}
/**
* @param {JQuery} item
* @param {Function|null} afterResolutionCallback
*/
resolveAttributes(item, afterResolutionCallback = null)
{
let attributesBag = {}
item[0].scriptAttributes = attributesBag
for (const attributeName in this._attributes) {
attributesBag[attributeName] = this._attributes[attributeName](item)
}
}
/**
* @param {JQuery} item
* @param {string} attribute
* @param {*} value
* @returns {BrazenItemAttributesResolver}
*/
set(item, attribute, value)
{
item[0].scriptAttributes[this._formatAttributeName(attribute)] = value
return this
}
}
class BrazenFramework
{
/**
* @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter
*/
/**
* @typedef {{storageKey?: string, primaryField?: string, legacyIdFields?: string[],
* legacyStorageKeys?: string[],
* enableConfigKey?: string, enableHelpText?: string, enableDefault?: boolean,
* isValidId?: (value: *) => boolean,
* getDownloadId: SearchEnhancerDownloadLedgerIdCallback}} DownloadDuplicateLedgerConfiguration
*/
/**
* @typedef {{configKey: string, otherTagSectionsSelector?: JQuery, styleClass: string, rows?: int, helpText: string, removeClasses?: string,
* formatter?: Function}} ItemTagHighlightsConfiguration
*/
/**
* @typedef {JQuery.Selector|Function|{[pageOrLayout: string]: JQuery.Selector, default?: JQuery.Selector}} SelectorConfig
*/
/**
* @typedef {{detect: Function, layout?: Function}} PageDefinition
*/
/**
* @typedef {{pages: string[], operation: Function}} PageOperation
*/
/**
* @typedef {{doItemCompliance?: Function, downloadDuplicateLedger?: DownloadDuplicateLedgerConfiguration, downloadsDelay?: int,
* isUserLoggedIn?: boolean, itemDeepAnalysisSelector?: JQuery.Selector,
* itemListSelectors: SelectorConfig, itemLinkSelector?: SelectorConfig, itemNameSelector: SelectorConfig, itemSelectors: SelectorConfig,
* itemSelectionMethod?: string, itemWrapperResolver?: SearchEnhancerItemWrapperResolver|null,
* requestDelay?: number, scriptPrefix: string, legacyScriptPrefix?: string,
* tagSelectorGenerator?: SearchEnhancerTagSelectorGeneratorCallback|null,
* trackComplianceRules?: boolean}} Configuration
*/
/**
* @callback SearchEnhancerFilterValidationCallback
* @param {*} configValues
* @return boolean
*/
/**
* @callback SearchEnhancerFilterComplianceCallback
* @param {JQuery} item
* @param {*} configValues
* @return {*}
*/
/**
* @callback SubscriptionsFilterExclusionsCallback
* @return {boolean}
*/
/**
* @callback SubscriptionsFilterUsernameCallback
* @param {JQuery} item
* @return {boolean|string}
*/
/**
* @callback SearchEnhancerItemWorkerCallback
* @param {JQuery} item
*/
/**
* @callback SearchEnhancerItemWrapperResolver
* @param {JQuery} item
* @return {JQuery}
*/
/**
* @callback SearchEnhancerTagsExtractionCallback
* @param {JQuery} item
* @return {string[]}
*/
/**
* @callback SearchEnhancerTagSelectorGeneratorCallback
* @param {string} tag
* @return {string}
*/
/**
* @callback SearchEnhancerDownloadLedgerIdCallback
* @param {*} item - DOM node, jQuery tile, or other site-specific download context
* @return {string|null|undefined}
*/
// -------------------------------------------------------------------------
// Private class variables
// -------------------------------------------------------------------------
/**
* Array of item compliance filters
* @type {ComplianceFilter[]}
* @private
*/
_complianceFilters = []
/**
* @type {BrazenDownloadManager|null}
* @private
*/
_downloadManager = null
/**
* @type {string}
* @private
*/
_highlightClasses = ''
/**
* @type {boolean}
* @private
*/
_sanitizationEnabled = false
/**
* @type {JQuery}
* @private
*/
_subscriptionsLoaderButton = null
// -------------------------------------------------------------------------
// Protected class variables
// -------------------------------------------------------------------------
/**
* @type {boolean}
* @protected
*/
_disableUI = false
/**
* @type {JQuery|null}
* @protected
*/
_uiSection = null
/**
* @type {{orientations: string[], defaultOrientation: string, scriptName?: string, showBranding?: boolean, onOpenMainPanel?: Function}|null}
* @protected
*/
_dockConfig = null
/**
* Persisted dock edge; loaded from IndexedDB via `readSetting('dock-position')`, not `field.value`.
* @type {string|null}
* @protected
*/
_dockOrientation = null
/**
* Last composed dock-rail membership signature (root field keys). Used to avoid
* tearing down CSS slide-outs on every configuration ping.
* @type {string|null}
* @private
*/
_dockRailMembershipSignature = null
/**
* Re-syncs bottom-dock panels when the dock layout changes size.
* @type {ResizeObserver|null}
* @private
*/
_dockPanelResizeObserver = null
/**
* Compliance list ChildObservers created on first validation run.
* @type {ChildObserver[]}
* @private
*/
_itemListChildObservers = []
/**
* Debounce timer for coalesced `_onAfterComplianceRun` after deep attribute loads.
* @type {number|null}
* @private
*/
_afterComplianceRunTimer = null
/**
* AbortController for `#bv-ui` mouseenter/mouseleave hide scheduling.
* @type {AbortController|null}
* @private
*/
_mainPanelUiAbort = null
/**
* Pending hide timer for `#bv-ui` mouseleave.
* @type {number|null}
* @private
*/
_mainPanelHideTimer = null
/**
* Whether pagehide teardown for observers / UI timers has been wired.
* @type {boolean}
* @private
*/
_frameworkUnloadWired = false
/**
* Cached subscription string used to invalidate per-username regexes.
* @type {string|null}
* @private
*/
_subscriptionsFilterSource = null
/**
* @type {Map<string, RegExp>}
* @private
*/
_subscriptionsFilterRegexCache = new Map()
/**
* Debounce timer for dock panel position sync.
* @type {number|null}
* @private
*/
_dockPanelSyncTimer = null
/**
* Attribute keys registered via `_addItemTagAttribute` (tag name arrays on items).
* @type {string[]}
* @private
*/
_tagListAttributeKeys = []
/**
* Hide-downloaded id resolver from `_addItemHideDownloadedMediaFilter`.
* @type {SearchEnhancerDownloadLedgerIdCallback|null}
* @private
*/
_hideDownloadedGetItemId = null
/**
* Debounce timer for ledger-driven Hide Downloaded refreshes (claims fire often).
* @type {ReturnType<typeof setTimeout>|null}
* @private
*/
_ledgerComplianceRefreshTimer = null
/**
* Set when a ledger config event is coalesced with queue/state traffic so hide refresh
* still runs after the merged flush.
* @type {boolean}
* @private
*/
_ledgerComplianceDirty = false
/**
* @type {string|null}
* @protected
*/
_downloadDuplicateLedgerFieldKey = null
/**
* @type {{enableConfigKey: string, getDownloadId: SearchEnhancerDownloadLedgerIdCallback}|null}
* @protected
*/
_downloadDuplicateLedgerConfig = null
/**
* Operations to perform after script initialization
* @type {function[]}
* @protected
*/
_onAfterInitialization = []
/**
* Host hooks after bookmark fields reload (e.g. GM hydrate).
* @type {function[]}
* @protected
*/
_onBookmarksHydrate = []
/**
* Script hooks after Framework refreshes panel/dock/bookmarks on configuration change.
* @type {function({manager: BrazenConfigurationManager, source: string, local: boolean})[]}
* @protected
*/
_onConfigurationChange = []
/**
* Session UI refresh when tag-substitution link mode changes without a config write.
* @type {function[]}
* @protected
*/
_onTagSubstitutionUiChange = []
/**
* In-progress alias for filename substitution link mode (normalized), or null.
* @type {string|null}
* @private
*/
_tagSubstitutionLinkSource = null
/**
* Site type name for {@link _tagSubstitutionLinkSource} when linking from a typed panel row.
* @type {string|null}
* @private
*/
_tagSubstitutionLinkSourceType = null
/**
* In-flight tag attribute persists (`fieldKey\\0normalizedName`) — blocks double-toggles
* after optimistic UI rebuilds replace the clicked button.
* @type {Set<string>}
* @private
*/
_tagAttributePersistKeys = new Set()
/**
* @type {{manager: BrazenConfigurationManager, source: string, local: boolean}|null}
* @private
*/
_pendingConfigurationChange = null
/**
* @type {boolean}
* @private
*/
_configurationChangeScheduled = false
/**
* Operations to perform after a complete compliance run
* @type {function[]}
* @protected
*/
_onAfterComplianceRun = []
/**
* Operations to perform after UI generation
* @type {function[]}
* @protected
*/
_onAfterUIBuild = []
/**
* Operations to perform before compliance validation.
* @type {function[]}
* @protected
*/
_onBeforeCompliance = []
/**
* Operations to perform before UI generation
* @type {function[]}
* @protected
*/
_onBeforeUIBuild = []
/**
* Operations to perform after compliance rule checks, the first time a search item is retrieved
* @type {function(JQuery)[]}
* @protected
*/
_onFirstHitAfterCompliance = []
/**
* Operations to perform before compliance checks, the first time a search item is retrieved
* @type {function(JQuery)[]}
* @protected
*/
_onFirstHitBeforeCompliance = []
/**
* Logic to hide a non-compliant item
* @type {SearchEnhancerItemWorkerCallback}
* @param {JQuery} item
* @protected
*/
_onItemHide = null
/**
* Logic to show the compliant search item
* @type {Function[]}
* @param {JQuery} item
* @protected
*/
_onItemShow = []
/**
* Validate initiating initialization.
* Can be used to stop script initialization on specific pages or vice versa
* @type {Function}
* @protected
*/
_onValidateInit = () => true
/**
* Operations to perform at the start of gated full init (Phase 2), after page detection.
* @type {function[]}
* @protected
*/
_onBeforeFullInit = []
/**
* Registered page definitions (name → detect/layout config).
* @type {Map<string, PageDefinition>}
* @protected
*/
_pages = new Map()
/**
* Pages active on the current document (evaluated once per `init()`).
* @type {Set<string>}
* @protected
*/
_activePages = new Set()
/**
* Optional allow-list of page names that gate full init. `null` = all defined pages.
* @type {string[]|null}
* @protected
*/
_initPages = null
/**
* Optional allow-list of page names that gate compliance runs. `null` = all active pages.
* @type {string[]|null}
* @protected
*/
_compliancePages = null
/**
* Boot-time page operations (Phase 1).
* @type {PageOperation[]}
* @protected
*/
_pageOperations = []
/**
* Layout variant per active page name (when a layout resolver is defined).
* @type {Map<string, string|null>}
* @protected
*/
_layouts = new Map()
/**
* Runtime switch for compliance passes (independent of Disable All Filters).
* @type {boolean}
* @protected
*/
_complianceEnabled = true
/**
* Pagination manager
* @type BrazenPaginator|null
* @protected
*/
_paginator = null
/**
* @type {ComplianceRuleRecorder|null}
* @protected
*/
_complianceRules = null
/**
* @type {BrazenSubscriptionsLoader|null}
* @protected
*/
_subscriptionsLoader = null
/**
* Must return the generated settings section node
* @type {JQuery[]}
* @protected
*/
_userInterface = []
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {Configuration} configuration
*/
constructor(configuration)
{
this._config = configuration
if (configuration.isUserLoggedIn === undefined) {
this._config.isUserLoggedIn = false
}
if (configuration.itemSelectionMethod === undefined) {
this._config.itemSelectionMethod = 'find'
}
if (configuration.itemWrapperResolver === undefined) {
this._config.itemWrapperResolver = (item) => item
}
this._itemAttributesResolver = new BrazenItemAttributesResolver({
itemDeepAnalysisSelector: this._config.itemDeepAnalysisSelector ?? '',
itemLinkSelector: this._config.itemLinkSelector ?? '',
requestDelay: this._config.requestDelay ?? 0,
onDeepAttributesResolution: (item) => {
this._complyItem(item)
// Coalesce deep-resolve completions into one after-run (not once per item).
this._scheduleAfterComplianceRun()
},
})
this._statistics = new StatisticsRecorder(this._config.scriptPrefix)
if (configuration.trackComplianceRules) {
this._complianceRules = new ComplianceRuleRecorder()
}
this._uiGen = new BrazenViewLayer(this._config.scriptPrefix)
this._configurationManager = new BrazenConfigurationManager(this._config.scriptPrefix, this._uiGen, this._config.tagSelectorGenerator)
if (configuration.legacyScriptPrefix) {
this._configurationManager.setLegacyScriptPrefix(configuration.legacyScriptPrefix)
}
this._configurationManager.onConfigurationChange((event) => {
this._scheduleConfigurationChange(event)
})
this._configurationManager.addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
setTitle('Disable All Filters').
setHelpText('Disables all search filters.').
applyDockTemplate('invertedFiltersMaster')
this._onItemHide = (item) => this._config.itemWrapperResolver(item).addClass(CLASS_NON_COMPLIANT_ITEM).removeClass(CLASS_COMPLIANT_ITEM).hide()
this._onItemShow.push((item) => this._config.itemWrapperResolver(item).addClass(CLASS_COMPLIANT_ITEM).removeClass(CLASS_NON_COMPLIANT_ITEM).show())
if (configuration.downloadDuplicateLedger?.getDownloadId) {
this._initDownloadDuplicateLedger(configuration.downloadDuplicateLedger)
}
}
// -------------------------------------------------------------------------
// Private class methods
// -------------------------------------------------------------------------
/**
* @return {ConfigurationField|null}
* @private
*/
_getDownloadDuplicateLedgerField()
{
if (!this._downloadDuplicateLedgerFieldKey) {
return null
}
return this._configurationManager.getField(this._downloadDuplicateLedgerFieldKey)
}
/**
* @param {DownloadDuplicateLedgerConfiguration} ledgerConfig
* @private
*/
_initDownloadDuplicateLedger(ledgerConfig)
{
let enableConfigKey = ledgerConfig.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER
let enableHelpText = ledgerConfig.enableHelpText ?? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER_HELP
let enableFlag = this._configurationManager.addFlagField(enableConfigKey).setHelpText(enableHelpText)
if (enableConfigKey === OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER) {
enableFlag.setTitle('Skip Duplicate Downloads')
}
if (ledgerConfig.enableDefault !== false) {
// Prefer setDefault so _settingCache matches — getValue() reads cache after createField
// seeds false, and a bare field.value = true leaves Skip Duplicate "off" after refresh.
enableFlag.setDefault(true)
}
enableFlag.applyDockTemplate('skipDuplicates')
this._configurationManager.addLedgerField('download-ledger').
setTitle('Download Ledger').
setPrimaryField(ledgerConfig.primaryField ?? 'ids').
setIsValidId(ledgerConfig.isValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)).
reload()
this._downloadDuplicateLedgerFieldKey = 'download-ledger'
this._downloadDuplicateLedgerConfig = {
enableConfigKey,
getDownloadId: ledgerConfig.getDownloadId,
}
}
/**
* Decodes HTML entities (e.g. `&` → `&`) from DOM-derived strings.
*
* @param {string} text
* @return {string}
* @protected
*/
_decodeHtmlEntities(text)
{
return Utilities.decodeHtmlEntities(text)
}
/**
* Polls and observes the DOM until `predicate(element)` is true for the first
* match of `selector`, then invokes `callback(element)` once.
*
* @param {string} selector
* @param {function(Element): boolean} predicate
* @param {function(Element): void} callback
* @param {{pollMs?: number, timeoutMs?: number, observeRoot?: Element}} [options]
* @protected
*/
_waitForDomElement(selector, predicate, callback, options = {})
{
this._domElementWatches ??= new Map()
// Dedupe concurrent watches for the same selector.
this._domElementWatches.get(selector)?.abort()
let pollMs = options.pollMs ?? 150
let timeoutMs = options.timeoutMs ?? 30000
let observeRoot = options.observeRoot ?? document.documentElement
let controller = new AbortController()
this._domElementWatches.set(selector, controller)
let resolved = false
let observer = null
let poll = null
let timeout = null
let cleanup = () => {
observer?.disconnect()
observer = null
if (poll) {
clearInterval(poll)
poll = null
}
if (timeout) {
clearTimeout(timeout)
timeout = null
}
if (this._domElementWatches?.get(selector) === controller) {
this._domElementWatches.delete(selector)
}
}
let abort = () => {
if (!controller.signal.aborted) {
controller.abort()
}
cleanup()
}
let attempt = () => {
if (resolved || controller.signal.aborted) {
return
}
let element = document.querySelector(selector)
if (!element || !predicate(element)) {
return
}
resolved = true
cleanup()
callback(element)
}
controller.signal.addEventListener('abort', cleanup, {once: true})
attempt()
if (resolved) {
return {abort}
}
observer = new MutationObserver(attempt)
observer.observe(observeRoot, {subtree: true, childList: true, attributes: true, attributeFilter: ['src']})
poll = setInterval(attempt, pollMs)
timeout = setTimeout(abort, timeoutMs)
return {abort}
}
/**
* @param {string} helpText
* @param rows
* @protected
*/
_addItemBlacklistFilter(helpText, rows = 5)
{
this._configurationManager.addFlagField(OPTION_ENABLE_TEXT_BLACKLIST).
setTitle('Enable Text Blacklist').
setHelpText('Applies the blacklist.')
this._configurationManager.addRulesetField(FILTER_TEXT_BLACKLIST).
setTitle('Blacklist').
setRows(rows).
setHelpText(helpText).
setOptimize((rules) => Utilities.buildWholeWordMatchingRegex(rules) ?? '').
setSortRules(true)
this._addItemComplexComplianceFilter(
FILTER_TEXT_BLACKLIST,
(rules) => this._getConfig(OPTION_ENABLE_TEXT_BLACKLIST) && rules !== '',
(item, value) => this._get(item, ITEM_NAME)?.match(value) === null,
)
}
/**
* @param {string} configKey
* @param {SearchEnhancerFilterValidationCallback|null} validationCallback
* @param {SearchEnhancerFilterComplianceCallback|null|string} complianceCallback
* @protected
*/
_addItemComplexComplianceFilter(configKey, validationCallback, complianceCallback)
{
this._addItemComplianceFilter(configKey, complianceCallback, validationCallback)
}
/**
* @param {string} configKey
* @param {SearchEnhancerFilterComplianceCallback|null|string} action
* @param {SearchEnhancerFilterValidationCallback|null} validationCallback
* @protected
*/
_addItemComplianceFilter(configKey, action = null, validationCallback = null)
{
let configType = this._configurationManager.getField(configKey).type
if (action === null) {
action = configKey
}
if (typeof action === 'string') {
let attributeName = action
switch (configType) {
case CONFIG_TYPE_CHECKBOXES_GROUP:
action = (item, values) => {
let attribute = this._get(item, attributeName)
return attribute && values.length ? values.includes(attribute) : true
}
break
case CONFIG_TYPE_FLAG:
action = (item) => {
let attribute = this._get(item, attributeName)
return attribute === null ? true : attribute
}
break
case CONFIG_TYPE_RADIOS_GROUP:
action = (item, value) => {
let attribute = this._get(item, attributeName)
return attribute ? value === attribute : true
}
break
case CONFIG_TYPE_RANGE:
action = (item, range) => {
let attribute = this._get(item, attributeName)
return attribute ? Validator.isInRange(this._get(item, attributeName), range.minimum, range.maximum) : true
}
break
default:
throw new Error('Associated config type requires explicit action callback definition.')
}
}
if (validationCallback === null) {
validationCallback = this._configurationManager.generateValidationCallback(configKey)
}
this._complianceFilters.push({
configKey: configKey,
validate: validationCallback,
comply: action,
})
}
/**
* @param {JQuery.Selector|Function} durationNodeSelector
* @param {string|null} helpText
* @param {string} separator
* @protected
*/
_addItemDurationRangeFilter(durationNodeSelector, helpText = null, separator = ':')
{
this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 0, 100000).
setTitle('Duration').
setHelpText(helpText ?? 'Filter items by duration.')
this._itemAttributesResolver.addAttribute(FILTER_DURATION_RANGE, (item) => {
let duration
if (typeof durationNodeSelector === 'function') {
duration = durationNodeSelector(item)
} else {
let durationNode = item.find(durationNodeSelector)
if (durationNode.length) {
duration = durationNode.text().trim()
} else {
return null
}
}
duration = duration.split(separator)
duration = (Number.parseInt(duration[0]) * 60) + Number.parseInt(duration[1])
return duration === 0 ? null : duration
})
this._addItemComplianceFilter(FILTER_DURATION_RANGE)
}
/**
* @param {JQuery.Selector} ratingNodeSelector
* @param {string|null} helpText
* @param {string|null} unratedHelpText
* @protected
*/
_addItemPercentageRatingRangeFilter(ratingNodeSelector, helpText = null, unratedHelpText = null)
{
this._configurationManager.addRangeField(FILTER_PERCENTAGE_RATING_RANGE, 0, 100000).
setTitle('Rating').
setHelpText(helpText ?? 'Filter items by percentage rating.')
this._configurationManager.addFlagField(FILTER_UNRATED).
setTitle('Unrated').
setHelpText(unratedHelpText ?? 'Hide items with zero or no rating.')
this._itemAttributesResolver.addAttribute(FILTER_PERCENTAGE_RATING_RANGE, (item) => {
let rating = item.find(ratingNodeSelector)
return rating.length === 0 ? null : Number.parseInt(rating.text().trim().replace('%', ''))
})
this._addItemComplianceFilter(FILTER_PERCENTAGE_RATING_RANGE, (item, range) => {
let rating = this._get(item, FILTER_PERCENTAGE_RATING_RANGE)
return rating ? Validator.isInRange(rating, range.minimum, range.maximum) : !this._getConfig(FILTER_UNRATED)
})
}
/**
* @param {string} key
* @param {boolean} deepAttribute
* @param {boolean} saveSelectors
* @param {SearchEnhancerTagsExtractionCallback} extractTags
* @protected
*/
_addItemTagAttribute(key, deepAttribute, saveSelectors, extractTags)
{
if (!this._tagListAttributeKeys.includes(key)) {
this._tagListAttributeKeys.push(key)
}
let tagsToSelectorsMapper = (item) => {
if (saveSelectors) {
let tagSelectors = ''
for (let tag of extractTags(item)) {
tagSelectors += this._config.tagSelectorGenerator(tag)
}
return tagSelectors
}
let tags = []
for (let tag of extractTags(item)) {
tags.push(tag)
}
return tags
}
if (deepAttribute) {
this._itemAttributesResolver.addDeepAttribute(key, tagsToSelectorsMapper)
} else {
this._itemAttributesResolver.addAttribute(key, tagsToSelectorsMapper)
}
}
/**
* @param {string|null} attribute
* @param {boolean} useSelectors
* @param {int} rows
* @param {string|null} helpText
* @param {string|null} key
* @param {string|null} optionKey
* @param {string|null} dockTemplateName Named CM dock template (default `tagBlacklist` for primary blacklist).
* @protected
*/
_addItemTagBlacklistFilter(attribute, useSelectors, rows = 5, helpText = null, key = null, optionKey = null, dockTemplateName = null)
{
if (helpText === null) {
helpText = 'Specify the tags blacklist with one rule on each line. <br> Conditional operators; "&" "|" can be used to make complex rules.'
}
if (key === null) {
key = FILTER_TAG_BLACKLIST
}
if (optionKey === null) {
optionKey = OPTION_ENABLE_TAG_BLACKLIST
}
let enableField = this._configurationManager.addFlagField(optionKey).setHelpText('Applies the blacklist.')
if (optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
enableField.setTitle('Enable Tag Blacklist')
}
let templateName = dockTemplateName
if (!templateName && optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
templateName = 'tagBlacklist'
}
if (templateName) {
enableField.applyDockTemplate(templateName)
}
this._configurationManager.addTagRulesetField(key, useSelectors).
setTitle(key === FILTER_TAG_BLACKLIST ? 'Tag Blacklist' : key).
setRows(rows).
setHelpText(helpText).
setSortRules(true)
this._addItemComplexComplianceFilter(
key,
() => this._validateTagComplianceFilter(key, optionKey),
(item) => this._complyTagComplianceFilter(item, attribute, key),
)
}
/**
* @param {string} fieldKey
* @param {string} optionKey
* @return {boolean}
* @protected
*/
_validateTagComplianceFilter(fieldKey, optionKey)
{
if (!this._getConfig(optionKey)) {
return false
}
if (this._configurationManager.isTagRegistryField(fieldKey)) {
return this._configurationManager.hasTagComplianceRules(fieldKey)
}
let field = this._configurationManager.getField(fieldKey)
let optimized = field?.optimized ?? field?.value
return Array.isArray(optimized) ? optimized.length > 0 : !!optimized?.length
}
/**
* @param {JQuery} item
* @param {string} attribute
* @param {string} fieldKey
* @return {boolean|{complies: boolean, rule: string}}
* @protected
*/
_complyTagComplianceFilter(item, attribute, fieldKey)
{
let itemTags = this._get(item, attribute)
if (itemTags === null || !itemTags.length) {
return true
}
if (this._configurationManager.isTagRegistryField(fieldKey) && this._configurationManager.canPersist()) {
let result = this._configurationManager.evaluateTagCompliance(itemTags, fieldKey)
return result.complies ? true : result
}
let field = this._configurationManager.getField(fieldKey)
return this._handleComplianceForBlacklistFilter(item, attribute, field?.optimized ?? field?.value ?? [])
}
/**
* Hides items whose media is already recorded in the download duplicate ledger.
* Membership uses recorded ledger ids (Skip Duplicate does not have to be on).
* Claims run when Skip **or** Hide is on ({@link _shouldClaimDownloadDuplicateLedger}).
*
* @param {SearchEnhancerDownloadLedgerIdCallback} getItemDownloadId Resolves a per-item ledger id from a tile
* @param {string|null} helpText
* @param {string} optionKey
* @protected
*/
_addItemHideDownloadedMediaFilter(getItemDownloadId, helpText = null, optionKey = OPTION_HIDE_DOWNLOADED_MEDIA)
{
this._hideDownloadedGetItemId = getItemDownloadId
let field = this._configurationManager.addFlagField(optionKey).setHelpText(helpText ?? OPTION_HIDE_DOWNLOADED_MEDIA_HELP)
if (optionKey === OPTION_HIDE_DOWNLOADED_MEDIA) {
field.setTitle('Hide Downloaded Media')
}
field.applyDockTemplate('hideDownloaded')
this._addItemComplexComplianceFilter(
optionKey,
(enabled) => enabled && !!this._getDownloadDuplicateLedgerField(),
(item) => {
let id = getItemDownloadId(item)
return id !== null && id !== undefined && this._isDownloadLedgerIdRecorded(String(id).trim())
? {complies: false, rule: 'Already downloaded'}
: true
},
)
}
/**
* @param {ItemTagHighlightsConfiguration} config
* @protected
*/
_addItemTagHighlights(config)
{
this.registerHighlightStyleClass(config.styleClass)
this._configurationManager.addTagRulesetField(config.configKey, true).
setRows(config.rows ?? 5).
setHelpText(config.helpText ?? '').
setFormatter(config.formatter ?? null).
setSortRules(true)
let highlightsHandler = (section) => {
let optimizedRuleset = this._configurationManager.getField(config.configKey).optimized
if (optimizedRuleset) {
let ruleApplies, subjectTags
for (let rule of optimizedRuleset) {
ruleApplies = true
subjectTags = section.find(rule.join(', '))
for (let tagSelector of rule) {
if (section.find(tagSelector).length === 0) {
ruleApplies = false
break
}
}
if (ruleApplies) {
subjectTags.addClass(config.styleClass)
if (config.removeClasses !== undefined) {
subjectTags.removeClass(config.removeClasses)
}
} else {
subjectTags.removeClass(config.styleClass)
}
}
}
}
if (config.otherTagSectionsSelector && config.otherTagSectionsSelector.length > 0) {
this._onBeforeUIBuild.push(() => highlightsHandler(config.otherTagSectionsSelector))
}
this._onItemShow.push((item) => highlightsHandler(item))
}
/**
* @param {string} helpText
* @protected
*/
_addItemTextSanitizationFilter(helpText)
{
this._sanitizationEnabled = true
this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION).
setTitle('Text Sanitization Rules').
setRows(2).
setHelpText(helpText).
setTranslateFromUI((rules) => {
let sanitizationRules = {}, fragments, validatedTargetWords
for (let sanitizationRule of rules) {
if (sanitizationRule.includes('=')) {
fragments = sanitizationRule.split('=')
if (fragments[0] === '') {
fragments[0] = ' '
}
validatedTargetWords = Utilities.trimAndKeepNonEmptyStrings(fragments[1].split(','))
if (validatedTargetWords.length) {
sanitizationRules[fragments[0]] = validatedTargetWords
}
}
}
return sanitizationRules
}).
setFormatForUI((rules) => {
let sanitizationRulesText = []
if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
return sanitizationRulesText
}
for (let substitute in rules) {
sanitizationRulesText.push(substitute + '=' + rules[substitute].join(','))
}
return sanitizationRulesText
}).
setOptimize((rules) => {
let optimizedRules = {}
if (!rules || typeof rules !== 'object' || Array.isArray(rules)) {
return optimizedRules
}
for (const substitute in rules) {
optimizedRules[substitute] = Utilities.buildWholeWordMatchingRegex(rules[substitute])
}
return optimizedRules
})
}
/**
* @param {string|null} helpText
* @protected
*/
_addItemTextSearchFilter(helpText = null)
{
this._configurationManager.addTextField(FILTER_TEXT_SEARCH).
setTitle('Search').
setHelpText(helpText ?? 'Show videos with these comma separated words in their names.')
this._addItemComplianceFilter(FILTER_TEXT_SEARCH, (item, value) => this._get(item, ITEM_NAME).includes(value))
}
/**
* @param {string} helpText
* @protected
*/
_addItemWhitelistFilter(helpText)
{
this._configurationManager.addRulesetField(FILTER_TEXT_WHITELIST).
setTitle('Whitelist').
setRows(5).
setHelpText(helpText).
setOptimize((rules) => Utilities.buildWholeWordMatchingRegex(rules)).
setSortRules(true)
}
/**
* @param {SubscriptionsFilterExclusionsCallback} exclusionsCallback Add page exclusions here
* @param {SubscriptionsFilterUsernameCallback} getItemUsername Return username of the item or return false to skip
* @protected
*/
_addSubscriptionsFilter(exclusionsCallback, getItemUsername)
{
this._configurationManager.addFlagField(FILTER_SUBSCRIBED_VIDEOS).
setTitle('Hide Subscribed Videos').
setHelpText('Hide videos from subscribed channels.')
this._configurationManager.addTextField(STORE_SUBSCRIPTIONS).
setTitle('Account Subscriptions').
setHelpText('Recorded subscription accounts.')
this._addItemComplexComplianceFilter(
FILTER_SUBSCRIBED_VIDEOS,
(value) => value && this._config.isUserLoggedIn && exclusionsCallback(),
(item) => {
let username = getItemUsername(item)
if (username === false) {
return true
}
let subscriptions = this._getConfig(STORE_SUBSCRIPTIONS) ?? ''
if (this._subscriptionsFilterSource !== subscriptions) {
this._subscriptionsFilterSource = subscriptions
this._subscriptionsFilterRegexCache = new Map()
}
let escaped = String(username).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
let regex = this._subscriptionsFilterRegexCache.get(escaped)
if (!regex) {
regex = new RegExp('"([^"]*' + escaped + '[^"]*)"')
this._subscriptionsFilterRegexCache.set(escaped, regex)
}
return !regex.test(subscriptions)
})
}
/**
* @param {JQuery} item
* @protected
*/
_complyItem(item)
{
let itemComplies = true
let doItemCompliance = true
if (this._config.doItemCompliance) {
doItemCompliance = Utilities.callEventHandler(this._config.doItemCompliance)
}
if (doItemCompliance && !this._getConfig(OPTION_DISABLE_COMPLIANCE_VALIDATION) && this._validateItemWhiteList(item)) {
let configField
Utilities.processEventHandlerQueue(this._onBeforeCompliance, [item])
for (let complianceFilter of this._complianceFilters) {
configField = this._configurationManager.getFieldOrFail(complianceFilter.configKey)
if (complianceFilter.validate(configField.optimized ?? configField.value)) {
let complyResult = complianceFilter.comply(item, configField.optimized ?? configField.value)
let {complies, rule} = this._unwrapComplianceResult(complyResult)
itemComplies = complies
this._statistics.record(complianceFilter.configKey, itemComplies)
if (!itemComplies) {
if (this._complianceRules) {
let ruleLabel = rule ?? this._configurationManager.getField(complianceFilter.configKey)?.title ?? complianceFilter.configKey
this._complianceRules.record(complianceFilter.configKey, ruleLabel)
}
break
}
}
}
}
if (itemComplies) {
Utilities.processEventHandlerQueue(this._onItemShow, [item])
} else {
Utilities.callEventHandler(this._onItemHide, [item])
}
item.css('opacity', 'unset')
}
/**
* Filters items as per settings. Primes bounded ledger/tag caches for the tile set first
* so sync filters never need a full-table RAM mirror.
* @param {JQuery} itemsList
* @param {boolean} fromObserver
* @return {Promise<void>}
* @protected
*/
async _complyItemsList(itemsList, fromObserver = false)
{
let items
if (fromObserver) {
items = itemsList.filter(this._config.itemSelectors).add(itemsList.find(this._config.itemSelectors))
} else if (this._config.itemSelectionMethod === 'find') {
items = itemsList.find(this._config.itemSelectors)
} else {
items = itemsList.children(this._config.itemSelectors)
}
// Shallow attribute resolve before priming (tag lists / post ids).
items.each((index, element) => {
let item = $(element)
if (this._get(item, ITEM_PROCESSED_ONCE) === null) {
if (this._sanitizationEnabled) {
Validator.sanitizeTextNode(
item.find(this._config.itemNameSelector),
this._configurationManager.getFieldOrFail(FILTER_TEXT_SANITIZATION).optimized)
}
this._itemAttributesResolver.resolveAttributes(item)
Utilities.processEventHandlerQueue(this._onFirstHitBeforeCompliance, [item])
}
})
// Prime IndexedDB lookups before dimming — otherwise selection overlays flicker for
// the whole await while every tile sits at opacity 0.75.
await this._primeComplianceCaches(items)
items.css('opacity', 0.75)
items.each((index, element) => {
let item = $(element)
this._complyItem(item)
if (!this._get(item, ITEM_PROCESSED_ONCE)) {
Utilities.processEventHandlerQueue(this._onFirstHitAfterCompliance, [item])
this._itemAttributesResolver.set(item, ITEM_PROCESSED_ONCE, true)
}
})
this._statistics.updateUI()
Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
}
/**
* Prefetch ledger membership and tag registry rows needed for this tile set.
* @param {JQuery} items
* @return {Promise<void>}
* @protected
*/
async _primeComplianceCaches(items)
{
if (!this._configurationManager.canPersist() || !items?.length) {
return
}
let ledgerField = this._getDownloadDuplicateLedgerField()
if (ledgerField && (this._getConfig(OPTION_HIDE_DOWNLOADED_MEDIA) || this._shouldClaimDownloadDuplicateLedger())) {
let ids = []
items.each((_, element) => {
let item = $(element)
let id = this._hideDownloadedGetItemId
? Utilities.callEventHandler(this._hideDownloadedGetItemId, [item], null)
: this._getDownloadDuplicateLedgerId(item)
if (id !== null && id !== undefined && String(id).trim()) {
ids.push(String(id).trim())
}
})
if (ids.length) {
await ledgerField.primeHits(ids)
}
}
let tagRuntime = this._configurationManager.getTagRuntime()
if (!tagRuntime || !this._tagListAttributeKeys.length) {
return
}
let tagNameLists = []
items.each((_, element) => {
let item = $(element)
for (let key of this._tagListAttributeKeys) {
let tags = this._get(item, key)
if (Array.isArray(tags) && tags.length) {
tagNameLists.push(tags)
}
}
})
let specs = []
for (let fieldKey of Object.keys(this._configurationManager._tagComplianceSpecs ?? {})) {
let spec = this._configurationManager.getTagComplianceSpec(fieldKey)
if (spec) {
specs.push(spec)
}
}
if (tagNameLists.length || specs.length) {
await tagRuntime.ensureComplianceLookups(tagNameLists, specs)
}
}
/**
* @protected
* @return {JQuery[]}
*/
_createPaginationControls()
{
return [
this._configurationManager.createElement(CONFIG_PAGINATOR_THRESHOLD),
this._configurationManager.createElement(CONFIG_PAGINATOR_LIMIT)]
}
/**
* @protected
* @return {JQuery}
*/
_createSettingsBackupRestoreFormActions()
{
return this._uiGen.createFormActions([
this._uiGen.createFormButton('Backup Configuration', 'Download configuration file.', () => this._onBackupSettings()),
this._uiGen.createSeparator(),
this._uiGen.createFormGroupInput('file').attr('id', 'restore-settings').attr('placeholder', 'Browse for settings file...'),
this._uiGen.createFormButton('Restore Configuration', 'Restore configuration from the selected file.', () => this._onRestoreSettings()),
], 'bv-flex-column')
}
/**
* @protected
* @return {JQuery}
*/
_createSettingsFormActions()
{
return this._uiGen.createFormActions([
this._uiGen.createFormButton('Apply', 'Apply settings.', () => this._onApplyNewSettings()),
this._uiGen.createFormButton('Save', 'Apply and update saved configuration.', () => this._onSaveSettings()),
this._uiGen.createFormButton('Reset', 'Revert to saved configuration.', () => this._onResetSettings()),
])
}
/**
* Include a Donate tab panel (default Patreon: https://www.patreon.com/c/brazen_mvs).
* Pass `Donate` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
*
* @param {{tabName?: string, isFirst?: boolean, patreonUrl?: string, linkLabel?: string, message?: string}} [options]
* @return {JQuery}
*/
createDonateTabPanel(options = {})
{
return this._uiGen.createDonateTabPanel(options)
}
/**
* Behaviours settings tab — Download Manager flags when registered
* (`review-ignored-filename-pins`, `skip-empty-filename-pins`, `only-show-download-manager`).
* Pass `Behaviours` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
*
* @param {{tabName?: string, isFirst?: boolean}} [options]
* @return {JQuery}
*/
createBehavioursTabPanel(options = {})
{
let tabName = options.tabName ?? 'Behaviours'
let pinKeys = [
'review-ignored-filename-pins',
'skip-empty-filename-pins',
]
let elements = []
for (let key of pinKeys) {
if (this._configurationManager.hasField(key)) {
elements.push(this._configurationManager.createElement(key))
}
}
if (this._configurationManager.hasField('only-show-download-manager')) {
if (elements.length) {
elements.push(this._uiGen.createSeparator())
}
elements.push(this._configurationManager.createElement('only-show-download-manager'))
}
return this._uiGen.createTabPanel(tabName, !!options.isFirst).append(elements)
}
/**
* @protected
* @return {JQuery}
*/
_createSubscriptionLoaderControls()
{
return this._subscriptionsLoaderButton
}
/**
* @param {JQuery} UISection
* @private
*/
_embedUI(UISection)
{
if (!this._dockConfig) {
throw new Error('configureDock() is required before UI embed.')
}
this._teardownMainPanelUiListeners()
this._mainPanelUiAbort = new AbortController()
let signal = this._mainPanelUiAbort.signal
let panelNode = UISection?.[0] ?? UISection
const cancelScheduledHide = () => {
if (this._mainPanelHideTimer != null) {
clearTimeout(this._mainPanelHideTimer)
this._mainPanelHideTimer = null
}
}
if (panelNode?.addEventListener) {
panelNode.addEventListener('mouseenter', cancelScheduledHide, {signal})
panelNode.addEventListener('mouseleave', (event) => {
if (!this._uiGen.isSettingsPaneBeingResized()) {
cancelScheduledHide()
this._mainPanelHideTimer = setTimeout(() => {
this._mainPanelHideTimer = null
if (!this._uiGen.isSettingsPaneBeingResized()) {
this._hideMainPanel(event.currentTarget)
}
}, 1000)
}
}, {signal})
}
this._uiGen.constructor.appendToBody(UISection)
this._uiSection = UISection
if (this._complianceRules) {
this._complianceRulesPanel = this._uiGen.createComplianceRulesSlidePanel()
this._wireComplianceRulesSlidePanel(this._complianceRulesPanel)
this._uiGen.constructor.appendToBody(this._complianceRulesPanel)
}
this._buildDock(UISection)
this._ensureFrameworkUnloadTeardown()
}
/**
* @private
*/
_teardownMainPanelUiListeners()
{
if (this._mainPanelHideTimer != null) {
clearTimeout(this._mainPanelHideTimer)
this._mainPanelHideTimer = null
}
this._mainPanelUiAbort?.abort()
this._mainPanelUiAbort = null
}
/**
* Coalesce deep-attribute completions into one after-compliance handler pass.
* @private
*/
_scheduleAfterComplianceRun()
{
if (this._afterComplianceRunTimer != null) {
clearTimeout(this._afterComplianceRunTimer)
}
this._afterComplianceRunTimer = setTimeout(() => {
this._afterComplianceRunTimer = null
this._statistics.updateUI()
Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
}, 100)
}
/**
* Disconnect observers / UI timers on pagehide so SPA remounts and tab discards do not leak.
* @private
*/
_ensureFrameworkUnloadTeardown()
{
if (this._frameworkUnloadWired) {
return
}
this._frameworkUnloadWired = true
window.addEventListener('pagehide', () => {
this._disconnectItemListChildObservers()
this._teardownMainPanelUiListeners()
if (this._afterComplianceRunTimer != null) {
clearTimeout(this._afterComplianceRunTimer)
this._afterComplianceRunTimer = null
}
this._configurationManager?.disposeBookmarkFields?.()
})
}
/**
* @param {JQuery} [railBody]
* @param {{layout?: boolean}} [options] Pass `{ layout: false }` to refresh button state only.
* @return {BrazenFramework}
*/
refreshDockInterface(railBody, options = {})
{
railBody = railBody ?? $('.bv-dock .bv-dock-rail-body').first()
this._configurationManager.setDockIncludeContext(this)
if (options.layout !== false) {
this.composeDockRail(railBody)
this._dockRailMembershipSignature = this._configurationManager.getDockRailMembershipSignature()
}
this._configurationManager.refreshDockButtonStates()
return this
}
/**
* @param {JQuery} railBody
*/
composeDockRail(railBody)
{
throw new Error('composeDockRail(railBody) must be implemented when configureDock() is used.')
}
/**
* @param {JQuery} UISection
* @private
*/
_buildDock(UISection)
{
let orientation = this._getDockOrientation()
this._setDockPanelAnchor(orientation)
let nameBar = this._getDockNameBarText()
let dock = this._uiGen.createDock(orientation, nameBar)
let railHead = dock.find('.bv-dock-rail-head')
let railBody = dock.find('.bv-dock-rail-body')
let railFoot = dock.find('.bv-dock-rail-foot')
let openPanel = this._dockConfig.onOpenMainPanel ?? (() => this._toggleMainPanel(UISection))
railHead.append(this._uiGen.createDockButton({
icon: 'menu',
tooltip: 'Toggle Main UI',
onClick: () => openPanel(),
}).addClass('bv-dock-main-ui-btn'))
railHead.append(this._uiGen.createDockSeparator())
this._configurationManager.setDockIncludeContext(this)
this.composeDockRail(railBody)
this._dockRailMembershipSignature = this._configurationManager.getDockRailMembershipSignature()
if (this._complianceRules && typeof this.isPage === 'function' && this.isPage('search')) {
railFoot.append(this._uiGen.createDockSeparator())
railFoot.append(this._uiGen.createDockButton({
icon: 'rules',
tooltip: 'Active hide rules — click to show',
onClick: () => this._toggleComplianceRulesPanel(),
}).addClass('bv-dock-compliance-rules-btn'))
}
if (this._dockConfig.orientations.length > 1) {
railFoot.append(this._uiGen.createDockSeparator())
railFoot.append(this._uiGen.createDockPositionButton({
orientations: this._dockConfig.orientations,
value: orientation,
onCycle: () => this._cycleDockOrientation(),
}))
}
this._uiGen.constructor.appendToBody(dock)
this._configurationManager.refreshDockButtonStates()
this._observeDockPanelSync(dock[0])
let panel = UISection[0]
let wasVisible = panel && this._isDockSlidePanelVisible(panel)
requestAnimationFrame(() => {
this._syncDockPanelPosition()
if (wasVisible) {
this._showMainPanel(panel)
} else {
this._hideMainPanel(panel, false)
}
})
$(window).off('resize.bvDockPanel').on('resize.bvDockPanel', () => this._syncDockPanelPosition())
}
_cycleDockOrientation()
{
if (!this._dockConfig || this._dockConfig.orientations.length <= 1) {
return
}
if (!this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
return
}
let orientations = this._dockConfig.orientations
let current = this._getDockOrientation()
let index = orientations.indexOf(current)
if (index < 0) {
index = 0
}
this._dockOrientation = orientations[(index + 1) % orientations.length]
void this._configurationManager.writeSetting(OPTION_DOCK_POSITION, this._dockOrientation).then(() => {
this._rebuildDock()
})
}
/**
* @private
*/
_rebuildDock()
{
if (this._dockPanelResizeObserver) {
this._dockPanelResizeObserver.disconnect()
this._dockPanelResizeObserver = null
}
this._dockRailMembershipSignature = null
$('.bv-dock').remove()
if (this._uiSection && this._dockConfig && !this._disableUI) {
this._buildDock(this._uiSection)
}
}
/**
* @return {string}
* @private
*/
_getDockNameBarText()
{
let scriptName = this._dockConfig.scriptName?.trim()
if (!scriptName) {
return DOCK_BRAND_PREFIX.trim()
}
return this._dockConfig.showBranding ? DOCK_BRAND_PREFIX + scriptName : scriptName
}
/**
* @return {string}
* @private
*/
_getDockOrientation()
{
if (this._dockOrientation && this._dockConfig.orientations.includes(this._dockOrientation)) {
return this._dockOrientation
}
return this._dockConfig.defaultOrientation
}
/**
* @return {Promise<void>}
* @private
*/
async _loadDockOrientationFromStorage()
{
if (!this._dockConfig || !this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
return
}
let stored = await this._configurationManager.readSetting(OPTION_DOCK_POSITION)
if (typeof stored === 'string' && this._dockConfig.orientations.includes(stored)) {
this._dockOrientation = stored
}
}
/**
* @return {Promise<void>}
* @private
*/
async _syncDockOrientationFromStorage()
{
if (!this._dockConfig || !this._configurationManager.hasField(OPTION_DOCK_POSITION)) {
return
}
let stored = await this._configurationManager.readSetting(OPTION_DOCK_POSITION)
if (typeof stored !== 'string' || !this._dockConfig.orientations.includes(stored)) {
return
}
if (stored === this._dockOrientation) {
return
}
this._dockOrientation = stored
this._rebuildDock()
}
/**
* @param {string} orientation
* @private
*/
_setDockPanelAnchor(orientation)
{
BrazenViewLayer.setDockSlidePanelOrientations(orientation)
this._syncDockPanelPosition()
}
/**
* @param {JQuery} panel
* @private
*/
_wireComplianceRulesSlidePanel(panel)
{
BrazenViewLayer.wireDockSlidePanel(panel[0], {
onClose: () => this._hideDockSlidePanel(panel[0]),
onMouseLeave: (target) => {
if (this._isDockSlidePanelVisible(target)) {
this._hideDockSlidePanel(target)
}
},
})
}
/**
* Pin dock-anchored slide panels beside the dock (and stack outward when needed).
* Order is near-dock → outward: blocking review panels first, then settings furthest out
* so opening Tag Discovery / human-interaction pushes `#bv-ui` up (bottom) or aside.
* @protected
*/
_syncDockPanelPosition()
{
if (!this._dockConfig || this._disableUI) {
return
}
if (this._dockPanelSyncTimer != null) {
clearTimeout(this._dockPanelSyncTimer)
}
this._dockPanelSyncTimer = setTimeout(() => {
this._dockPanelSyncTimer = null
this._syncDockPanelPositionNow()
}, 16)
}
/**
* Immediate dock panel stack sync (use from layout settle paths that already coalesce).
* @protected
*/
_syncDockPanelPositionNow()
{
if (!this._dockConfig || this._disableUI) {
return
}
let dock = $('.bv-dock').first()[0]
if (!dock) {
return
}
BrazenViewLayer.syncDockSlidePanelStack(dock, this._getDockOrientation(), [
document.getElementById('bv-human-interaction-panel'),
document.getElementById('bv-tag-discovery-panel'),
document.getElementById('bv-compliance-rules'),
document.getElementById('bv-ui'),
].filter(Boolean))
}
/**
* @param {HTMLElement} dock
* @private
*/
_observeDockPanelSync(dock)
{
if (this._dockPanelResizeObserver) {
this._dockPanelResizeObserver.disconnect()
this._dockPanelResizeObserver = null
}
if (!dock) {
return
}
this._dockPanelResizeObserver = new ResizeObserver(() => {
this._syncDockPanelPosition()
})
this._dockPanelResizeObserver.observe(dock)
// Content height/width changes (discovery Similar load, settings tabs) must re-stack.
for (let id of [
'bv-human-interaction-panel',
'bv-tag-discovery-panel',
'bv-compliance-rules',
'bv-ui',
]) {
let panel = document.getElementById(id)
if (panel) {
this._dockPanelResizeObserver.observe(panel)
}
}
}
/**
* @param {HTMLElement} panel
* @return {boolean}
* @private
*/
_isDockSlidePanelVisible(panel)
{
return BrazenViewLayer.isDockSlidePanelVisible(panel)
}
/**
* @param {HTMLElement} panel
* @protected
*/
_showDockSlidePanel(panel)
{
let orientation = this._getDockOrientation()
BrazenViewLayer.showDockSlidePanel(panel, orientation, () => this._syncDockPanelPositionNow())
this._syncDockPanelPositionNow()
// Post-layout pass: first sync can run mid open (offscreen) or before height settles.
// Do not re-attach ResizeObserver on every open — observe once from dock build.
requestAnimationFrame(() => {
this._syncDockPanelPositionNow()
})
this._updateDockSlidePanelButtonStates()
}
/**
* @param {HTMLElement} panel
* @param {boolean} [animate]
* @protected
*/
_hideDockSlidePanel(panel, animate = true)
{
BrazenViewLayer.hideDockSlidePanel(panel, this._getDockOrientation(), animate, () => {
this._syncDockPanelPosition()
})
this._updateDockSlidePanelButtonStates()
}
/**
* @param {HTMLElement|JQuery} UISection
* @protected
*/
_toggleMainPanel(UISection)
{
let panel = UISection?.[0] ?? UISection ?? document.getElementById('bv-ui')
if (!panel) {
return
}
if (this._isDockSlidePanelVisible(panel)) {
this._hideDockSlidePanel(panel)
return
}
this._showDockSlidePanel(panel)
}
/**
* @param {HTMLElement} panel
* @protected
*/
_showMainPanel(panel)
{
this._showDockSlidePanel(panel)
}
/**
* @param {HTMLElement} panel
* @param {boolean} [animate]
* @protected
*/
_hideMainPanel(panel, animate = true)
{
this._hideDockSlidePanel(panel, animate)
}
/**
* @protected
*/
_toggleComplianceRulesPanel()
{
let panel = document.getElementById('bv-compliance-rules')
if (!panel) {
return
}
if (this._isDockSlidePanelVisible(panel)) {
this._hideDockSlidePanel(panel)
return
}
this._renderComplianceRulesPanelContent()
this._showDockSlidePanel(panel)
}
/**
* @private
*/
_updateDockSlidePanelButtonStates()
{
BrazenViewLayer.updateDockSlidePanelToggleButtons()
}
/**
* @protected
*/
_refreshDockButtonStates()
{
let railBody = $('.bv-dock .bv-dock-rail-body').first()
this._configurationManager.setDockIncludeContext(this)
let membership = this._configurationManager.getDockRailMembershipSignature()
// Full composeDockRail clears every slot (CSS slide-outs thrash open/closed). Only
// rebuild when which root buttons belong on the rail actually changed.
let needsLayout = membership !== this._dockRailMembershipSignature
this.refreshDockInterface(railBody, {layout: needsLayout})
this._syncDockPanelPosition()
}
/**
* @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean}} event
* @private
*/
_scheduleConfigurationChange(event)
{
if (event?.source === 'ledger') {
this._ledgerComplianceDirty = true
}
let pending = this._pendingConfigurationChange
if (pending && pending.source !== event.source && event.source !== 'all') {
// Preserve ledger dirty when coalescing ledger + queue/state into `all`.
if (pending.source === 'ledger' || event?.source === 'ledger') {
this._ledgerComplianceDirty = true
}
event = {...event, source: 'all'}
}
this._pendingConfigurationChange = event
if (this._configurationChangeScheduled) {
return
}
this._configurationChangeScheduled = true
queueMicrotask(() => {
this._configurationChangeScheduled = false
let scheduledEvent = this._pendingConfigurationChange
this._pendingConfigurationChange = null
if (scheduledEvent) {
void this._handleConfigurationChange(scheduledEvent)
}
})
}
/**
* @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean}} event
* @return {Promise<void>}
* @private
*/
async _handleConfigurationChange(event)
{
let manager = event.manager ?? this._configurationManager
let source = event.source ?? 'all'
let local = event.local !== false
// Queue/ledger/runtime writes must not wipe unsaved settings controls.
let runtimePipeline = source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
source === 'downloadManagerState'
// Reload settings controls from persisted cache only after Apply/Save/reset (local
// settings|all) or a foreign cross-tab config revision. Local tag/bookmark attribute
// writes must not discard unsaved textarea edits.
let reloadsSettingsControls =
(local && (source === 'settings' || source === 'all')) ||
(!local && (source === 'all' || source === 'settings' || source === 'tags' || source === 'bookmarks'))
if (reloadsSettingsControls) {
manager.refreshMountedFields()
void this._syncDockOrientationFromStorage()
}
if (reloadsSettingsControls || source === 'bookmarks') {
await manager.reloadBookmarkFields()
}
// Download processors put queue/state rows continuously — do not rebuild the dock on every put.
if (!runtimePipeline) {
this._refreshDockButtonStates()
}
if (reloadsSettingsControls || source === 'tags') {
this._validateCompliance()
}
// Ledger claims fire once per download — do not re-dim the whole search page each time.
// Debounce a Hide Downloaded refresh so newly recorded ids can hide without selection flicker.
// `_ledgerComplianceDirty` survives coalesce with downloadQueue / downloadManagerState puts
// (including when the merged event source becomes `all`).
if (source === 'ledger' || this._ledgerComplianceDirty) {
this._ledgerComplianceDirty = false
this._scheduleLedgerComplianceRefresh()
}
if (!runtimePipeline) {
Utilities.processEventHandlerQueue(this._onConfigurationChange, [event])
}
}
/**
* Soft compliance refresh after ledger claims (Hide Downloaded only).
* @private
*/
_scheduleLedgerComplianceRefresh()
{
if (!this._getConfig(OPTION_HIDE_DOWNLOADED_MEDIA) || !this._shouldRunCompliance()) {
return
}
if (this._ledgerComplianceRefreshTimer) {
clearTimeout(this._ledgerComplianceRefreshTimer)
}
this._ledgerComplianceRefreshTimer = setTimeout(() => {
this._ledgerComplianceRefreshTimer = null
// Ledger-only: re-comply visible lists without resetting all statistics.
void this._runComplianceValidation(false)
}, 400)
}
/**
* @param {JQuery} item
* @param {string} attributeName
* @returns {*}
* @protected
*/
_get(item, attributeName)
{
return this._itemAttributesResolver.get(item, attributeName)
}
/**
* @param {string} config
* @returns {*}
* @protected
*/
_getConfig(config)
{
return this._configurationManager.getValue(config)
}
/**
* @param {*} result
* @return {{complies: boolean, rule: string|null}}
* @protected
*/
_unwrapComplianceResult(result)
{
if (result !== null && typeof result === 'object' && 'complies' in result) {
return {complies: !!result.complies, rule: result.rule ?? null}
}
return {complies: !!result, rule: null}
}
/**
* @return {{filterKey: string, filterLabel: string, rules: {label: string, count: number}[]}[]}
* @protected
*/
_getComplianceRuleReport()
{
if (!this._complianceRules) {
return []
}
return this._complianceRules.getReport((filterKey) => this._configurationManager.getField(filterKey)?.title ?? filterKey)
}
/**
* @protected
*/
_renderComplianceRulesPanelContent()
{
if (!this._complianceRulesPanel?.length) {
return
}
this._uiGen.renderComplianceRulesPanelContent(this._complianceRulesPanel, this._getComplianceRuleReport(), {
canRemoveRule: (filterKey, ruleLabel) => this._canRemoveComplianceRule(filterKey, ruleLabel),
onRemoveRule: (filterKey, ruleLabel) => {
this._removeComplianceRule(filterKey, ruleLabel)
this._renderComplianceRulesPanelContent()
},
})
}
/**
* @protected
*/
_showComplianceRulesModal()
{
this._toggleComplianceRulesPanel()
}
/**
* @param {string} filterKey
* @param {string} ruleLabel
* @return {boolean}
* @protected
*/
_canRemoveComplianceRule(filterKey, ruleLabel)
{
return false
}
/**
* @param {string} filterKey
* @param {string} ruleLabel
* @protected
*/
_removeComplianceRule(filterKey, ruleLabel)
{
}
_handleComplianceForBlacklistFilter(item, attribute, blacklistRuleset)
{
let itemTags = this._get(item, attribute)
if (itemTags === null || !itemTags.length) {
return true
}
if (blacklistRuleset && !Array.isArray(blacklistRuleset)) {
return true
}
for (let rule of blacklistRuleset) {
if (typeof rule === 'string') {
if (itemTags.includes(rule)) {
return {complies: false, rule: rule}
}
continue
}
let isBlacklisted = true
for (let tag of rule) {
if (!itemTags.includes(tag)) {
isBlacklisted = false
break
}
}
if (isBlacklisted) {
return {complies: false, rule: rule.join(' & ')}
}
}
return true
}
/**
* @private
*/
_onApplyNewSettings()
{
this._configurationManager.update()
this._validateCompliance()
this.refreshDockInterface($('.bv-dock .bv-dock-rail-body').first())
}
/**
* @private
*/
_onBackupSettings()
{
if (this._configurationManager.isIdbBlocked()) {
alert('IndexedDB is unavailable — backup is disabled.')
return
}
void this._configurationManager.backup()
}
/**
* @private
*/
_onResetSettings()
{
void this._configurationManager.revertChanges()
}
/**
* @private
*/
_onRestoreSettings()
{
void this._configurationManager.restore(new Response($('#restore-settings').prop('files')[0]))
}
/**
* @protected
*/
_showIdbBlockedBanner()
{
BrazenViewLayer.ensureIdbBlockedBanner()
}
/**
* @private
*/
async _onSaveSettings()
{
if (this._configurationManager.isIdbBlocked()) {
alert('IndexedDB is unavailable — settings cannot be saved.')
return
}
try {
await this._configurationManager.save()
} catch (error) {
console.log('[BrazenFramework] save settings failed:', error)
alert('Settings could not be saved. See the browser console for details.')
}
}
/**
* @param {string} configKey
* @param {function(*)} actionCallback
* @param {function(*, function?): boolean} validationCallback
* @returns {BrazenFramework}
* @protected
*/
_performComplexOperation(configKey, validationCallback, actionCallback)
{
return this._performOperation(configKey, actionCallback, validationCallback)
}
/**
* @param {string} configKey
* @param {function(*)} actionCallback
* @param {function(*, function?): boolean|null} validationCallback
* @returns {BrazenFramework}
* @protected
*/
_performOperation(configKey, actionCallback, validationCallback = null)
{
let configField = this._configurationManager.getField(configKey)
let defaultValidationCallback = this._configurationManager.generateValidationCallback(configKey)
let validationCallbackParams
let values = configField.optimized ?? configField.value
if (validationCallback) {
validationCallbackParams = [values, defaultValidationCallback]
} else {
validationCallbackParams = [values]
validationCallback = defaultValidationCallback
}
if (Utilities.callEventHandler(validationCallback, validationCallbackParams, true)) {
actionCallback(values)
}
return this
}
/**
* @param {string} flagConfigKey
* @param {string|null} configKey
* @param {function(*)} actionCallback
* @param {function(*, function?): boolean} validationCallback
* @returns {BrazenFramework}
* @protected
*/
_performTogglableComplexOperation(flagConfigKey, configKey, validationCallback, actionCallback)
{
if (this._getConfig(flagConfigKey)) {
this._performComplexOperation(configKey ?? flagConfigKey, validationCallback, actionCallback)
}
return this
}
/**
* @param {string} flagConfigKey
* @param {string} configKey
* @param {function(*)} actionCallback
* @param {function(*, function?): boolean|null} validationCallback
* @returns {BrazenFramework}
* @protected
*/
_performTogglableOperation(flagConfigKey, configKey, actionCallback, validationCallback = null)
{
if (this._configurationManager.getValue(flagConfigKey)) {
this._performOperation(configKey, actionCallback, validationCallback)
}
return this
}
/**
* @param {boolean} enableCondition
* @param {PaginatorConfiguration} configuration
* @protected
*/
_setupPaginator(enableCondition, configuration)
{
if (enableCondition) {
configuration.itemSelectors = this._config.itemSelectors
this._paginator = new BrazenPaginator(configuration)
}
this._configurationManager.addNumberField(CONFIG_PAGINATOR_LIMIT, 1, 50).
setTitle('Pagination Limit').
setHelpText('Limit paginator to concatenate the specified number of maximum pages.')
this._configurationManager.addNumberField(CONFIG_PAGINATOR_THRESHOLD, 1, 1000).
setTitle('Pagination Threshold').
setHelpText('Make paginator ensure the specified number of minimum results.')
}
/**
* @return {BrazenSubscriptionsLoader}
* @protected
*/
_setupSubscriptionLoader()
{
this._subscriptionsLoader = new BrazenSubscriptionsLoader(
(status) => this._subscriptionsLoaderButton.text(status),
(subscriptions) => {
this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' +
subscriptions.join('""') + '"' : ''
this._configurationManager.save()
$('#subscriptions-loader').prop('disabled', false)
})
this._subscriptionsLoaderButton = this._uiGen.createFormButton(
'Load Subscriptions',
'Makes a copy of your subscriptions in cache for related filters.',
(event) => {
if (this._config.isUserLoggedIn) {
$(event.currentTarget).prop('disabled', true)
this._subscriptionsLoader.run()
} else {
this._showNotLoggedInAlert()
}
}).attr('id', 'subscriptions-loader')
return this._subscriptionsLoader
}
/**
* @protected
*/
_showNotLoggedInAlert()
{
alert('You need to be logged in to use this functionality')
}
/**
* @param {boolean} firstRun
* @protected
*/
_validateCompliance(firstRun = false)
{
if (!this._shouldRunCompliance()) {
return
}
void this._prepareTagComplianceRuntime().then(() => this._runComplianceValidation(firstRun))
}
/**
* @return {Promise<void>}
* @protected
*/
async _prepareTagComplianceRuntime()
{
if (this._configurationManager.canPersist()) {
await this._configurationManager.prepareTagComplianceRuntime()
}
}
/**
* @param {boolean} firstRun
* @return {Promise<void>}
* @protected
*/
async _runComplianceValidation(firstRun = false)
{
if (!this._shouldRunCompliance()) {
return
}
let itemLists = $(this._config.itemListSelectors)
if (firstRun) {
this._disconnectItemListChildObservers()
let listNodes = itemLists.toArray()
for (let itemList of listNodes) {
let itemListObject = $(itemList)
if (this._paginator && itemListObject.is(this._paginator.getItemListSelector())) {
let observer = ChildObserver.create().onNodesAdded((itemsAdded) => {
void this._complyItemsList($(itemsAdded), true).then(() => {
this._paginator.run(this._getConfig(CONFIG_PAGINATOR_THRESHOLD), this._getConfig(CONFIG_PAGINATOR_LIMIT))
})
}).observe(itemList)
this._itemListChildObservers.push(observer)
} else {
let observer = ChildObserver.create().onNodesAdded((itemsAdded) => {
void this._complyItemsList($(itemsAdded), true)
}).observe(itemList)
this._itemListChildObservers.push(observer)
}
await this._complyItemsList(itemListObject)
}
} else {
this._statistics.reset()
if (this._complianceRules) {
this._complianceRules.reset()
}
let listNodes = itemLists.toArray()
for (let itemsList of listNodes) {
await this._complyItemsList($(itemsList))
}
}
if (this._paginator) {
this._paginator.run(this._getConfig(CONFIG_PAGINATOR_THRESHOLD), this._getConfig(CONFIG_PAGINATOR_LIMIT))
}
this._itemAttributesResolver.completeResolutionRun()
let compliancePanel = document.getElementById('bv-compliance-rules')
if (compliancePanel && this._isDockSlidePanelVisible(compliancePanel)) {
this._renderComplianceRulesPanelContent()
}
}
/**
* @param {JQuery} item
* @return {boolean}
* @protected
*/
_validateItemWhiteList(item)
{
let field = this._configurationManager.getField(FILTER_TEXT_WHITELIST)
if (field) {
let validationResult = field.value.length
? Validator.regexMatches(this._get(item, ITEM_NAME), field.optimized)
: true
this._statistics.record(FILTER_TEXT_WHITELIST, validationResult)
return validationResult
}
return true
}
/**
* Whether the download duplicate ledger is recording / skipping (Skip Duplicate Downloads).
* @return {boolean}
* @protected
*/
_isDownloadDuplicateLedgerActive()
{
if (!this._downloadDuplicateLedgerConfig) {
return false
}
return !!this._getConfig(this._downloadDuplicateLedgerConfig.enableConfigKey)
}
/**
* True when ledger claims should run — Skip Duplicate and/or Hide Downloaded Media.
* Hide needs claims in the ledger even when the user has not opted into skip-on-download.
* @return {boolean}
* @protected
*/
_shouldClaimDownloadDuplicateLedger()
{
if (!this._downloadDuplicateLedgerConfig) {
return false
}
return this._isDownloadDuplicateLedgerActive() || !!this._getConfig(OPTION_HIDE_DOWNLOADED_MEDIA)
}
/**
* Sync membership for compliance filters — uses the bounded positive cache only
* (primed via {@link _primeComplianceCaches} / claims). Does not hit IndexedDB.
*
* @param {string|null|undefined} downloadId
* @return {boolean}
* @protected
*/
_isDownloadLedgerIdRecorded(downloadId)
{
let ledgerField = this._getDownloadDuplicateLedgerField()
if (!ledgerField || downloadId === null || downloadId === undefined) {
return false
}
let id = String(downloadId).trim()
return !!id && ledgerField.downloadedIds.has(id)
}
/**
* Async membership for download pipeline / skip-duplicate (IndexedDB when not cached).
*
* @param {string|null|undefined} downloadId
* @return {Promise<boolean>}
* @protected
*/
async _isDownloadLedgerIdRecordedAsync(downloadId)
{
let ledgerField = this._getDownloadDuplicateLedgerField()
if (!ledgerField || downloadId === null || downloadId === undefined) {
return false
}
let id = String(downloadId).trim()
if (!id) {
return false
}
return ledgerField.has(id)
}
/**
* @param {*} item
* @return {string|null}
* @protected
*/
_getDownloadDuplicateLedgerId(item)
{
if (!this._shouldClaimDownloadDuplicateLedger()) {
return null
}
let id = Utilities.callEventHandler(this._downloadDuplicateLedgerConfig.getDownloadId, [item], null)
if (id === null || id === undefined || !String(id).length) {
return null
}
return String(id)
}
/**
* Invalidates the bounded ledger positive cache (does not load the ledger into RAM).
*
* @return {Promise<void>}
* @protected
*/
async _reloadDownloadDuplicateLedgerFromStorage()
{
await this._getDownloadDuplicateLedgerField()?.reload()
}
/**
* @param {string|null|undefined} downloadId
* @return {boolean}
* @protected
*/
_isDownloadDuplicate(downloadId)
{
if (!this._isDownloadDuplicateLedgerActive()) {
return false
}
return this._isDownloadLedgerIdRecorded(downloadId)
}
/**
* @param {string|null|undefined} downloadId
* @return {Promise<boolean>}
* @protected
*/
async _isDownloadDuplicateAsync(downloadId)
{
if (!this._isDownloadDuplicateLedgerActive()) {
return false
}
return this._isDownloadLedgerIdRecordedAsync(downloadId)
}
/**
* Reserves a download before `GM_download` runs. Tampermonkey often does not fire
* `onload` in default download mode, so waiting for success would allow repeat attempts.
*
* @param {string|null|undefined|Array<string|null|undefined>} downloadId
* @return {Promise<boolean>}
* @protected
*/
async _claimDownloadDuplicateLedgerSlot(downloadId)
{
let ledgerField = this._getDownloadDuplicateLedgerField()
if (!ledgerField || !this._shouldClaimDownloadDuplicateLedger()) {
return true
}
let claimed = Array.isArray(downloadId)
? await ledgerField.claim(downloadId)
: await ledgerField.claim(downloadId === null || downloadId === undefined ? [] : [downloadId])
if (claimed) {
// Do not depend on config-event ordering with hot downloadQueue puts.
this._scheduleLedgerComplianceRefresh()
}
return claimed
}
/**
* @param {{name?: string|null}} download
* @protected
*/
_handleDuplicateDownloadSkipped(download)
{
}
/**
* Fired when `GM_download` reports success (`onload`). Tampermonkey often skips this
* in default download mode — apps must not rely on it for ledger claims.
*
* @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
* @protected
*/
_handleDownloadSucceeded(download)
{
}
/**
* @param {{name?: string|null}} download
* @param {*} error
* @protected
*/
_handleDownloadFailed(download, error)
{
console.log('Download error:', error?.error, error?.details)
}
/**
* Disconnects compliance list observers retained from the first validation run.
* @protected
*/
_disconnectItemListChildObservers()
{
for (let observer of this._itemListChildObservers) {
observer.disconnect()
}
this._itemListChildObservers = []
}
/**
* @param {string|string[]} names
* @param {Function} setup
* @return {BrazenFramework}
* @protected
*/
_forPage(names, setup)
{
return this._forPages(Array.isArray(names) ? names : [names], setup)
}
/**
* @param {string[]} names
* @param {Function} setup
* @return {BrazenFramework}
* @protected
*/
_forPages(names, setup)
{
if (names.some((name) => this._activePages.has(name))) {
Utilities.callEventHandler(setup)
}
return this
}
/**
* @param {string|string[]} names
* @param {Function} callback
* @return {Function}
* @protected
*/
_gatePage(names, callback)
{
let pageNames = Array.isArray(names) ? names : [names]
return (...args) => {
if (pageNames.some((name) => this._activePages.has(name))) {
return Utilities.callEventHandler(callback, args)
}
}
}
/**
* @param {string|string[]} names
* @param {string} configKey
* @param {function(*)} actionCallback
* @param {function(*, function?): boolean|null} validationCallback
* @return {BrazenFramework}
* @protected
*/
_performOperationOnPage(names, configKey, actionCallback, validationCallback = null)
{
return this._forPage(names, () => this._performOperation(configKey, actionCallback, validationCallback))
}
/**
* @param {string|string[]} names
* @param {string} flagConfigKey
* @param {string} configKey
* @param {function(*)} actionCallback
* @param {function(*, function?): boolean|null} validationCallback
* @return {BrazenFramework}
* @protected
*/
_performTogglableOperationOnPage(names, flagConfigKey, configKey, actionCallback, validationCallback = null)
{
return this._forPage(names, () => this._performTogglableOperation(flagConfigKey, configKey, actionCallback, validationCallback))
}
/**
* Phase 0: evaluate page detectors and resolve layout-aware selectors.
* @protected
*/
_initPageDetection()
{
this._activePages.clear()
this._layouts.clear()
for (let [name, page] of this._pages) {
if (Utilities.callEventHandler(page.detect)) {
this._activePages.add(name)
if (page.layout) {
this._layouts.set(name, Utilities.callEventHandler(page.layout))
}
}
}
this._resolveEffectiveSelectors()
}
/**
* @param {SelectorConfig} value
* @return {JQuery.Selector}
* @protected
*/
_resolveSelectorValue(value)
{
if (value === null || value === undefined) {
return value
}
if (typeof value === 'function') {
return Utilities.callEventHandler(value)
}
if (typeof value === 'string') {
return value
}
if (typeof value === 'object') {
for (let pageName of this._activePages) {
if (pageName in value) {
return value[pageName]
}
let layout = this._layouts.get(pageName)
if (layout && layout in value) {
return value[layout]
}
}
if ('default' in value) {
return value.default
}
}
return value
}
/**
* Resolves selector config values once after page/layout detection.
* @protected
*/
_resolveEffectiveSelectors()
{
this._config.itemListSelectors = this._resolveSelectorValue(this._config.itemListSelectors)
this._config.itemSelectors = this._resolveSelectorValue(this._config.itemSelectors)
this._config.itemNameSelector = this._resolveSelectorValue(this._config.itemNameSelector)
if (this._config.itemLinkSelector !== undefined) {
this._config.itemLinkSelector = this._resolveSelectorValue(this._config.itemLinkSelector)
}
}
/**
* Phase 1: run config-independent page operations for active pages.
* @return {boolean} `true` when an operation halted the boot pipeline.
* @protected
*/
_runPageOperations()
{
for (let {pages, operation} of this._pageOperations) {
if (pages.some((name) => this._activePages.has(name))) {
let result = Utilities.callEventHandler(operation)
if (result === true || (result !== null && typeof result === 'object' && result.haltInit === true)) {
return true
}
}
}
return false
}
/**
* @return {boolean}
* @protected
*/
_shouldRunFullInit()
{
if (this._pages.size === 0) {
return Utilities.callEventHandler(this._onValidateInit)
}
let initPages = this._initPages ?? [...this._pages.keys()]
let hasInitPage = initPages.some((name) => this._activePages.has(name))
return hasInitPage && Utilities.callEventHandler(this._onValidateInit)
}
/**
* @return {boolean}
* @protected
*/
_shouldRunCompliance()
{
if (!this._complianceEnabled) {
return false
}
if (this._pages.size === 0) {
return true
}
if (this._compliancePages === null) {
return this._activePages.size > 0
}
return this._compliancePages.some((name) => this._activePages.has(name))
}
// -------------------------------------------------------------------------
// Public class methods
// -------------------------------------------------------------------------
/**
* Initialize the script and do basic UI removals
*/
async init()
{
this._initPageDetection()
if (this._runPageOperations()) {
return
}
if (!this._shouldRunFullInit()) {
return
}
Utilities.processEventHandlerQueue(this._onBeforeFullInit)
await this._configurationManager.initialize()
if (this._configurationManager.isIdbBlocked()) {
this._showIdbBlockedBanner()
}
await this._configurationManager.reloadBookmarkFields()
Utilities.processEventHandlerQueue(this._onBookmarksHydrate)
await this._configurationManager.ensureTagRuleSetsCompiled()
if (this._downloadManager) {
await this._downloadManager.initialize()
}
await this._loadDockOrientationFromStorage()
this._itemAttributesResolver.addAttribute(ITEM_PROCESSED_ONCE, () => false)
if (this._config.itemNameSelector !== '') {
this._itemAttributesResolver.addAttribute(ITEM_NAME, (item) => item.find(this._config.itemNameSelector).text())
}
if (this._paginator) {
this._paginator.initialize()
}
Utilities.processEventHandlerQueue(this._onBeforeUIBuild)
if (!this._disableUI) {
this._embedUI(this._uiGen.createSettingsSection().append(this._userInterface))
Utilities.processEventHandlerQueue(this._onAfterUIBuild)
this._configurationManager.updateInterface()
}
// After the dock exists — early DM init must not flash/hide the review panel.
if (this._downloadManager) {
await this._downloadManager.restoreTagDiscoveryPanelIfNeeded()
}
this._validateCompliance(true)
Utilities.processEventHandlerQueue(this._onAfterInitialization)
}
/**
* @param {string} name
* @param {Function|PageDefinition} detectorOrConfig
* @return {BrazenFramework}
*/
definePage(name, detectorOrConfig)
{
if (typeof detectorOrConfig === 'function') {
this._pages.set(name, {detect: detectorOrConfig})
} else {
this._pages.set(name, detectorOrConfig)
}
return this
}
/**
* @param {{[name: string]: Function|PageDefinition}} pages
* @return {BrazenFramework}
*/
definePages(pages)
{
for (let name in pages) {
this.definePage(name, pages[name])
}
return this
}
/**
* @param {string} name
* @return {boolean}
*/
isPage(name)
{
return this._activePages.has(name)
}
/**
* @param {...string} names
* @return {boolean}
*/
anyPage(...names)
{
return names.some((name) => this._activePages.has(name))
}
/**
* @return {string[]}
*/
getActivePages()
{
return [...this._activePages]
}
/**
* @param {string} [name]
* @return {string|null}
*/
getLayout(name)
{
if (name !== undefined) {
return this._layouts.get(name) ?? null
}
for (let pageName of this._activePages) {
if (this._layouts.has(pageName)) {
return this._layouts.get(pageName) ?? null
}
}
return null
}
/**
* @param {Function} handler
* @return {BrazenFramework}
*/
onBookmarksHydrate(handler)
{
this._onBookmarksHydrate.push(handler)
return this
}
/**
* @param {function({manager: BrazenConfigurationManager, source: string, local: boolean})} handler
* @return {BrazenFramework}
*/
onConfigurationChange(handler)
{
this._onConfigurationChange.push(handler)
return this
}
/**
* Fired when substitution link-mode UI state changes (including cancel with no persist).
* @param {Function} handler
* @return {BrazenFramework}
*/
onTagSubstitutionUiChange(handler)
{
this._onTagSubstitutionUiChange.push(handler)
return this
}
/**
* @param {string|null} [fieldKey='filename-tag-substitutions']
* @return {boolean}
*/
hasTagSubstitutionField(fieldKey = 'filename-tag-substitutions')
{
return !!this._configurationManager?.hasField(fieldKey)
}
/**
* @param {string} tagName
* @param {{normalize?: function(string): string, fieldKey?: string}} [options]
* @return {'idle'|'linkingSource'|'isSubject'|'linkingTarget'}
*/
getTagSubstitutionUiState(tagName, options = {})
{
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let fieldKey = options.fieldKey ?? 'filename-tag-substitutions'
let normalized = normalize(tagName)
let source = this._tagSubstitutionLinkSource
if (source != null) {
return normalize(source) === normalized ? 'linkingSource' : 'linkingTarget'
}
if (this._configurationManager.hasTagSubstitutionSubject(normalized, fieldKey)) {
return 'isSubject'
}
return 'idle'
}
/**
* Advance or apply the substitution link-mode flow for a tag.
* @param {string} tagName
* @param {{normalize?: function(string): string, fieldKey?: string, tag?: {name: string, type?: string|null}, source?: string}} [options]
* @return {Promise<void>}
*/
async toggleTagSubstitutionLink(tagName, options = {})
{
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let fieldKey = options.fieldKey ?? 'filename-tag-substitutions'
let state = this.getTagSubstitutionUiState(tagName, {normalize, fieldKey})
let normalized = normalize(tagName)
let tag = options.tag ?? {name: tagName}
switch (state) {
case 'idle':
this._tagSubstitutionLinkSource = normalized
this._tagSubstitutionLinkSourceType = tag.type ?? null
this._notifyTagSubstitutionUiChange()
break
case 'linkingSource':
this._tagSubstitutionLinkSource = null
this._tagSubstitutionLinkSourceType = null
this._notifyTagSubstitutionUiChange()
break
case 'isSubject': {
this._tagSubstitutionLinkSource = null
this._tagSubstitutionLinkSourceType = null
// clearTagSubstitution paints optimistically then reconciles IDB.
let clearPersist = this._configurationManager.clearTagSubstitution(normalized, fieldKey)
this._notifyTagSubstitutionUiChange()
await clearPersist
break
}
case 'linkingTarget': {
let source = this._tagSubstitutionLinkSource
let sourceType = this._tagSubstitutionLinkSourceType
this._tagSubstitutionLinkSource = null
this._tagSubstitutionLinkSourceType = null
// End of link flow: optimistic cache + UI first; ensure/type promote inside set.
let setPersist = source ?
this._configurationManager.setTagSubstitution(source, normalized, fieldKey, {
subjectTypeName: sourceType,
replacementTypeName: tag.type ?? null,
source: options.source ?? 'tag-action',
}) :
Promise.resolve(true)
this._notifyTagSubstitutionUiChange()
await setPersist
break
}
}
}
/**
* @param {string} tagName
* @param {{normalize?: function(string): string, fieldKey?: string, tag?: {name: string, type?: string|null}, className?: string, iconClass?: string, onAfterToggle?: function(): void}} [options]
* @return {JQuery}
*/
createTagSubstitutionActionButton(tagName, options = {})
{
let normalize = options.normalize
let fieldKey = options.fieldKey
return BrazenViewLayer.createTagSubstitutionActionButton({
state: this.getTagSubstitutionUiState(tagName, {normalize, fieldKey}),
className: options.className,
iconClass: options.iconClass,
onClick: () => {
// Link-mode steps notify sync; commit/clear paint optimistically before IDB settles.
let pending = this.toggleTagSubstitutionLink(tagName, {
normalize,
fieldKey,
tag: options.tag ?? {name: tagName},
})
options.onAfterToggle?.()
void pending.catch(() => options.onAfterToggle?.())
},
})
}
/**
* @private
*/
_notifyTagSubstitutionUiChange()
{
Utilities.processEventHandlerQueue(this._onTagSubstitutionUiChange, [])
}
/**
* @param {string} optionKey
* @protected
*/
_enableConfigOption(optionKey)
{
if (!optionKey || !this._configurationManager?.hasField(optionKey)) {
return
}
if (this._configurationManager.canPersist()) {
if (!this._configurationManager.getValue(optionKey)) {
void this._configurationManager.writeSetting(optionKey, true)
}
return
}
let enableField = this._configurationManager.getField(optionKey)
if (enableField && !enableField.value) {
enableField.value = true
if (enableField.element) {
enableField.updateUserInterface()
}
}
}
/**
* @param {{name: string, type?: string|null}|string} tag
* @param {function(string): string} normalize
* @param {string} [source='tag-action']
* @return {Promise<void>}
* @private
*/
async _ensureTagEntityForAction(tag, normalize, source = 'tag-action')
{
let name = typeof tag === 'string' ? tag : tag?.name
if (!this._configurationManager.canPersist() || !name) {
return
}
let normalized = normalize(name)
if (!normalized) {
return
}
let typeName = typeof tag === 'object' ? (tag.type ?? null) : null
let cached = this._configurationManager.getCachedTagEntry(normalized)
// Skip resolve/put when the row already has a confirmed type (or no type to promote).
// Still promote when discovery/sidebar left only lastSeenTypeEntryId.
if (cached && (cached.typeEntryId != null || !typeName)) {
return
}
await this._configurationManager.getTagRuntime()?.ensureTag(normalized,
this._configurationManager.createTagContext({typeName, source}))
}
/**
* Toggle a TAG_FIELD_SPEC sole attribute (blacklist / explore / ignore).
* @param {string} tagName
* @param {{fieldKey: string, normalize?: function(string): string, ensureOptionKey?: string, tag?: {name: string, type?: string|null}, source?: string}} options
* @return {Promise<boolean>}
*/
async toggleTagSoleAttribute(tagName, options = {})
{
let fieldKey = options.fieldKey
if (!fieldKey || !this._configurationManager.hasField(fieldKey)) {
return false
}
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let tag = options.tag ?? {name: tagName}
let normalized = normalize(tagName)
if (!normalized) {
return false
}
let persistKey = `${fieldKey}\0${normalized}`
if (this._tagAttributePersistKeys.has(persistKey)) {
return false
}
this._tagAttributePersistKeys.add(persistKey)
try {
if (options.ensureOptionKey) {
this._enableConfigOption(options.ensureOptionKey)
}
// Type promote happens inside patchAttributes/ensureTag — do not block the optimistic paint.
let context = this._configurationManager.createTagContext({
typeName: tag.type ?? null,
source: options.source ?? 'tag-action',
})
if (this._configurationManager.canPersist()) {
await this._configurationManager.toggleTagRule(fieldKey, normalized, context)
if (fieldKey === FILTER_TAG_BLACKLIST || fieldKey === 'explored-tags-tracker') {
await this._configurationManager.ensureTagRuleSetsCompiled()
}
return true
}
let field = this._configurationManager.getField(fieldKey)
field?.toggleRule?.(normalized, normalized)
await this._configurationManager.save()
return true
} finally {
this._tagAttributePersistKeys.delete(persistKey)
}
}
/**
* @param {string} tagName
* @param {{fieldKey: string, normalize?: function(string): string}} options
* @return {boolean}
*/
hasTagSoleAttributeActive(tagName, options = {})
{
let fieldKey = options.fieldKey
if (!fieldKey) {
return false
}
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
return this._configurationManager.hasTagSoleAttribute(fieldKey, normalize(tagName))
}
/**
* @param {string} tagName
* @param {{
* kind: 'ignore'|'blacklist'|'explore',
* fieldKey: string,
* normalize?: function(string): string,
* ensureOptionKey?: string,
* tag?: {name: string, type?: string|null},
* className?: string,
* iconClass?: string,
* onAfterToggle?: function(): void,
* }} options
* @return {JQuery}
*/
createTagSoleAttributeActionButton(tagName, options = {})
{
let kind = options.kind
let normalize = options.normalize
let fieldKey = options.fieldKey
let active = this.hasTagSoleAttributeActive(tagName, {fieldKey, normalize})
let titles = {
ignore: active ? 'Stop ignoring in filenames' : 'Ignore in filenames',
blacklist: active ? 'Stop hiding posts with this tag' : 'Hide posts with the tag',
explore: active ? 'Remove tag from explore list' : 'Add tag to explore list',
}
let iconClass = options.iconClass ?? 'bv-tag-action-icon'
let icon = kind === 'ignore' ? BrazenViewLayer.createTagIgnoreIcon(active, iconClass) :
kind === 'explore' ? BrazenViewLayer.createTagExploreIcon(active, iconClass) :
BrazenViewLayer.createTagBlacklistIcon(active, iconClass)
let pending = false
return BrazenViewLayer.createTagAttributeActionButton({
title: titles[kind] ?? '',
icon,
className: options.className ?? 'bv-tag-action-btn',
onClick: () => {
if (pending) {
return
}
pending = true
// toggleTagRule paints cache + notifies before the first await; refresh chrome now.
let persist = this.toggleTagSoleAttribute(tagName, {
fieldKey,
normalize,
ensureOptionKey: options.ensureOptionKey,
tag: options.tag ?? {name: tagName},
})
options.onAfterToggle?.()
void persist.catch(() => options.onAfterToggle?.()).finally(() => {
pending = false
})
},
})
}
/**
* @param {string} tagName
* @param {{
* buildUrl: function(string): string,
* formatLabel?: function(string): string,
* normalizeUrl?: function(string): string,
* fieldKey?: string,
* normalize?: function(string): string,
* }} options
* @return {boolean}
*/
isTagSearchBookmarked(tagName, options = {})
{
let fieldKey = options.fieldKey ?? 'bookmarks'
let field = this._configurationManager.getField(fieldKey)
if (!field?.widget || !options.buildUrl) {
return false
}
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let url = options.buildUrl(normalize(tagName))
return !!field.widget.isCurrentPageBookmarked(url)
}
/**
* Toggle bookmark for a single-tag search URL supplied by the consumer.
* @param {string} tagName
* @param {{
* buildUrl: function(string): string,
* formatLabel?: function(string): string,
* normalizeUrl?: function(string): string,
* fieldKey?: string,
* normalize?: function(string): string,
* }} options
* @return {Promise<void>}
*/
async toggleTagSearchBookmark(tagName, options = {})
{
let fieldKey = options.fieldKey ?? 'bookmarks'
let field = this._configurationManager.getField(fieldKey)
if (!field || !options.buildUrl) {
return
}
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
let formatLabel = options.formatLabel ?? ((tags) => String(tags).replaceAll('_', ' '))
let tags = normalize(tagName)
let url = options.buildUrl(tags)
let persistKey = `${fieldKey}\0url:${normalizeUrl(url)}`
if (this._tagAttributePersistKeys.has(persistKey)) {
return
}
this._tagAttributePersistKeys.add(persistKey)
let rows = Array.isArray(field._bookmarkRows) ? field._bookmarkRows :
(Array.isArray(field.value) ? field.value : [])
let bookmarked = field.widget?.isCurrentPageBookmarked(url) ||
rows.some((entry) => normalizeUrl(entry.url) === normalizeUrl(url))
let previousRows = rows.slice()
try {
if (bookmarked) {
let bookmark = rows.find((entry) => normalizeUrl(entry.url) === normalizeUrl(url))
if (!bookmark) {
return
}
let entryId = bookmark.entryId ?? bookmark.id
// Optimistic remove for star / isCurrentPageBookmarked readers.
let nextRows = rows.filter((entry) => (entry.entryId ?? entry.id) !== entryId)
field._bookmarkRows = nextRows
if (!this._configurationManager.isStorageReady()) {
field.value = nextRows
}
field.widget?.render(nextRows)
if (this._configurationManager.isStorageReady()) {
await this._configurationManager.getRepos().bookmarks.remove(Number(entryId))
await field.reload?.()
field.widget?.render(field._bookmarkRows ?? [])
return
}
await field.save()
field.widget?.render(field.value)
return
}
if (rows.some((bookmark) => normalizeUrl(bookmark.url) === normalizeUrl(url))) {
return
}
let optimistic = {
entryId: null,
id: typeof crypto !== 'undefined' && crypto.randomUUID ?
crypto.randomUUID() :
String(Date.now()) + Math.random(),
label: formatLabel(tags),
tags,
url,
sortOrder: 0,
_optimistic: true,
}
let nextRows = [optimistic, ...rows]
field._bookmarkRows = nextRows
if (!this._configurationManager.isStorageReady()) {
field.value = nextRows
}
field.widget?.render(nextRows)
if (this._configurationManager.isStorageReady()) {
await this._configurationManager.getRepos().bookmarks.add({
label: formatLabel(tags),
tags,
url,
sortOrder: 0,
})
await field.reload?.()
field.widget?.render(field._bookmarkRows ?? [])
return
}
await field.save()
field.widget?.render(field.value)
} catch (error) {
field._bookmarkRows = previousRows
if (!this._configurationManager.isStorageReady()) {
field.value = previousRows
}
field.widget?.render(previousRows)
throw error
} finally {
this._tagAttributePersistKeys.delete(persistKey)
}
}
/**
* @param {string} tagName
* @param {{
* buildUrl: function(string): string,
* formatLabel?: function(string): string,
* normalizeUrl?: function(string): string,
* fieldKey?: string,
* normalize?: function(string): string,
* className?: string,
* iconClass?: string,
* onAfterToggle?: function(): void,
* }} options
* @return {JQuery|null}
*/
createTagBookmarkActionButton(tagName, options = {})
{
let fieldKey = options.fieldKey ?? 'bookmarks'
if (!this._configurationManager.hasField(fieldKey) || !options.buildUrl) {
return null
}
let bookmarked = this.isTagSearchBookmarked(tagName, options)
return BrazenViewLayer.createTagAttributeActionButton({
title: bookmarked ? 'Remove bookmark' : 'Bookmark',
icon: BrazenViewLayer.createTagBookmarkStarIcon(bookmarked, options.iconClass ?? 'bv-tag-action-icon'),
className: options.className ?? 'bv-tag-action-btn',
onClick: () => {
// Optimistic row mutate + widget.render run sync before the first await.
let persist = this.toggleTagSearchBookmark(tagName, options)
options.onAfterToggle?.()
void persist.catch(() => options.onAfterToggle?.())
},
})
}
/**
* Append framework-owned tag attribute actions (bookmark, ignore, substitute, blacklist, explore).
* Consumer supplies incidence options (`buildUrl`, field keys, CSS classes, feature gates).
* @param {HTMLElement|JQuery} actionsElement
* @param {{name: string, type?: string|null}} tag
* @param {{
* className?: string,
* iconClass?: string,
* ignoreClassName?: string,
* normalize?: function(string): string,
* bookmark?: {buildUrl: function(string): string, formatLabel?: function(string): string, normalizeUrl?: function(string): string, fieldKey?: string, iconClass?: string}|false,
* ignore?: {fieldKey?: string}|false,
* substitute?: {fieldKey?: string}|false,
* blacklist?: {fieldKey?: string, ensureOptionKey?: string}|false,
* explore?: {fieldKey?: string, ensureOptionKey?: string, isEnabled?: function(): boolean}|false,
* onAfterToggle?: function(): void,
* }} [config]
*/
appendTagAttributeActions(actionsElement, tag, config = {})
{
let $actions = $(actionsElement)
if (!$actions.length || !tag?.name) {
return
}
let normalize = config.normalize ?? ((value) => String(value ?? '').trim())
let className = config.className ?? 'bv-tag-action-btn'
let iconClass = config.iconClass ?? 'bv-tag-action-icon'
let after = config.onAfterToggle
let buttons = []
if (config.bookmark !== false && config.bookmark?.buildUrl) {
let button = this.createTagBookmarkActionButton(tag.name, {
...config.bookmark,
normalize,
className,
iconClass: config.bookmark.iconClass ?? iconClass,
onAfterToggle: after,
})
if (button) {
buttons.push(button)
}
}
let substituted = this._configurationManager.hasTagSubstitutionSubject(
normalize(tag.name), config.substitute?.fieldKey ?? 'filename-tag-substitutions')
if (config.ignore !== false) {
let ignoreKey = config.ignore?.fieldKey ?? 'filename-tag-ignore-list'
if (this._configurationManager.hasField(ignoreKey) && !substituted) {
buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
kind: 'ignore',
fieldKey: ignoreKey,
normalize,
tag,
className: config.ignoreClassName ?? className,
iconClass,
onAfterToggle: after,
}))
}
}
if (config.substitute !== false) {
let subKey = config.substitute?.fieldKey ?? 'filename-tag-substitutions'
if (this.hasTagSubstitutionField(subKey)) {
buttons.push(this.createTagSubstitutionActionButton(tag.name, {
fieldKey: subKey,
normalize,
tag,
className,
iconClass,
onAfterToggle: after,
}))
}
}
if (config.blacklist !== false) {
let blacklistKey = config.blacklist?.fieldKey ?? FILTER_TAG_BLACKLIST
if (this._configurationManager.hasField(blacklistKey)) {
buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
kind: 'blacklist',
fieldKey: blacklistKey,
ensureOptionKey: config.blacklist?.ensureOptionKey,
normalize,
tag,
className,
iconClass,
onAfterToggle: after,
}))
}
}
if (config.explore !== false) {
let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
let exploreEnabled = config.explore?.isEnabled ? config.explore.isEnabled() : this._configurationManager.hasField(exploreKey)
if (exploreEnabled && this._configurationManager.hasField(exploreKey)) {
buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
kind: 'explore',
fieldKey: exploreKey,
ensureOptionKey: config.explore?.ensureOptionKey,
normalize,
tag,
className,
iconClass,
onAfterToggle: after,
}))
}
}
$actions.append(buttons)
}
/**
* @param {string|string[]} names
* @param {Function} operation Return truthy or `{haltInit: true}` to skip full init.
* @return {BrazenFramework}
*/
addPageOperation(names, operation)
{
let pageNames = Array.isArray(names) ? names : [names]
this._pageOperations.push({pages: pageNames, operation})
return this
}
/**
* @return {BrazenFramework}
*/
enableCompliance()
{
this._complianceEnabled = true
return this
}
/**
* @return {BrazenFramework}
*/
disableCompliance()
{
this._complianceEnabled = false
return this
}
/**
* @return {boolean}
*/
isComplianceEnabled()
{
return this._complianceEnabled
}
/**
* @param {string[]} names Compliance runs only when one of these pages is active.
* @return {BrazenFramework}
*/
setCompliancePages(names)
{
this._compliancePages = names
return this
}
/**
* @returns {boolean}
*/
isUserLoggedIn()
{
return this._config.isUserLoggedIn
}
registerHighlightStyleClass(styleClass)
{
this._highlightClasses += ' ' + styleClass
return this
}
/**
* @param {{
* orientations: string[],
* defaultOrientation?: string,
* scriptName?: string,
* showBranding?: boolean,
* onOpenMainPanel?: Function,
* }} config
* @return {BrazenFramework}
*/
configureDock(config)
{
let validOrientations = ['left', 'right', 'bottom']
let orientations = (config.orientations ?? []).filter((orientation) => validOrientations.includes(orientation))
if (!orientations.length) {
throw new Error('configureDock() requires at least one valid orientation: left, right, or bottom.')
}
let defaultOrientation = config.defaultOrientation ?? orientations[0]
if (!orientations.includes(defaultOrientation)) {
defaultOrientation = orientations[0]
}
this._dockConfig = {
orientations,
defaultOrientation,
scriptName: config.scriptName ?? '',
showBranding: config.showBranding === true,
onOpenMainPanel: config.onOpenMainPanel,
}
this._dockOrientation = defaultOrientation
this._configurationManager.setDockActive(true)
this._configurationManager.setDockIncludeContext(this)
// Dock flag clicks already persist via writeSetting(); do not call save() here —
// save()'s update() re-reads stale settings-panel checkboxes and can revert the toggle,
// which remounts CSS slide-outs open/closed repeatedly.
if (orientations.length > 1) {
this._configurationManager.addHeadlessSettingField(OPTION_DOCK_POSITION, defaultOrientation)
}
return this
}
/**
* @return {BrazenDownloadManager|null}
*/
getDownloadManager()
{
return this._downloadManager
}
/**
* @param {object} config
* @return {BrazenFramework}
*/
configureDownloadManager(config)
{
if (!this._dockConfig) {
throw new Error('configureDownloadManager() requires configureDock() to be called first.')
}
this._downloadManager = new BrazenDownloadManager(this, this._configurationManager, config)
return this
}
/**
* Phase-1 Cloudflare / challenge page: queue HI tabs show **Done — resume** on this
* media page; standalone browsing shows **Done — reload**. Requires {@link configureDownloadManager}.
* @param {{title?: string, message?: string, confirmLabel?: string}} [options]
* @return {Promise<void>}
*/
handleMediaCloudflarePage(options = {})
{
return this._downloadManager?.handleMediaCloudflarePage(options) ?? Promise.resolve()
}
/**
* @param {{mediaElement: HTMLElement, removeMediaOnSuccess?: boolean, source?: string}} options
* @return {Promise<void>}
*/
downloadImmediate(options)
{
return this._downloadManager?.downloadImmediate(options) ?? Promise.resolve()
}
/**
* @param {object} context
* @return {Promise<boolean>}
*/
enqueueDownload(context)
{
return this._downloadManager?.enqueueDownload(context) ?? Promise.resolve(false)
}
/**
* @param {string} itemId
* @return {Promise<void>}
*/
dequeueDownload(itemId)
{
return this._downloadManager?.dequeueDownload(itemId) ?? Promise.resolve()
}
/**
* @param {string} itemId
* @return {Promise<boolean>}
*/
isQueued(itemId)
{
return this._downloadManager?.isQueued(itemId) ?? Promise.resolve(false)
}
/**
* @return {Promise<void>}
*/
confirmTagDiscoveryMappings()
{
return this._downloadManager?.confirmTagDiscoveryMappings() ?? Promise.resolve()
}
/**
* @return {Promise<void>}
*/
skipTagDiscoveryInclusion()
{
return this._downloadManager?.skipTagDiscoveryInclusion() ?? Promise.resolve()
}
/**
* @return {Promise<void>}
*/
openTagDiscoveryMedia()
{
return this._downloadManager?.openTagDiscoveryMedia() ?? Promise.resolve()
}
/**
* @return {Promise<void>}
*/
toggleDownloadManagerPaused()
{
return this._downloadManager?.toggleDownloadManagerPaused() ?? Promise.resolve()
}
/**
* @return {Promise<void>}
*/
clearDownloadQueue()
{
return this._downloadManager?.clearDownloadQueue() ?? Promise.resolve()
}
/**
* Wipe all download-ledger ids from IndexedDB and the in-memory Set.
*
* @return {Promise<void>}
*/
clearDownloadDuplicateLedger()
{
return this._configurationManager.clearDownloadLedger()
}
/**
* Wipe this script's entire IndexedDB database. Reloads afterward so setup can rebuild it.
* Prefer calling from a Toolbox confirm button.
*
* @return {Promise<void>}
*/
async clearScriptDatabase()
{
let wipeFlagKey = this._config.scriptPrefix + 'pending-idb-wipe'
try {
sessionStorage.setItem(wipeFlagKey, '1')
} catch (e) {
}
try {
await this._configurationManager.clearScriptDatabase()
try {
sessionStorage.removeItem(wipeFlagKey)
} catch (e) {
}
} catch (error) {
// Keep the flag so initialize() retries the wipe before open on the next load.
console.warn('[Brazen] clearScriptDatabase will retry on reload:', error)
}
location.reload()
}
/**
* @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
*/
getDownloadManagerProgress()
{
return this._downloadManager?.getDownloadManagerProgress() ?? Promise.resolve({
resolution: {current: 0, total: 0},
download: {current: 0, total: 0},
})
}
/**
* @return {Promise<number>}
*/
getDownloadQueueCount()
{
return this._downloadManager?.getDownloadQueueCount() ?? Promise.resolve(0)
}
/**
* @return {Promise<void>}
*/
toggleTagDiscoveryMode()
{
return this._downloadManager?.toggleTagDiscoveryMode() ?? Promise.resolve()
}
/**
* @return {void}
*/
toggleSelectionMode()
{
this._downloadManager?.toggleSelectionMode()
}
/**
* @return {Promise<void>}
*/
toggleCurrentMediaQueued()
{
return this._downloadManager?.toggleCurrentMediaQueued() ?? Promise.resolve()
}
/**
* @param {string} role
* @return {boolean}
*/
isDownloadPageRole(role)
{
return this._downloadManager?.isDownloadPageRole(role) ?? false
}
/**
* @return {boolean}
*/
isDownloadManagerEnabled()
{
return this._downloadManager?.isDownloadManagerEnabled() ?? false
}
/**
* @return {boolean}
*/
isDownloadManagerLeaderTab()
{
return this._downloadManager?.isDownloadManagerLeaderTab() ?? false
}
}