Main class of the Brazen user scripts framework
بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.sleazyfork.org/scripts/416105/1875149/Brazen%20Framework%20-%20Framework.js
// ==UserScript==
// @name Brazen Framework - Framework
// @namespace brazenvoid
// @version 11.0.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Main class of the Brazen user scripts framework
// ==/UserScript==
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_ALWAYS_SHOW_SETTINGS_PANE = 'always-show-settings-pane'
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. Only works while Skip Duplicate Downloads is enabled.'
/** Default cap for optional download duplicate ledger entries in `GM_setValue`. */
const DEFAULT_DOWNLOAD_DUPLICATE_LEDGER_MAX_ENTRIES = 25000
/**
* `GM_download` conflict policy when `downloadDuplicateLedger` is enabled on a site script.
* Never `'uniquify'` — ledger-enabled scripts dictate filenames.
*
* @type {'overwrite'}
*/
const DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION = 'overwrite'
class BrazenFramework
{
/**
* @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter
*/
/**
* @typedef {{storageKey?: string, maxEntries?: number, 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 {boolean}
* @private
*/
_downloadsProcessing = false
/**
* @type {Promise<{name: string|null, url: string}>[]}
* @private
*/
_downloadsQueue = []
/**
* @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
/**
* Re-syncs bottom-dock panels when the dock layout changes size.
* @type {ResizeObserver|null}
* @private
*/
_dockPanelResizeObserver = null
/**
* @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 = []
/**
* 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)
Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
},
})
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.onExternalConfigurationChange(() => this._validateCompliance())
this._configurationManager.addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
setTitle('Disable All Filters').
setHelpText('Disables all search filters.')
this._configurationManager.addFlagField(OPTION_ALWAYS_SHOW_SETTINGS_PANE).
setTitle('Always Show Settings Pane').
setHelpText('Always show configuration interface.')
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 {Promise<void>}
* @private
*/
async _startProcessingDownloadQueue()
{
if (this._downloadsProcessing) {
return
}
this._downloadsProcessing = true
while (this._downloadsQueue.length > 0) {
await this._downloadsQueue.shift()
await Utilities.sleep(this._config.downloadsDelay)
}
this._downloadsProcessing = false
}
/**
* @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) {
this._configurationManager.getField(enableConfigKey).value = true
}
this._configurationManager.addLedgerField('download-ledger').
setTitle('Download Ledger').
setStorageKey(ledgerConfig.storageKey ?? this._config.scriptPrefix + 'download-ledger').
setMaxEntries(ledgerConfig.maxEntries ?? DEFAULT_DOWNLOAD_DUPLICATE_LEDGER_MAX_ENTRIES).
setPrimaryField(ledgerConfig.primaryField ?? 'ids').
setLegacyIdFields(ledgerConfig.legacyIdFields ?? []).
setLedgerLegacyStorageKeys(ledgerConfig.legacyStorageKeys ?? []).
setIsValidId(ledgerConfig.isValidId ?? ((value) => typeof value === 'string' && value.trim().length > 0)).
setStorage(STORAGE_DRIVER_GM).
reload()
this._downloadDuplicateLedgerFieldKey = 'download-ledger'
this._downloadDuplicateLedgerConfig = {
enableConfigKey,
getDownloadId: ledgerConfig.getDownloadId,
}
window.addEventListener('pagehide', () => this._flushDownloadDuplicateLedgerOnPageHide(), {capture: true})
}
/**
* @private
*/
_flushDownloadDuplicateLedgerOnPageHide()
{
this._getDownloadDuplicateLedgerField()?.flushPending()
}
/**
* @param {{name: string|null, element: JQuery|null, url: string, downloadId?: string|null}} download
* @return {Promise<void>}
* @private
*/
_wrapDownloadTask(download)
{
let path = download.name
if (!path) {
alert('Download failed: missing file path.')
return Promise.resolve()
}
let conflictAction = this._isDownloadDuplicateLedgerActive() ? DOWNLOAD_DUPLICATE_LEDGER_CONFLICT_ACTION : 'uniquify'
return new Promise((resolve) => {
download.element?.remove()
GM_download({
url: download.url,
name: path,
conflictAction,
onload: () => resolve(),
onerror: (error) => {
this._handleDownloadFailed(download, error)
resolve()
},
})
})
}
// -------------------------------------------------------------------------
// Protected class methods
// -------------------------------------------------------------------------
/**
* Removes a picture from the page and clears its src so an in-flight fetch can abort.
*
* @param {HTMLImageElement|HTMLVideoElement} mediaElement
* @protected
*/
_removeDownloadMediaElement(mediaElement)
{
if (!(mediaElement instanceof HTMLImageElement)) {
return
}
mediaElement.removeAttribute('srcset')
mediaElement.removeAttribute('sizes')
mediaElement.src = ''
mediaElement.remove()
}
/**
* Decodes HTML entities (e.g. `&` → `&`) from DOM-derived strings.
*
* @param {string} text
* @return {string}
* @protected
*/
_decodeHtmlEntities(text)
{
if (typeof text !== 'string' || text.length === 0) {
return ''
}
if (!text.includes('&')) {
return text
}
let textarea = document.createElement('textarea')
textarea.innerHTML = text
return textarea.value
}
/**
* Sanitizes a single path segment, dropping characters illegal in file names
* (including `/`, so a segment cannot introduce extra nesting).
*
* @param {string} segment
* @return {string}
* @protected
*/
_sanitizePathSegment(segment)
{
return this._decodeHtmlEntities(String(segment)).
replace(/[<>:"/\\|?*]/g, '-').
replace(/[.\s]+$/, '').
trim()
}
/**
* Builds a sanitized, possibly nested, relative download path.
*
* @param {string} folder
* @param {string} name
* @return {string}
* @protected
*/
_buildDownloadPath(folder, name)
{
let folderPath = String(folder).split('/').
map((segment) => this._sanitizePathSegment(segment)).
filter((segment) => segment.length).
join('/').
substring(0, 120).
replace(/[.\s/]+$/, '')
let fileName = this._sanitizePathSegment(name) || 'media'
return folderPath ? folderPath + '/' + fileName : fileName
}
/**
* 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 = {})
{
let pollMs = options.pollMs ?? 150
let timeoutMs = options.timeoutMs ?? 30000
let observeRoot = options.observeRoot ?? document.documentElement
let resolved = false
let observer = null
let poll = null
let timeout = null
let cleanup = () => {
observer?.disconnect()
if (poll) {
clearInterval(poll)
}
if (timeout) {
clearTimeout(timeout)
}
}
let attempt = () => {
if (resolved) {
return
}
let element = document.querySelector(selector)
if (!element || !predicate(element)) {
return
}
resolved = true
cleanup()
callback(element)
}
attempt()
if (resolved) {
return
}
observer = new MutationObserver(attempt)
observer.observe(observeRoot, {subtree: true, childList: true, attributes: true, attributeFilter: ['src']})
poll = setInterval(attempt, pollMs)
timeout = setTimeout(cleanup, timeoutMs)
}
/**
* @param {string} tag
* @param {function(string): string} normalizeToken
* @return {string}
* @protected
*/
_normalizeDownloadTagRuleToken(tag, normalizeToken)
{
return normalizeToken(tag)
}
/**
* @param {string[]|{subject: string, replacement: string}[]} lines
* @param {function(string): string} normalizeToken
* @return {{subject: string, replacement: string}[]}
* @protected
*/
_parseDownloadTagSubstitutionLines(lines, normalizeToken)
{
let rules = []
for (let line of lines) {
if (typeof line === 'object' && line?.subject && line?.replacement) {
let subject = this._normalizeDownloadTagRuleToken(line.subject, normalizeToken)
let replacement = this._normalizeDownloadTagRuleToken(line.replacement, normalizeToken)
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
continue
}
if (typeof line !== 'string') {
continue
}
let match = line.match(/^(.+?)\s+-\s+(.+)$/)
if (!match) {
continue
}
let subject = this._normalizeDownloadTagRuleToken(match[1], normalizeToken)
let replacement = this._normalizeDownloadTagRuleToken(match[2], normalizeToken)
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
}
return rules
}
/**
* @param {{subject: string, replacement: string}[]} rules
* @return {Map<string, string>}
* @protected
*/
_buildDownloadTagSubstitutionMap(rules)
{
let map = new Map()
for (let rule of rules) {
map.set(rule.subject, rule.replacement)
}
return map
}
/**
* @param {string} tag
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string}
* @protected
*/
_resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries)
{
if (substitutions.has(tag)) {
return substitutions.get(tag)
}
if (type === 'character' && stripCharacterSeries) {
let stripped = tag.replace(/_\([^)]*\)$/, '')
if (stripped !== tag && substitutions.has(stripped)) {
return substitutions.get(stripped)
}
}
return tag
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string[]}
* @protected
*/
_applyDownloadTagSubstitutions(tags, type, substitutions, stripCharacterSeries)
{
if (!substitutions.size) {
return tags
}
return tags.map((tag) => this._resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries))
}
/**
* @param {string} tag
* @param {string|null} type
* @param {boolean} stripCharacterSeries
* @return {string}
* @protected
*/
_formatTagForDownloadPath(tag, type, stripCharacterSeries)
{
let formatted = this._decodeHtmlEntities(tag)
if (type === 'character' && stripCharacterSeries) {
formatted = formatted.replace(/_\([^)]*\)$/, '')
}
return formatted.replaceAll('_', ' ')
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Set<string>|null} ignore
* @param {{substitutions: Map<string, string>, stripCharacterSeries: boolean, multiTagSeparator: string}} options
* @return {string}
* @protected
*/
_joinTagsForDownloadPath(tags, type, ignore, options)
{
tags = this._applyDownloadTagSubstitutions(tags, type, options.substitutions, options.stripCharacterSeries)
if (ignore?.size) {
tags = tags.filter((tag) => !ignore.has(tag))
}
let seen = new Set()
let unique = []
for (let tag of tags) {
let formatted = this._formatTagForDownloadPath(tag, type, options.stripCharacterSeries)
if (!formatted || seen.has(formatted)) {
continue
}
seen.add(formatted)
unique.push(formatted)
}
return unique.
sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'})).
join(options.multiTagSeparator)
}
/**
* @param {string} pattern
* @param {string} type
* @param {{token: string, label: string}[]} chips
* @param {Set<string>} unknownTypes
* @return {boolean}
* @protected
*/
_patternEmploysDownloadTagTypeToken(pattern, type, chips, unknownTypes)
{
if (!unknownTypes.has(type)) {
return false
}
for (let chip of chips) {
if (chip.token === type && pattern.includes(chip.label)) {
return true
}
}
return false
}
/**
* @param {string} pattern
* @param {string[]} tags
* @param {string} type
* @param {Set<string>} ignore
* @param {{chips: {token: string, label: string}[], unknownTypes: Set<string>, unknownDefault: string, substitutions: Map<string, string>, stripCharacterSeries: boolean, multiTagSeparator: string}} resolver
* @return {string}
* @protected
*/
_resolveDownloadTagTypeTokenForPath(pattern, tags, type, ignore, resolver)
{
let value = this._joinTagsForDownloadPath(tags, type, ignore, resolver)
if (!value && this._patternEmploysDownloadTagTypeToken(pattern, type, resolver.chips, resolver.unknownTypes)) {
return resolver.unknownDefault
}
return value
}
/**
* @param {string} pattern
* @param {{}} data
* @param {{}} tagGroups
* @param {{chips: {token: string, label: string}[], tagTypes: string[], unknownTypes: Set<string>, unknownDefault: string, ignore: Set<string>, substitutions: Map<string, string>, stripCharacterSeries: boolean, multiTagSeparator: string}} resolver
* @return {string}
* @protected
*/
_resolveDownloadPattern(pattern, data, tagGroups, resolver)
{
let resolved = pattern
let chips = [...resolver.chips].sort((a, b) => b.label.length - a.label.length)
for (let chip of chips) {
let value
if (resolver.tagTypes.includes(chip.token)) {
value = this._resolveDownloadTagTypeTokenForPath(pattern, tagGroups[chip.token] ?? [], chip.token, resolver.ignore, resolver)
} else {
value = data[chip.token] ?? ''
}
resolved = resolved.replaceAll(chip.label, value)
}
return resolved.replace(/\s+/g, ' ').trim()
}
/**
* @param {HTMLImageElement|HTMLVideoElement} mediaElement
* @param {string} folder
* @param {string} name
* @param {boolean} removeMediaOnSuccess
* @protected
*/
_addDownload(mediaElement, folder, name, removeMediaOnSuccess = false)
{
const path = this._removeIllegalCharactersFromPath(folder).substring(0, 120).trim().replace(/[.\s]+$/, '') + '/' + this._removeIllegalCharactersFromPath(name)
let url = mediaElement.src ?? ''
let downloadElement = null
let removedPicture = false
if (removeMediaOnSuccess && mediaElement instanceof HTMLImageElement) {
this._removeDownloadMediaElement(mediaElement)
removedPicture = true
} else if (removeMediaOnSuccess) {
downloadElement = $(mediaElement)
}
let downloadId = this._getDownloadDuplicateLedgerId(mediaElement)
if (this._isDownloadDuplicateLedgerActive() && !this._claimDownloadDuplicateLedgerSlot(downloadId)) {
this._handleDuplicateDownloadSkipped({name: path})
return
}
let task = this._wrapDownloadTask({
name: path,
element: downloadElement,
url,
downloadId,
})
if (mediaElement instanceof HTMLImageElement && !mediaElement.complete && !removedPicture) {
mediaElement.addEventListener('load', () => this._downloadsQueue.push(task))
} else {
this._downloadsQueue.push(task)
}
// noinspection JSIgnoredPromiseFromCall
this._startProcessingDownloadQueue()
}
/**
* @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)
{
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
* @protected
*/
_addItemTagBlacklistFilter(attribute, useSelectors, rows = 5, helpText = null, key = null, optionKey = 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 rulesetField = this._configurationManager.addTagRulesetField(key, useSelectors).
setTitle(key === FILTER_TAG_BLACKLIST ? 'Tag Blacklist' : key).
setRows(rows).
setHelpText(helpText).
setSortRules(true)
this._addItemComplexComplianceFilter(
key,
(rules) => this._getConfig(optionKey) && rules.length,
(item, blacklistRuleset) => this._handleComplianceForBlacklistFilter(item, attribute, blacklistRuleset),
)
}
/**
* Hides items whose media is already recorded in the download duplicate ledger. Gated on the
* ledger being active, so the filter is inert (no stat hit) while {@link OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER}
* is off.
*
* @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)
{
let field = this._configurationManager.addFlagField(optionKey).setHelpText(helpText ?? OPTION_HIDE_DOWNLOADED_MEDIA_HELP)
if (optionKey === OPTION_HIDE_DOWNLOADED_MEDIA) {
field.setTitle('Hide Downloaded Media')
}
this._addItemComplexComplianceFilter(
optionKey,
(enabled) => enabled && this._isDownloadDuplicateLedgerActive(),
(item) => {
let id = getItemDownloadId(item)
return id !== null && id !== undefined && this._isDownloadDuplicate(id)
? {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 = []
for (let substitute in rules) {
sanitizationRulesText.push(substitute + '=' + rules[substitute].join(','))
}
return sanitizationRulesText
}).
setOptimize((rules) => {
let optimizedRules = {}
for (const substitute in rules) {
optimizedRules[substitute] = Utilities.buildWholeWordMatchingRegex(rules[substitute])
}
return optimizedRules
}).
setSortRules(true)
}
/**
* @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
}
return !(new RegExp('"([^"]*' + username + '[^"]*)"')).test(this._getConfig(STORE_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
* @param {JQuery} itemsList
* @param {boolean} fromObserver
* @protected
*/
_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)
}
items.css('opacity', 0.75).each((index, element) => {
let item = $(element)
// First run processing
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])
}
// Compliance filtering
this._complyItem(item)
// Processing of search items on later runs
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)
}
/**
* @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()),
])
}
/**
* @protected
* @return {JQuery}
*/
_createSubscriptionLoaderControls()
{
return this._subscriptionsLoaderButton
}
/**
* @param {JQuery} UISection
* @private
*/
_embedUI(UISection)
{
let hideTimer = null
const cancelScheduledHide = () => {
clearTimeout(hideTimer)
hideTimer = null
}
UISection.on('mouseenter', cancelScheduledHide)
UISection.on('mouseleave', (event) => {
if (!this._uiGen.isSettingsPaneBeingResized() && !this._getConfig(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) {
cancelScheduledHide()
hideTimer = setTimeout(() => {
hideTimer = null
if (!this._uiGen.isSettingsPaneBeingResized() && !this._getConfig(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) {
if (this._dockConfig && !this._disableUI) {
this._hideMainPanel(event.currentTarget)
} else {
$(event.currentTarget).slideUp(300)
}
}
}, 1000)
}
})
if (this._getConfig(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) {
UISection.show()
}
this._uiGen.constructor.appendToBody(UISection)
this._uiSection = UISection
if (this._complianceRules && this._dockConfig && !this._disableUI) {
this._complianceRulesPanel = this._uiGen.createComplianceRulesSlidePanel()
this._wireComplianceRulesSlidePanel(this._complianceRulesPanel)
this._uiGen.constructor.appendToBody(this._complianceRulesPanel)
}
if (this._dockConfig && !this._disableUI) {
this._buildDock(UISection)
} else {
this._uiGen.constructor.appendToBody(this._uiGen.createSettingsShowButton('', UISection))
}
}
/**
* @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)
for (let field of this._configurationManager.getDockRootFields()) {
let node = this._configurationManager.createDockElement(field.key)
if (node) {
railBody.append(node)
}
}
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.updateDockInterface(railBody)
this._observeDockPanelSync(dock[0])
this._configurationManager.onMigrationStatusChange((message) => {
this._uiGen.setDockMigrationStatus($('.bv-dock').first(), message)
})
void this._runDeferredStorageMigration()
let panel = UISection[0]
let wasVisible = panel && this._isDockSlidePanelVisible(panel)
requestAnimationFrame(() => {
this._syncDockPanelPosition()
if (wasVisible || this._getConfig(OPTION_ALWAYS_SHOW_SETTINGS_PANE)) {
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
}
let field = this._configurationManager.getField(OPTION_DOCK_POSITION)
if (!field) {
return
}
let orientations = this._dockConfig.orientations
let index = orientations.indexOf(field.value)
if (index < 0) {
index = 0
}
field.value = orientations[(index + 1) % orientations.length]
this._configurationManager.save()
this._rebuildDock()
}
/**
* @private
*/
_rebuildDock()
{
if (this._dockPanelResizeObserver) {
this._dockPanelResizeObserver.disconnect()
this._dockPanelResizeObserver = 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._configurationManager.hasField(OPTION_DOCK_POSITION)) {
let value = this._getConfig(OPTION_DOCK_POSITION)
if (this._dockConfig.orientations.includes(value)) {
return value
}
}
return this._dockConfig.defaultOrientation
}
/**
* @param {string} orientation
* @private
*/
_setDockPanelAnchor(orientation)
{
for (let panel of document.querySelectorAll('.bv-dock-slide-panel')) {
panel.classList.remove('bv-dock-panel-left', 'bv-dock-panel-right', 'bv-dock-panel-bottom')
panel.classList.add('bv-dock-panel-' + orientation)
}
this._syncDockPanelPosition()
}
/**
* @param {JQuery} panel
* @private
*/
_wireComplianceRulesSlidePanel(panel)
{
panel.find('.bv-dock-slide-panel-close').on('click', () => {
this._hideDockSlidePanel(panel[0])
})
panel.on('mouseleave', (event) => {
if (this._isDockSlidePanelVisible(event.currentTarget)) {
this._hideDockSlidePanel(event.currentTarget)
}
})
}
/**
* Pin dock-anchored slide panels beside the dock (and stack outward when needed).
* @protected
*/
_syncDockPanelPosition()
{
if (!this._dockConfig || this._disableUI) {
return
}
let dock = $('.bv-dock').first()[0]
if (!dock) {
return
}
let dockRect = dock.getBoundingClientRect()
let orientation = this._getDockOrientation()
let mainPanel = document.getElementById('bv-ui')
let compliancePanel = document.getElementById('bv-compliance-rules')
if (mainPanel) {
this._syncDockSlidePanelPosition(mainPanel, dockRect, orientation, dockRect)
}
if (compliancePanel) {
let anchorRect = mainPanel && this._isDockSlidePanelVisible(mainPanel) ?
mainPanel.getBoundingClientRect() :
dockRect
this._syncDockSlidePanelPosition(compliancePanel, dockRect, orientation, anchorRect)
}
}
/**
* @param {HTMLElement} panel
* @param {DOMRect} dockRect
* @param {string} orientation
* @param {DOMRect} anchorRect
* @private
*/
_syncDockSlidePanelPosition(panel, dockRect, orientation, anchorRect)
{
if (orientation === 'left') {
panel.style.left = anchorRect.right + 'px'
panel.style.right = 'auto'
panel.style.top = dockRect.top + 'px'
panel.style.bottom = 'auto'
this._applyPanelTransformLeft(panel, panel.classList.contains('bv-dock-panel-offscreen'))
return
}
if (orientation === 'right') {
panel.style.left = 'auto'
panel.style.right = (window.innerWidth - anchorRect.left) + 'px'
panel.style.top = dockRect.top + 'px'
panel.style.bottom = 'auto'
this._applyPanelTransformRight(panel, panel.classList.contains('bv-dock-panel-offscreen'))
return
}
panel.style.left = (dockRect.left + dockRect.width / 2) + 'px'
panel.style.right = 'auto'
panel.style.top = 'auto'
panel.style.bottom = (window.innerHeight - anchorRect.top) + 'px'
let offscreen = panel.classList.contains('bv-dock-panel-offscreen')
this._applyPanelTransformBottom(panel, offscreen)
}
/**
* @param {HTMLElement} dock
* @private
*/
_observeDockPanelSync(dock)
{
if (this._dockPanelResizeObserver) {
this._dockPanelResizeObserver.disconnect()
this._dockPanelResizeObserver = null
}
if (!dock || this._getDockOrientation() !== 'bottom') {
return
}
this._dockPanelResizeObserver = new ResizeObserver(() => {
this._syncDockPanelPosition()
})
this._dockPanelResizeObserver.observe(dock)
}
/**
* @param {HTMLElement} panel
* @param {string} orientation
* @private
*/
_applyDockSlidePanelTransform(panel, orientation)
{
panel.classList.add('bv-dock-panel-animate')
let offscreen = panel.classList.contains('bv-dock-panel-offscreen')
if (orientation === 'bottom') {
this._applyPanelTransformBottom(panel, offscreen)
return
}
if (orientation === 'right') {
this._applyPanelTransformRight(panel, offscreen)
return
}
this._applyPanelTransformLeft(panel, offscreen)
}
/**
* @param {HTMLElement} panel
* @param {boolean} offscreen
* @private
*/
_applyPanelTransformLeft(panel, offscreen)
{
panel.style.transform = offscreen ? 'translateX(-100%)' : 'translateX(0)'
}
/**
* @param {HTMLElement} panel
* @param {boolean} offscreen
* @private
*/
_applyPanelTransformRight(panel, offscreen)
{
panel.style.transform = offscreen ? 'translateX(100%)' : 'translateX(0)'
}
/**
* @param {HTMLElement} panel
* @param {boolean} offscreen
* @private
*/
_applyPanelTransformBottom(panel, offscreen)
{
panel.style.transform = offscreen ? 'translate(-50%, 100%)' : 'translateX(-50%)'
}
/**
* @param {HTMLElement} panel
* @return {boolean}
* @private
*/
_isDockSlidePanelVisible(panel)
{
return !panel.classList.contains('bv-dock-panel-hidden') &&
panel.style.display !== 'none' &&
!panel.classList.contains('bv-dock-panel-offscreen')
}
/**
* @param {HTMLElement} panel
* @protected
*/
_showDockSlidePanel(panel)
{
let orientation = this._getDockOrientation()
this._syncDockPanelPosition()
panel.classList.remove('bv-dock-panel-hidden')
panel.style.display = 'flex'
panel.classList.add('bv-dock-panel-offscreen')
this._applyDockSlidePanelTransform(panel, orientation)
panel.offsetHeight
panel.classList.remove('bv-dock-panel-offscreen')
this._applyDockSlidePanelTransform(panel, orientation)
this._syncDockPanelPosition()
this._updateDockSlidePanelButtonStates()
}
/**
* @param {HTMLElement} panel
* @param {boolean} [animate]
* @protected
*/
_hideDockSlidePanel(panel, animate = true)
{
let orientation = this._getDockOrientation()
panel.classList.add('bv-dock-panel-offscreen')
this._applyDockSlidePanelTransform(panel, orientation)
this._updateDockSlidePanelButtonStates()
let finish = () => {
if (!panel.classList.contains('bv-dock-panel-offscreen')) {
return
}
panel.classList.add('bv-dock-panel-hidden')
panel.style.display = 'none'
this._syncDockPanelPosition()
}
if (animate) {
window.setTimeout(finish, 250)
} else {
finish()
}
}
/**
* @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()
{
let mainPanel = document.getElementById('bv-ui')
$('.bv-dock-main-ui-btn').toggleClass('bv-dock-btn-active', !!(mainPanel && this._isDockSlidePanelVisible(mainPanel)))
let compliancePanel = document.getElementById('bv-compliance-rules')
$('.bv-dock-compliance-rules-btn').toggleClass('bv-dock-btn-active', !!(compliancePanel && this._isDockSlidePanelVisible(compliancePanel)))
}
/**
* @private
*/
_updateDockMenuButtonState()
{
this._updateDockSlidePanelButtonStates()
}
/**
* @protected
*/
async _runDeferredStorageMigration()
{
let hadPendingMigration = this._configurationManager.needsStorageMigration()
await this._configurationManager.runStorageMigrationAsync()
this._configurationManager.reloadBookmarkFields()
Utilities.processEventHandlerQueue(this._onBookmarksHydrate)
if (!hadPendingMigration) {
return
}
this._configurationManager.revertChanges()
this._configurationManager.updateInterface()
this._configurationManager.reloadBookmarkFields()
this._refreshDockButtonStates()
this._validateCompliance(true)
}
/**
* @protected
*/
_refreshDockButtonStates()
{
this._configurationManager.setDockIncludeContext(this)
this._configurationManager.updateDockInterface($('.bv-dock .bv-dock-rail-body').first())
this._syncDockPanelPosition()
}
/**
* @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()
{
let panel = this._complianceRulesPanel
if (!panel?.length) {
return
}
let body = panel.find('.bv-dock-slide-panel-body')
body.empty()
let report = this._getComplianceRuleReport()
if (!report.length) {
body.append($('<p>').text('No items hidden on this page.'))
return
}
for (let section of report) {
body.append($('<h3>').text(section.filterLabel))
let list = $('<ul class="bv-compliance-rule-list">')
for (let rule of section.rules) {
let row = $('<li class="bv-compliance-rule-row">')
row.append($('<span class="bv-compliance-rule-label">').text(rule.label + ' — ' + rule.count + ' hidden'))
if (this._canRemoveComplianceRule(section.filterKey, rule.label)) {
row.append(
this._uiGen.createFormButton('Remove', 'Remove this rule from the filter list.', () => {
this._removeComplianceRule(section.filterKey, rule.label)
this._renderComplianceRulesPanelContent()
}).addClass('bv-compliance-rule-remove'),
)
}
list.append(row)
}
body.append(list)
}
}
/**
* @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 isBlacklisted
let itemTags = this._get(item, attribute)
if (itemTags !== null && itemTags.length) {
for (let rule of blacklistRuleset) {
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._configurationManager.setDockIncludeContext(this)
this._configurationManager.updateDockInterface($('.bv-dock .bv-dock-rail-body').first())
}
/**
* @private
*/
_onBackupSettings()
{
this._configurationManager.backup()
}
/**
* @private
*/
_onResetSettings()
{
this._configurationManager.revertChanges()
this._validateCompliance()
}
/**
* @private
*/
_onRestoreSettings()
{
this._configurationManager.restore(new Response($('#restore-settings').prop('files')[0])).then(() => this._validateCompliance())
}
/**
* @private
*/
_onSaveSettings()
{
this._onApplyNewSettings()
this._configurationManager.save()
}
/**
* @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 {string} path
* @private
*/
_removeIllegalCharactersFromPath(path)
{
return path.replace(/[<>:"/\\|?*]/g, '-').replace(/[.\s]+$/, '').trim()
}
/**
* @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
}
let itemLists = $(this._config.itemListSelectors)
if (firstRun) {
itemLists.each((index, itemList) => {
let itemListObject = $(itemList)
if (this._paginator && itemListObject.is(this._paginator.getItemListSelector())) {
ChildObserver.create().onNodesAdded((itemsAdded) => {
this._complyItemsList($(itemsAdded), true)
this._paginator.run(this._getConfig(CONFIG_PAGINATOR_THRESHOLD), this._getConfig(CONFIG_PAGINATOR_LIMIT))
}).observe(itemList)
} else {
ChildObserver.create().onNodesAdded((itemsAdded) => {
this._complyItemsList($(itemsAdded), true)
}).observe(itemList)
}
this._complyItemsList(itemListObject)
})
} else {
this._statistics.reset()
if (this._complianceRules) {
this._complianceRules.reset()
}
itemLists.each((index, itemsList) => {
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
}
/**
* @return {boolean}
* @protected
*/
_isDownloadDuplicateLedgerActive()
{
if (!this._downloadDuplicateLedgerConfig) {
return false
}
return !!this._getConfig(this._downloadDuplicateLedgerConfig.enableConfigKey)
}
/**
* @param {*} item
* @return {string|null}
* @protected
*/
_getDownloadDuplicateLedgerId(item)
{
if (!this._isDownloadDuplicateLedgerActive()) {
return null
}
let id = Utilities.callEventHandler(this._downloadDuplicateLedgerConfig.getDownloadId, [item], null)
if (id === null || id === undefined || !String(id).length) {
return null
}
return String(id)
}
/**
* Replaces the in-memory ledger with the current storage snapshot.
*
* @protected
*/
_reloadDownloadDuplicateLedgerFromStorage()
{
this._getDownloadDuplicateLedgerField()?.reload()
}
/**
* @protected
*/
_mergeDownloadDuplicateLedgerFromStorage()
{
this._getDownloadDuplicateLedgerField()?.reload()
}
/**
* @param {string|null|undefined} downloadId
* @return {boolean}
* @protected
*/
_isDownloadDuplicate(downloadId)
{
if (!this._isDownloadDuplicateLedgerActive()) {
return false
}
return this._getDownloadDuplicateLedgerField()?.has(downloadId) === true
}
/**
* 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 {boolean}
* @protected
*/
_claimDownloadDuplicateLedgerSlot(downloadId)
{
let ledgerField = this._getDownloadDuplicateLedgerField()
if (!ledgerField || !this._isDownloadDuplicateLedgerActive()) {
return true
}
if (Array.isArray(downloadId)) {
return ledgerField.claim(downloadId)
}
return ledgerField.claim(downloadId === null || downloadId === undefined ? [] : [downloadId])
}
/**
* @param {{name?: string|null}} download
* @protected
*/
_handleDuplicateDownloadSkipped(download)
{
}
/**
* @param {{name?: string|null}} download
* @param {*} error
* @protected
*/
_handleDownloadFailed(download, error)
{
alert('Failed to download:\n\n' + (download.name ?? ''))
console.log('Download error:', error?.error, error?.details)
}
/**
* @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
*/
init()
{
this._initPageDetection()
if (this._runPageOperations()) {
return
}
if (!this._shouldRunFullInit()) {
return
}
Utilities.processEventHandlerQueue(this._onBeforeFullInit)
this._configurationManager.initialize()
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()
}
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 {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._configurationManager.setDockActive(true)
this._configurationManager.setDockIncludeContext(this)
this._configurationManager.onDockToggle((field) => {
this._onSaveSettings()
})
if (orientations.length > 1) {
this._configurationManager.addTextField(OPTION_DOCK_POSITION).
setTitle('Dock Position').
setDefault(defaultOrientation)
}
return this
}
}