Main class of the Brazen user scripts framework
ของเมื่อวันที่
สคริปต์นี้ไม่ควรถูกติดตั้งโดยตรง มันเป็นคลังสำหรับสคริปต์อื่น ๆ เพื่อบรรจุด้วยคำสั่งเมทา // @require https://update.sleazyfork.org/scripts/416105/1901976/Brazen%20Framework%20-%20Framework.js
// ==UserScript==
// @name Brazen Framework - Framework
// @namespace brazenvoid
// @version 14.1.0
// @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-14.1.0'
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 OPTION_AUTO_HIDE_SETTINGS_PANE = 'auto-hide-settings-pane'
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'
const OPTION_ENABLE_EXPLORED_TAGS_TRACKER = 'enable-explored-tags-tracker'
const OPTION_HIDE_OLDER_POSTS = 'hide-older-posts'
const OPTION_LAST_ID = 'last-id'
const OPTION_AUTO_NEXT_PAGE = 'auto-next-page'
const DOCK_SET_LATEST_ID = 'set-latest-id'
const DOCK_SET_POST_ID = 'set-post-id'
/** Tiles processed per compliance batch before yielding to the event loop. */
const COMPLIANCE_TILE_BATCH_SIZE = 10
/**
* Default detailed help HTML for framework-owned settings fields.
* Consumer scripts may override per field via {@link ConfigurationField#setHelp}.
*
* @type {Readonly<Record<string, string>>}
*/
const FRAMEWORK_FIELD_DETAILED_HELP = Object.freeze({
DISABLE_ALL_FILTERS:
'<p>Dock master switch to bypass every filter without clearing your settings. Hover it for individual filter toggles.</p>' +
'<p>When bypassed, blacklists, explored tags, hide older posts, and hide downloaded no longer remove tiles on search pages.</p>',
BOOKMARKS:
'<p>Save searches as one-click bookmarks from the dock star on search pages or from sidebar tag rows.</p>' +
'<p>Bookmarks store the tags you searched for — not script default tags or width/height filters. ' +
'Manage saved searches here: filter the list, sort A–Z with ⇅, remove with ×. ' +
'Single-tag bookmarks may show eye or compass buttons when that tag is on your hide or explore list.</p>' +
'<p>Included in backup/restore (bookmark list is replaced on restore).</p>',
ENABLE_TAG_BLACKLIST:
'<p>Also toggle from the dock. When enabled, tag blacklist rules hide matching posts on search pages.</p>' +
'<p>Hover a sidebar tag row and click the eye to add a tag here and turn this on. Click again ' +
'(<strong>Stop hiding posts with this tag</strong>) to remove it. A strike on the eye means it is active.</p>' +
'<p>Edit bulk or combined rules in the Tag Blacklist panel below; tap ⓘ there for line grammar.</p>',
ENABLE_EXPLORED_TAGS:
'<p>Also toggle from the dock (<strong>Explored tags</strong>). When enabled, explored-tag rules hide matching posts while you page.</p>' +
'<p>Hover a sidebar tag and click the compass to add a tag to your explore list. Click again to remove. ' +
'A strike on the compass means it is active; the row gets a soft red tint.</p>' +
'<p>Use this secondary list while working through huge result sets; when done exploring a tag, hide it on the main blacklist.</p>',
TAG_BLACKLIST:
'<p>Hide posts with these tags. One tag name per line (underscores, not spaces).</p>' +
'<p>Combine with <code>&</code> (and) / <code>|</code> (or); add <code>// note</code> for comments (stored separately).</p>' +
'<p>Sidebar hide buttons add sole tags here when the blacklist is enabled. ' +
'Open <strong>Active Hide Rules</strong> on the dock to see what removed posts and remove matching lines.</p>',
EXPLORED_TAGS:
'<p>Hide posts with these explored tags while paging through large result sets. One tag name per line (underscores, not spaces).</p>' +
'<p>Sidebar compass buttons add here when the tracker is enabled. Same line grammar as the blacklist: ' +
'<code>&</code> (and), <code>|</code> (or), optional <code>//</code> comments.</p>',
HIDE_OLDER_POSTS:
'<p>Turn on from the dock. Hides search results whose post id is lower than <strong>Last ID</strong>.</p>' +
'<p>Hover this control and choose <strong>Set Latest ID</strong> on a search page (highest id on the page) ' +
'or <strong>Set Post ID</strong> on a post. Or type Last ID manually in Filters.</p>',
LAST_ID:
'<p>Posts with an id below this value are hidden when <strong>Hide Older Posts</strong> is on.</p>' +
'<p>Set manually or via dock capture buttons. Useful for continuing from where you left off in large result sets.</p>',
AUTO_NEXT_PAGE:
'<p>Dock toggle. On search pages, when compliance hides every post on the current page, the script loads the next page.</p>' +
'<p>Useful with strict blacklists or hide-downloaded filters while paging.</p>',
SET_LATEST_ID:
'<p>Sets <strong>Last ID</strong> to the highest post id visible on the current search page.</p>' +
'<p>Used with <strong>Hide Older Posts</strong> to skip earlier results. Available from the Hide Older Posts dock slide-out on search pages.</p>',
SET_POST_ID:
'<p>Sets <strong>Last ID</strong> to the current media post id.</p>' +
'<p>Used with <strong>Hide Older Posts</strong> to hide lower-id search results. Available from the Hide Older Posts dock slide-out on media pages.</p>',
SKIP_DUPLICATE:
'<p>Dock control next to Start/Pause (on by default). Remembers post ids this script already queued — not what is on your disk.</p>' +
'<p>When on, the same post is not saved again. Turn off to save again after you deleted files locally. ' +
'Memory has no fixed size cap and is included in backup/restore (merged on import).</p>' +
'<p>Repeating a save overwrites the file on disk; the script does not add <code>(1)</code> copies.</p>',
HIDE_DOWNLOADED:
'<p>Hover slide-out on the Skip Duplicate dock control (always available, even when Skip is off).</p>' +
'<p>Uses the same download ledger memory to hide tiles you already saved. New downloads are recorded when ' +
'<strong>Skip Duplicate Downloads</strong> or <strong>Hide Downloaded Media</strong> is on.</p>',
PAGINATION_LIMIT:
'<p>Caps how many extra result pages the paginator will fetch when merging results on supported sites.</p>',
PAGINATION_THRESHOLD:
'<p>Target minimum visible results before the paginator stops fetching additional pages on supported sites.</p>',
BOOKMARK_SEARCH:
'<p>Save the current search as a one-click bookmark from the dock star on search pages.</p>' +
'<p>Manage saved searches in the Bookmarks settings panel when your script provides one.</p>',
AUTO_HIDE_SETTINGS_PANE:
'<p>When enabled, the settings panel closes automatically one second after the pointer leaves it.</p>' +
'<p>Re-entering the settings panel or a review panel (Active Hide Rules, Tag Discovery, human-interaction, etc.) cancels the pending close. ' +
'Dragging the width resizer also prevents auto-hide until you release the mouse.</p>',
})
class BrazenItemAttributesResolver
{
/**
* @typedef {{itemLinkSelector: string, itemDeepAnalysisSelector: string, requestDelay: number,
* onDeepAttributesResolution: Function}} ItemAttributesResolverConfiguration
*/
/**
* @callback ItemAttributesResolverCallback
* @param {HTMLElement} 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 {HTMLElement}
* @private
*/
_sandbox
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {ItemAttributesResolverConfiguration} configuration
*/
constructor(configuration)
{
this._itemLinkSelector = configuration.itemLinkSelector
this._itemDeepAnalysisSelector = configuration.itemDeepAnalysisSelector
this._onDeepAttributesResolution = configuration.onDeepAttributesResolution
this._requestDelay = configuration.requestDelay
this._sandbox = Utilities.makeEl('div', {
attrs: {id: 'brazen-item-attributes-resolver-sandbox'},
hidden: true,
})
document.body.appendChild(this._sandbox)
}
// -------------------------------------------------------------------------
// Private class methods
// -------------------------------------------------------------------------
/**
* @param {string} attribute
* @returns {string}
* @private
*/
_formatAttributeName(attribute)
{
return attribute.toLowerCase().replaceAll(' ', '_')
}
/**
* @param {HTMLElement} item
* @param {Object} attributesBag
* @private
*/
_loadDeepAttributes(item, attributesBag)
{
let linkEl = item.querySelector(this._itemLinkSelector)
let url = linkEl?.getAttribute('href')
if (url) {
Utilities.sleep(this._requestIteration * this._requestDelay).then(async () => {
try {
let response = await fetch(url, {credentials: 'include'})
let html = await response.text()
let doc = new DOMParser().parseFromString(html, 'text/html')
let deepRoot = doc.querySelector(this._itemDeepAnalysisSelector)
for (const attributeName in this._deepAttributes) {
attributesBag[attributeName] = this._deepAttributes[attributeName](deepRoot)
}
this._onDeepAttributesResolution(item)
this._sandbox.replaceChildren()
} 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 {HTMLElement} item
* @param {string} attribute
* @returns {*}
*/
get(item, attribute)
{
let attributesBag = item.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 {HTMLElement} item
* @param {Function|null} afterResolutionCallback
*/
resolveAttributes(item, afterResolutionCallback = null)
{
let attributesBag = {}
item.scriptAttributes = attributesBag
for (const attributeName in this._attributes) {
attributesBag[attributeName] = this._attributes[attributeName](item)
}
}
/**
* @param {HTMLElement} item
* @param {string} attribute
* @param {*} value
* @returns {BrazenItemAttributesResolver}
*/
set(item, attribute, value)
{
item.scriptAttributes[this._formatAttributeName(attribute)] = value
return this
}
}
/** Regex capture/group fragments keyed by download-pattern chip token. */
const DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN = {
id: '(?<id>\\d+)',
md5: '[0-9a-f]{32}',
width: '\\d+',
height: '\\d+',
score: '\\d+',
rating: '[a-z]+',
author: '.+?',
copyright: '.+?',
character: '.+?',
meta: '.+?',
general: '.+?',
}
/** Canonical Gelbooru-family download pattern sections (shared with ledger folder import). */
const DEFAULT_DOWNLOAD_PATTERN_SECTIONS = [
{
label: 'Post',
chips: [
{token: 'id', label: 'ID'},
{token: 'md5', label: 'File hash'},
{token: 'width', label: 'Width'},
{token: 'height', label: 'Height'},
{token: 'score', label: 'Score'},
{token: 'rating', label: 'Rating'},
],
},
{
label: 'Tags',
chips: [
{token: 'author', label: 'Artists'},
{token: 'copyright', label: 'Series'},
{token: 'character', label: 'Characters'},
{token: 'meta', label: 'Meta'},
{token: 'general', label: 'General'},
],
},
]
/**
* Build label → regex map from download pattern sections (Toolbox ledger import).
* @param {{label: string, chips: {token?: string, insert?: string, label: string}[]}[]} patternSections
* @param {Record<string, string>} [overrides]
* @return {Record<string, string>}
*/
function buildLedgerImportLabelToRegex(patternSections, overrides = {})
{
let map = {}
for (let section of patternSections ?? []) {
for (let chip of section.chips ?? []) {
let token = chip.token ?? chip.insert
if (token && chip.label && DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN[token]) {
map[chip.label] = DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN[token]
}
}
}
return {...map, ...overrides}
}
/** Default label → regex map for {@link DEFAULT_DOWNLOAD_PATTERN_SECTIONS}. */
const DEFAULT_LEDGER_IMPORT_LABEL_TO_REGEX = buildLedgerImportLabelToRegex(DEFAULT_DOWNLOAD_PATTERN_SECTIONS)
/**
* @param {string} fieldKey
* @return {{templateId: string, templateConfig: object, config: object}|null}
*/
function rulesetFieldSeedFromSpec(fieldKey)
{
let spec = typeof getRulesetFieldSpec === 'function' ? getRulesetFieldSpec(fieldKey) : null
if (!spec) {
return null
}
return {
templateId: spec.templateId,
templateConfig: spec.templateConfig ?? {},
config: {...DEFAULT_RULESET_USER_CONFIG},
}
}
/** Post ids buffered in RAM per IndexedDB flush during folder import. */
const DOWNLOAD_LEDGER_IMPORT_BATCH_SIZE = 4096
/** Progress bar refresh step as a percent of total files (1 = every 1%). */
const DOWNLOAD_LEDGER_IMPORT_PROGRESS_STEP_PERCENT = 1
/** Event-loop yield step as a percent of total files. */
const DOWNLOAD_LEDGER_IMPORT_YIELD_STEP_PERCENT = 2
class BrazenFramework
{
/**
* @typedef {{configKey: string, validate: SearchEnhancerFilterValidationCallback, comply: SearchEnhancerFilterComplianceCallback}} ComplianceFilter
*/
/**
* @typedef {{primaryField?: string,
* enableConfigKey?: string, enableHelpText?: string, enableDefault?: boolean,
* isValidId?: (value: *) => boolean,
* getDownloadId: SearchEnhancerDownloadLedgerIdCallback}} DownloadDuplicateLedgerConfiguration
*/
/**
* @typedef {{patternSections: {label: string, chips: {token?: string, insert?: string, label: string, title: string}[]}[],
* patternSeparators: {insert: string, label: string, title: string}[],
* filenamePatternConfigKey?: string,
* idTokenLabel?: string,
* labelToRegex?: Record<string, string>,
* patternBuilderEventNamespace?: string}} DownloadLedgerImportConfiguration
*/
/**
* @typedef {{configKey: string, otherTagSectionsSelector?: HTMLElement, styleClass: string, rows?: int, help: string, removeClasses?: string,
* formatter?: Function}} ItemTagHighlightsConfiguration
*/
/**
* @typedef {string|Function|{[pageOrLayout: string]: string, default?: string}} SelectorConfig
*/
/**
* @typedef {{detect: Function, layout?: Function}} PageDefinition
*/
/**
* @typedef {{pages: string[], operation: Function}} PageOperation
*/
/**
* @typedef {{doItemCompliance?: Function, downloadDuplicateLedger?: DownloadDuplicateLedgerConfiguration, downloadsDelay?: int,
* isUserLoggedIn?: boolean, itemDeepAnalysisSelector?: string,
* 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 {HTMLElement} item
* @param {*} configValues
* @return {*}
*/
/**
* @callback SubscriptionsFilterExclusionsCallback
* @return {boolean}
*/
/**
* @callback SubscriptionsFilterUsernameCallback
* @param {HTMLElement} item
* @return {boolean|string}
*/
/**
* @callback SearchEnhancerItemWorkerCallback
* @param {HTMLElement} item
*/
/**
* @callback SearchEnhancerItemWrapperResolver
* @param {HTMLElement} item
* @return {HTMLElement}
*/
/**
* @callback SearchEnhancerTagsExtractionCallback
* @param {HTMLElement} item
* @return {string[]}
*/
/**
* @callback SearchEnhancerTagSelectorGeneratorCallback
* @param {string} tag
* @return {string}
*/
/**
* @callback SearchEnhancerDownloadLedgerIdCallback
* @param {*} item - DOM 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
/**
* Set when {@link createDownloadsTabPanel} includes a shared pattern builder; drives post-embed patching.
* @type {{folderConfigKey: string, subfolderPatternConfigKey: string, filenamePatternConfigKey: string,
* patternBuilderEventNamespace: string}|null}
* @private
*/
_downloadsTabPanelConfig = null
/**
* @type {boolean}
* @private
*/
_downloadsTabPatchQueued = false
/**
* @type {string}
* @private
*/
_highlightClasses = ''
/**
* @type {boolean}
* @private
*/
_sanitizationEnabled = false
/**
* @type {HTMLElement}
* @private
*/
_subscriptionsLoaderButton = null
// -------------------------------------------------------------------------
// Protected class variables
// -------------------------------------------------------------------------
/**
* @type {boolean}
* @protected
*/
_disableUI = false
/**
* @type {HTMLElement|null}
* @protected
*/
_uiSection = null
/**
* @type {{orientations: string[], defaultOrientation: string, scriptName?: string, showBranding?: boolean, onOpenMainPanel?: Function}|null}
* @protected
*/
_dockConfig = null
/**
* When true, {@link hideDockMigrationStatus} is a no-op and post-build re-shows migrating UI (style testing).
* @type {boolean}
* @protected
*/
_forceDockMigrationStatus = false
/**
* @type {HTMLElement|null}
* @protected
*/
_migrationPanel = null
/**
* @type {boolean}
* @protected
*/
_migrationPanelActive = false
/**
* 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
/**
* AbortController for dock resize listener (aborted on dock rebuild / pagehide).
* @type {AbortController|null}
* @private
*/
_dockResizeAbort = null
/**
* @type {{fieldKeys: Set<string>, normalizeRuleLine: function(string): string, getRuleColor?: function(string, string): (string|null)}|null}
* @private
*/
_removableTagComplianceFilters = 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
/**
* Trailing debounce for window resize / stack ResizeObserver panel sync.
* @type {number|null}
* @private
*/
_dockPanelResizeSyncTimer = null
/**
* Prevents nested stack sync while programmatic layout writes run.
* @type {boolean}
* @private
*/
_dockPanelSyncInProgress = false
/**
* 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
/**
* @type {AbortController|null}
* @private
*/
_hideDownloadedResyncAbort = null
/**
* @type {AbortController|null}
* @private
*/
_paginatorKeyboardNavAbort = 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
/**
* Toolbox Download Ledger summary line (`createFormSectionIntro`).
* @type {HTMLElement|null}
* @private
*/
_downloadLedgerCountEl = null
/**
* Ignores stale async ledger count responses.
* @type {number}
* @private
*/
_downloadLedgerCountRefreshGeneration = 0
/**
* Debounce timer for ledger-driven Toolbox count refreshes.
* @type {ReturnType<typeof setTimeout>|null}
* @private
*/
_downloadLedgerCountRefreshTimer = null
/**
* @type {string|null}
* @protected
*/
_downloadDuplicateLedgerFieldKey = null
/**
* @type {{enableConfigKey: string, getDownloadId: SearchEnhancerDownloadLedgerIdCallback}|null}
* @protected
*/
_downloadDuplicateLedgerConfig = null
/**
* @type {DownloadLedgerImportConfiguration|null}
* @protected
*/
_downloadLedgerImportConfig = 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()
/**
* In-flight ensureNames for bookmark-row attribute status (keyed by attemptedKey).
* Not a permanent attempted set — cache eviction / clearCache must be allowed to re-fetch.
* @type {Map<string, Set<string>>}
* @private
*/
_bookmarkRowAttributePrefetchInFlight = new Map()
/**
* @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(HTMLElement)[]}
* @protected
*/
_onFirstHitAfterCompliance = []
/**
* Operations to perform before compliance checks, the first time a search item is retrieved
* @type {function(HTMLElement)[]}
* @protected
*/
_onFirstHitBeforeCompliance = []
/**
* Logic to hide a non-compliant item
* @type {SearchEnhancerItemWorkerCallback}
* @param {HTMLElement} item
* @protected
*/
_onItemHide = null
/**
* Logic to show the compliant search item
* @type {Function[]}
* @param {HTMLElement} 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 {HTMLElement[]}
* @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()
},
})
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)
})
/** @type {Function[]} */
this._settingsDetailPaneListeners = []
BrazenViewLayer.onSettingsDetailPaneChange((isOpen, ctx) => {
for (let listener of this._settingsDetailPaneListeners) {
listener(isOpen, ctx)
}
})
this._configurationManager.addFlagField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
setTitle('Disable All Filters').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.DISABLE_ALL_FILTERS).
applyDockTemplate('invertedFiltersMaster')
this._onItemHide = (item) => {
let wrapper = this._config.itemWrapperResolver(item)
wrapper.classList.add(CLASS_NON_COMPLIANT_ITEM)
wrapper.classList.remove(CLASS_COMPLIANT_ITEM)
Utilities.setHidden(wrapper, true)
}
this._onItemShow.push((item) => {
let wrapper = this._config.itemWrapperResolver(item)
wrapper.classList.add(CLASS_COMPLIANT_ITEM)
wrapper.classList.remove(CLASS_NON_COMPLIANT_ITEM)
Utilities.setHidden(wrapper, false)
})
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 enableFlag = this._configurationManager.addFlagField(enableConfigKey).
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.SKIP_DUPLICATE)
if (enableConfigKey === OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER) {
enableFlag.setTitle('Skip Duplicate Downloads')
}
if (ledgerConfig.enableDefault !== false) {
this._configurationManager.registerFieldSeed(enableConfigKey, 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} help
* @param rows
* @protected
*/
_addItemBlacklistFilter(help, rows = 5)
{
this._configurationManager.addFlagField(OPTION_ENABLE_TEXT_BLACKLIST).
setTitle('Enable Text Blacklist').
setHelp('Applies the blacklist.')
this._configurationManager.addRulesetField(FILTER_TEXT_BLACKLIST).
setTitle('Blacklist').
setRows(rows).
setHelp(help).
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 {string|Function} durationNodeSelector
* @param {string|null} help
* @param {string} separator
* @protected
*/
_addItemDurationRangeFilter(durationNodeSelector, help = null, separator = ':')
{
this._configurationManager.addRangeField(FILTER_DURATION_RANGE, 0, 100000).
setTitle('Duration').
setHelp(help ?? 'Filter items by duration.')
this._itemAttributesResolver.addAttribute(FILTER_DURATION_RANGE, (item) => {
let duration
if (typeof durationNodeSelector === 'function') {
duration = durationNodeSelector(item)
} else {
let durationNode = item.querySelector(durationNodeSelector)
if (durationNode) {
duration = durationNode.textContent.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 {string} ratingNodeSelector
* @param {string|null} help
* @param {string|null} unratedHelp
* @protected
*/
_addItemPercentageRatingRangeFilter(ratingNodeSelector, help = null, unratedHelp = null)
{
this._configurationManager.addRangeField(FILTER_PERCENTAGE_RATING_RANGE, 0, 100000).
setTitle('Rating').
setHelp(help ?? 'Filter items by percentage rating.')
this._configurationManager.addFlagField(FILTER_UNRATED).
setTitle('Unrated').
setHelp(unratedHelp ?? 'Hide items with zero or no rating.')
this._itemAttributesResolver.addAttribute(FILTER_PERCENTAGE_RATING_RANGE, (item) => {
let rating = item.querySelector(ratingNodeSelector)
return rating ? Number.parseInt(rating.textContent.trim().replace('%', '')) : null
})
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} key
* @param {string|null} optionKey
* @param {string|null} dockTemplateName Named CM dock template (default `tagBlacklist` for primary blacklist).
* @protected
*/
_addItemTagBlacklistFilter(attribute, useSelectors, rows = 5, key = null, optionKey = null, dockTemplateName = null)
{
if (key === null) {
key = FILTER_TAG_BLACKLIST
}
if (optionKey === null) {
optionKey = OPTION_ENABLE_TAG_BLACKLIST
}
let enableHelp = optionKey === OPTION_ENABLE_EXPLORED_TAGS_TRACKER ?
FRAMEWORK_FIELD_DETAILED_HELP.ENABLE_EXPLORED_TAGS :
FRAMEWORK_FIELD_DETAILED_HELP.ENABLE_TAG_BLACKLIST
let rulesetHelp = key === FILTER_TAG_BLACKLIST ?
FRAMEWORK_FIELD_DETAILED_HELP.TAG_BLACKLIST :
FRAMEWORK_FIELD_DETAILED_HELP.EXPLORED_TAGS
let enableField = this._configurationManager.addFlagField(optionKey).setHelp(enableHelp)
if (optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
enableField.setTitle('Enable Tag Blacklist')
} else if (optionKey === OPTION_ENABLE_EXPLORED_TAGS_TRACKER) {
enableField.setTitle('Enable Explored Tags Tracker')
}
let templateName = dockTemplateName
if (!templateName && optionKey === OPTION_ENABLE_TAG_BLACKLIST) {
templateName = 'tagBlacklist'
}
if (templateName) {
enableField.applyDockTemplate(templateName)
}
this._configurationManager.addRulesetField(key).
setTemplate(key === FILTER_TAG_BLACKLIST ? 'tag-blacklist' : 'explored-tags').
setTemplateConfig({
compileGroup: key === FILTER_TAG_BLACKLIST ? 'blacklist' : 'explored',
useSelectors: false,
}).
setGroupingAvailable(true).
setTitle(key === FILTER_TAG_BLACKLIST ? 'Tag Blacklist' : 'Explored Tags Tracker').
setHelp(rulesetHelp)
let seed = rulesetFieldSeedFromSpec(key)
if (seed) {
this._configurationManager.registerFieldSeed(key, seed)
}
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
}
return this._configurationManager.hasTagComplianceRules(fieldKey)
}
/**
* @param {HTMLElement} 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
}
let result = this._configurationManager.evaluateTagCompliance(itemTags, fieldKey)
return result.complies ? true : result
}
/**
* 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} [optionKey]
* @param {{resyncOnFocusAndVisibility?: boolean, resyncPages?: string|string[]}} [options]
* @protected
*/
_addItemHideDownloadedMediaFilter(getItemDownloadId, optionKey = OPTION_HIDE_DOWNLOADED_MEDIA, options = {})
{
this._hideDownloadedGetItemId = getItemDownloadId
let field = this._configurationManager.addFlagField(optionKey)
if (optionKey === OPTION_HIDE_DOWNLOADED_MEDIA) {
field.setTitle('Hide Downloaded Media').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.HIDE_DOWNLOADED)
}
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
},
)
if (options.resyncOnFocusAndVisibility) {
let setup = () => this._wireHideDownloadedFocusVisibilityResync()
if (options.resyncPages) {
this._forPage(options.resyncPages, setup)
} else {
setup()
}
}
}
/**
* @param {string} help
* @protected
*/
_addItemTextSanitizationFilter(help)
{
this._sanitizationEnabled = true
this._configurationManager.addRulesetField(FILTER_TEXT_SANITIZATION).
setTitle('Text Sanitization Rules').
setRows(2).
setHelp(help).
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} help
* @protected
*/
_addItemTextSearchFilter(help = null)
{
this._configurationManager.addTextField(FILTER_TEXT_SEARCH).
setTitle('Search').
setHelp(help ?? '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} help
* @protected
*/
_addItemWhitelistFilter(help)
{
this._configurationManager.addRulesetField(FILTER_TEXT_WHITELIST).
setTitle('Whitelist').
setRows(5).
setHelp(help).
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').
setHelp('Hide videos from subscribed channels.')
this._configurationManager.addTextField(STORE_SUBSCRIPTIONS).
setTitle('Account Subscriptions').
setHelp('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 {HTMLElement} 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)
let filterValue = this._configurationManager.getOptimized(complianceFilter.configKey)
if (complianceFilter.validate(filterValue)) {
let complyResult = complianceFilter.comply(item, filterValue)
let {complies, rule} = this._unwrapComplianceResult(complyResult)
itemComplies = complies
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.style.opacity = 'unset'
}
/**
* Collect item tiles from a list container or observer-added nodes.
* @param {HTMLElement|NodeList|HTMLElement[]} itemsList
* @param {boolean} fromObserver
* @return {HTMLElement[]}
* @private
*/
_collectListItems(itemsList, fromObserver = false)
{
let selector = this._config.itemSelectors
if (fromObserver) {
let items = []
let seen = new Set()
let add = (el) => {
if (el instanceof Element && !seen.has(el)) {
seen.add(el)
items.push(el)
}
}
let nodes = itemsList instanceof NodeList || Array.isArray(itemsList) ? itemsList : [itemsList]
for (let node of nodes) {
if (!(node instanceof Element)) {
continue
}
if (node.matches(selector)) {
add(node)
}
for (let el of node.querySelectorAll(selector)) {
add(el)
}
}
return items
}
if (!(itemsList instanceof Element)) {
return []
}
if (this._config.itemSelectionMethod === 'find') {
return Array.from(itemsList.querySelectorAll(selector))
}
return Array.from(itemsList.children).filter((child) => child.matches(selector))
}
/**
* 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 {HTMLElement|NodeList|HTMLElement[]} itemsList
* @param {boolean} fromObserver
* @return {Promise<void>}
* @protected
*/
async _complyItemsList(itemsList, fromObserver = false)
{
let items = this._collectListItems(itemsList, fromObserver)
// Shallow attribute resolve before priming (tag lists / post ids).
for (let item of items) {
if (this._get(item, ITEM_PROCESSED_ONCE) === null) {
if (this._sanitizationEnabled) {
Validator.sanitizeTextNode(
item.querySelector(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)
for (let offset = 0; offset < items.length; offset += COMPLIANCE_TILE_BATCH_SIZE) {
let batch = items.slice(offset, offset + COMPLIANCE_TILE_BATCH_SIZE)
for (let item of batch) {
item.style.opacity = '0.75'
}
for (let item of batch) {
this._complyItem(item)
if (!this._get(item, ITEM_PROCESSED_ONCE)) {
Utilities.processEventHandlerQueue(this._onFirstHitAfterCompliance, [item])
this._itemAttributesResolver.set(item, ITEM_PROCESSED_ONCE, true)
}
}
if (offset + COMPLIANCE_TILE_BATCH_SIZE < items.length) {
await Utilities.sleep(0)
}
}
Utilities.processEventHandlerQueue(this._onAfterComplianceRun)
}
/**
* @return {boolean}
* @protected
*/
_needsTagCompliancePriming()
{
return this._validateTagComplianceFilter(FILTER_TAG_BLACKLIST, OPTION_ENABLE_TAG_BLACKLIST) ||
this._validateTagComplianceFilter('explored-tags-tracker', OPTION_ENABLE_EXPLORED_TAGS_TRACKER)
}
/**
* Prefetch ledger membership and tag registry rows needed for this tile set.
* @param {HTMLElement[]} 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 = []
for (let item of items) {
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 || !this._needsTagCompliancePriming()) {
return
}
let tagNameLists = []
for (let item of items) {
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 {HTMLElement[]}
*/
_createPaginationControls()
{
return [
this._configurationManager.createElement(CONFIG_PAGINATOR_THRESHOLD),
this._configurationManager.createElement(CONFIG_PAGINATOR_LIMIT)]
}
/**
* @protected
* @return {HTMLElement}
*/
_createSettingsBackupRestoreFormActions()
{
let restoreInput = this._uiGen.createFormGroupInput('file')
restoreInput.id = 'restore-settings'
restoreInput.placeholder = 'Browse for settings file...'
return this._uiGen.createFormActions([
this._uiGen.createFormButton('Backup Configuration', 'Download configuration file.', () => this._onBackupSettings()),
this._uiGen.createSeparator(),
restoreInput,
this._uiGen.createFormButton('Restore Configuration', 'Restore configuration from the selected file.', () => this._onRestoreSettings()),
], 'bv-flex-column')
}
/**
* @protected
* @return {HTMLElement}
*/
_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 {HTMLElement}
*/
createDonateTabPanel(options = {})
{
return this._uiGen.createDonateTabPanel(options)
}
/**
* Downloads settings tab — folder/patterns, substitutions, and ignore list when registered by Download Manager.
* Does not include **Start in Selection Mode** (`download-selection-mode-default`); mount that via
* {@link createBehavioursTabPanel}.
* Pass `Downloads` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
*
* @param {{tabName?: string, isFirst?: boolean, patternSections?: object[], patternSeparators?: object[],
* patternBuilderEventNamespace?: string, folderConfigKey?: string, subfolderPatternConfigKey?: string,
* filenamePatternConfigKey?: string, stripCharacterSeriesConfigKey?: string, substitutionsFieldKey?: string,
* tagIgnoreFieldKey?: string}} [options]
* @return {HTMLElement}
*/
createDownloadsTabPanel(options = {})
{
let tabName = options.tabName ?? 'Downloads'
let paths = this._downloadManager?._config?.downloadPaths
let folderKey = options.folderConfigKey ?? paths?.folderConfigKey ?? 'download-folder'
let subfolderKey = options.subfolderPatternConfigKey ?? paths?.subfolderPatternConfigKey ?? 'subfolder-pattern'
let filenameKey = options.filenamePatternConfigKey ?? paths?.filenamePatternConfigKey ?? 'filename-pattern'
let stripKey = options.stripCharacterSeriesConfigKey ?? paths?.stripCharacterSeriesConfigKey ??
'strip-series-from-character-tags'
let subsKey = options.substitutionsFieldKey ?? paths?.substitutionsFieldKey ?? 'filename-tag-substitutions'
let ignoreKey = options.tagIgnoreFieldKey ?? paths?.tagIgnoreFieldKey ?? 'filename-tag-ignore-list'
let elements = []
let addPatternGroup = (key) => {
if (!this._configurationManager.hasField(key)) {
return
}
let element = this._configurationManager.createElement(key)
element.classList.add('bv-pattern-group')
elements.push(element)
}
addPatternGroup(folderKey)
addPatternGroup(subfolderKey)
addPatternGroup(filenameKey)
if (options.patternSections?.length && options.patternSeparators?.length) {
elements.push(this._uiGen.createSharedPatternTokenBuilder(options.patternSections, options.patternSeparators))
this._downloadsTabPanelConfig = {
folderConfigKey: folderKey,
subfolderPatternConfigKey: subfolderKey,
filenamePatternConfigKey: filenameKey,
patternBuilderEventNamespace: options.patternBuilderEventNamespace ?? 'bvpattern',
}
if (!this._downloadsTabPatchQueued) {
this._downloadsTabPatchQueued = true
this._onAfterUIBuild.push(() => this._patchDownloadsTabPatternBuilder())
}
}
if (elements.length) {
elements.push(this._uiGen.createSeparator())
}
if (this._configurationManager.hasField(stripKey)) {
elements.push(this._configurationManager.createElement(stripKey))
elements.push(this._uiGen.createSeparator())
}
if (this._configurationManager.hasField(subsKey)) {
elements.push(this._configurationManager.createElement(subsKey))
}
if (this._configurationManager.hasField(ignoreKey)) {
elements.push(this._configurationManager.createElement(ignoreKey))
}
return Utilities.appendChildren(this._uiGen.createTabPanel(tabName, !!options.isFirst), elements)
}
/**
* Wire shared pattern-token builder focus/click handlers after the settings panel is embedded.
* Queued from {@link createDownloadsTabPanel} when pattern sections are supplied.
*
* @protected
*/
_patchDownloadsTabPatternBuilder()
{
let config = this._downloadsTabPanelConfig
if (!config) {
return
}
let filenameEl = this._configurationManager.getField(config.filenamePatternConfigKey)?.element
let subfolderEl = this._configurationManager.getField(config.subfolderPatternConfigKey)?.element
let builder = document.querySelector('#bv-ui .bv-pattern-builder-shared')
this._uiGen.patchPatternTokenBuilder(
filenameEl,
subfolderEl,
builder,
config.patternBuilderEventNamespace,
)
this._uiGen.patchStackedPatternFieldGroups(this._configurationManager, [
config.folderConfigKey,
config.filenamePatternConfigKey,
config.subfolderPatternConfigKey,
])
}
/**
* Behaviours settings tab — Framework auto-hide flag when registered, then Download Manager flags
* (`download-selection-mode-default`, `review-ignored-filename-pins`, `skip-empty-filename-pins`,
* `defer-tag-discovery-unattended`), in alphabetical title order.
* Pass `Behaviours` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
*
* @param {{tabName?: string, isFirst?: boolean}} [options]
* @return {HTMLElement}
*/
createBehavioursTabPanel(options = {})
{
let tabName = options.tabName ?? 'Behaviours'
let keys = [
OPTION_AUTO_HIDE_SETTINGS_PANE,
'defer-tag-discovery-unattended',
'review-ignored-filename-pins',
'skip-empty-filename-pins',
'download-selection-mode-default',
]
let elements = []
for (let key of keys) {
if (this._configurationManager.hasField(key)) {
elements.push(this._configurationManager.createElement(key))
}
}
return Utilities.appendChildren(this._uiGen.createTabPanel(tabName, !!options.isFirst), elements)
}
/**
* Toolbox tab — backup/restore, Clear Database, and (when configured) Download Ledger controls.
* Pass `Toolbox` (or `options.tabName`) in the matching {@link BrazenViewLayer#createTabsSection} names list.
*
* @param {{tabName?: string, isFirst?: boolean}} [options]
* @return {HTMLElement}
*/
createToolboxTabPanel(options = {})
{
let tabName = options.tabName ?? 'Toolbox'
let elements = [this._createToolboxBackupRestoreSection()]
if (this._getDownloadDuplicateLedgerField()) {
elements.push(this._uiGen.createSeparator())
elements.push(this._createDownloadLedgerToolboxSection())
}
elements.push(this._uiGen.createSeparator())
elements.push(this._createToolboxDatabaseSection())
return Utilities.appendChildren(this._uiGen.createTabPanel(tabName, !!options.isFirst), elements)
}
/**
* Register pattern-token metadata for {@link #_openDownloadLedgerImportPanel} (required for Import Folder).
*
* @param {DownloadLedgerImportConfiguration} config
* @return {BrazenFramework}
*/
configureDownloadLedgerImport(config)
{
this._downloadLedgerImportConfig = config
return this
}
/**
* @protected
* @return {HTMLElement}
*/
_createToolboxBackupRestoreSection()
{
let restoreInput = this._uiGen.createFormGroupInput('file')
restoreInput.id = 'restore-settings'
restoreInput.placeholder = 'Browse for settings file...'
return this._uiGen.createFormActions([
this._uiGen.createFormButton('Backup Configuration', 'Download configuration file.', () => this._onBackupSettings()),
this._uiGen.createSeparator(),
restoreInput,
this._uiGen.createFormButton('Restore Configuration', 'Restore configuration from the selected file.', () => this._onRestoreSettings()),
], 'bv-flex-column')
}
/**
* @protected
* @return {HTMLElement}
*/
_createToolboxDatabaseSection()
{
let resetButton = BrazenViewLayer.createProgressButton({
label: 'Reset Tag Discovery',
title: 'Clear the discovery gate for every tag (isDiscovered). Tag types and rulesets are kept; tag discovery will treat previously confirmed tags as new again.',
onClick: (api) => this._onResetAllTagsDiscovered(api),
})
let section = this._uiGen.createFormSection('Database')
section.append(this._uiGen.createFormActions([
resetButton,
this._uiGen.createFormButton(
'Clear Database',
'Delete this script’s entire IndexedDB store (settings, bookmarks, download memory, tags, and queues). The page reloads so the database can be rebuilt from scratch.',
() => this._onClearScriptDatabase(),
),
], 'bv-flex-column'))
return section
}
/**
* @protected
* @return {HTMLElement}
*/
_createDownloadLedgerToolboxSection()
{
let buttons = []
if (this._downloadLedgerImportConfig) {
buttons.push(this._uiGen.createFormButton(
'Import Folder',
'Choose a download folder and import post ids into the ledger using your filename pattern.',
() => this._openDownloadLedgerImportPanel(),
))
}
buttons.push(this._uiGen.createFormButton(
'Clear Ledger',
'Delete all remembered download ids from IndexedDB. Hide Downloaded Media and Skip Duplicate Downloads will treat prior saves as unseen until they are downloaded again.',
() => this._onClearDownloadLedger(),
))
let section = this._uiGen.createFormSection('Download Ledger')
let summary = this._uiGen.createFormSectionIntro(this._formatDownloadLedgerCountSentence(null))
this._downloadLedgerCountEl = summary
section.append(summary)
section.append(this._uiGen.createFormActions(buttons))
void this._refreshDownloadLedgerToolboxCount(summary)
this._onAfterUIBuild.push(() => this._wireDownloadLedgerToolboxCountRefresh())
return section
}
/**
* @param {number|null|undefined} count `null`/`undefined` = loading; `-1` = error
* @return {string}
* @protected
*/
_formatDownloadLedgerCountSentence(count)
{
if (count === null || count === undefined) {
return 'The download ledger contains … post ids.'
}
if (count === -1) {
return 'Could not read the download ledger count.'
}
if (count === 0) {
return 'The download ledger contains no post ids.'
}
if (count === 1) {
return 'The download ledger contains 1 post id.'
}
return `The download ledger contains ${count.toLocaleString()} post ids.`
}
/**
* @param {HTMLElement} [summaryEl]
* @return {Promise<void>}
* @protected
*/
_refreshDownloadLedgerToolboxCount(summaryEl = this._downloadLedgerCountEl)
{
let el = summaryEl ?? this._downloadLedgerCountEl
if (!el?.isConnected) {
return Promise.resolve()
}
let generation = ++this._downloadLedgerCountRefreshGeneration
el.textContent = this._formatDownloadLedgerCountSentence(null)
return this._configurationManager.countDownloadLedger().
then((count) => {
if (generation !== this._downloadLedgerCountRefreshGeneration || !el.isConnected) {
return
}
el.textContent = this._formatDownloadLedgerCountSentence(count)
}).
catch((error) => {
console.error(error)
if (generation !== this._downloadLedgerCountRefreshGeneration || !el.isConnected) {
return
}
el.textContent = this._formatDownloadLedgerCountSentence(-1)
})
}
/**
* @protected
*/
_scheduleDownloadLedgerToolboxCountRefresh()
{
if (!this._downloadLedgerCountEl?.isConnected) {
return
}
if (this._downloadLedgerCountRefreshTimer) {
clearTimeout(this._downloadLedgerCountRefreshTimer)
}
this._downloadLedgerCountRefreshTimer = setTimeout(() => {
this._downloadLedgerCountRefreshTimer = null
void this._refreshDownloadLedgerToolboxCount()
}, 1000)
}
/**
* @protected
*/
_wireDownloadLedgerToolboxCountRefresh()
{
for (let btn of document.querySelectorAll('.bv-tabs-nav .bv-tab-button')) {
if (Utilities.toKebabCase(btn.textContent) !== 'toolbox') {
continue
}
btn.addEventListener('click', () => {
void this._refreshDownloadLedgerToolboxCount()
})
break
}
}
/**
* @return {string[]}
* @protected
*/
_getDownloadLedgerImportPatternLabels()
{
let sections = this._downloadLedgerImportConfig?.patternSections ?? []
return sections.
flatMap((section) => section.chips.map((chip) => chip.label)).
sort((a, b) => b.length - a.length)
}
/**
* @return {Record<string, string>}
* @protected
*/
_getDownloadLedgerImportLabelToRegex()
{
let sections = this._downloadLedgerImportConfig?.patternSections ?? DEFAULT_DOWNLOAD_PATTERN_SECTIONS
return buildLedgerImportLabelToRegex(sections, this._downloadLedgerImportConfig?.labelToRegex)
}
/**
* @return {string}
* @protected
*/
_getDownloadLedgerImportIdTokenLabel()
{
return this._downloadLedgerImportConfig?.idTokenLabel ?? 'ID'
}
/**
* @param {string} pattern
* @return {boolean}
* @protected
*/
_downloadLedgerImportPatternHasIdToken(pattern)
{
if (!pattern || typeof pattern !== 'string') {
return false
}
let idLabel = this._getDownloadLedgerImportIdTokenLabel()
let labels = this._getDownloadLedgerImportPatternLabels()
let i = 0
while (i < pattern.length) {
let matched = false
for (let label of labels) {
if (pattern.startsWith(label, i)) {
if (label === idLabel) {
return true
}
i += label.length
matched = true
break
}
}
if (!matched) {
let nextToken = pattern.length
for (let label of labels) {
let idx = pattern.indexOf(label, i + 1)
if (idx !== -1 && idx < nextToken) {
nextToken = idx
}
}
i = nextToken
}
}
return false
}
/**
* @param {string} pattern
* @return {RegExp|null}
* @protected
*/
_buildDownloadLedgerFilenameRegex(pattern)
{
if (!this._downloadLedgerImportPatternHasIdToken(pattern)) {
return null
}
let labels = this._getDownloadLedgerImportPatternLabels()
let labelToRegex = this._getDownloadLedgerImportLabelToRegex()
let escapeRegex = (str) => str.replaceAll(/[.*+?^${}()|[\]\\]/g, String.raw`\$&`)
let parts = []
let i = 0
while (i < pattern.length) {
let matched = false
for (let label of labels) {
if (pattern.startsWith(label, i)) {
parts.push(labelToRegex[label] ?? escapeRegex(label))
i += label.length
matched = true
break
}
}
if (!matched) {
let nextToken = pattern.length
for (let label of labels) {
let idx = pattern.indexOf(label, i + 1)
if (idx !== -1 && idx < nextToken) {
nextToken = idx
}
}
parts.push(escapeRegex(pattern.slice(i, nextToken)))
i = nextToken
}
}
try {
return new RegExp(`^${parts.join('')}$`, 'i')
} catch (error) {
console.error(error)
return null
}
}
/**
* @param {File} file
* @param {RegExp} regex
* @param {function(string): boolean} isValidId
* @return {string|null}
* @protected
*/
_extractDownloadLedgerImportIdFromFile(file, regex, isValidId)
{
let name = file.name || (file.webkitRelativePath?.split('/').pop() ?? '')
if (!name) {
return null
}
let dot = name.lastIndexOf('.')
let basename = dot > 0 ? name.slice(0, dot) : name
let match = basename.match(regex)
if (!match?.groups?.id) {
return null
}
let id = String(match.groups.id).trim()
return isValidId(id) ? id : null
}
/**
* @return {Promise<void>}
* @protected
*/
_yieldDownloadLedgerImportTurn()
{
return new Promise((resolve) => {
setTimeout(resolve, 0)
})
}
/**
* @return {{lastReportBucket: number, lastYieldBucket: number}}
* @protected
*/
_createDownloadLedgerImportProgressState()
{
return {lastReportBucket: -1, lastYieldBucket: -1}
}
/**
* @param {number} index
* @param {number} total
* @param {{scanned: number, matched: number, skipped: number}} stats
* @param {function({scanned: number, matched: number, skipped: number, total: number}): void|null|undefined} onProgress
* @param {{lastReportBucket: number, lastYieldBucket: number}} progressState
* @return {boolean} whether the caller should yield to the event loop
* @protected
*/
_maybeDownloadLedgerImportProgress(index, total, stats, onProgress, progressState)
{
if (!onProgress || total <= 0) {
return false
}
let isLast = index === total - 1
let percent = Math.floor(((index + 1) * 100) / total)
let reportBucket = Math.floor(percent / DOWNLOAD_LEDGER_IMPORT_PROGRESS_STEP_PERCENT)
if (!isLast && reportBucket <= progressState.lastReportBucket) {
return false
}
progressState.lastReportBucket = reportBucket
onProgress({...stats, total})
let yieldBucket = Math.floor(percent / DOWNLOAD_LEDGER_IMPORT_YIELD_STEP_PERCENT)
if (isLast || yieldBucket > progressState.lastYieldBucket) {
progressState.lastYieldBucket = yieldBucket
return true
}
return false
}
/**
* Count matching files without accumulating post ids in RAM.
*
* @param {FileList|File[]} fileList
* @param {string} pattern
* @param {function({scanned: number, matched: number, skipped: number, total: number}): void} [onProgress]
* @return {Promise<{scanned: number, matched: number, skipped: number}>}
* @protected
*/
async _countDownloadLedgerImportFiles(fileList, pattern, onProgress = null)
{
let regex = this._buildDownloadLedgerFilenameRegex(pattern)
if (!regex) {
return {scanned: 0, matched: 0, skipped: 0}
}
let isValidId = this._getDownloadLedgerImportIsValidId()
let scanned = 0
let matched = 0
let skipped = 0
let total = fileList.length
let progressState = this._createDownloadLedgerImportProgressState()
for (let i = 0; i < total; i++) {
scanned++
if (this._extractDownloadLedgerImportIdFromFile(fileList[i], regex, isValidId)) {
matched++
} else {
skipped++
}
if (this._maybeDownloadLedgerImportProgress(i, total, {scanned, matched, skipped}, onProgress, progressState)) {
await this._yieldDownloadLedgerImportTurn()
}
}
return {scanned, matched, skipped}
}
/**
* Stream folder filenames into the ledger in bounded batches (no full id list in RAM).
*
* @param {FileList|File[]} fileList
* @param {string} pattern
* @param {boolean} replace
* @param {function({scanned: number, matched: number, skipped: number, total: number}): void} [onProgress]
* @return {Promise<{scanned: number, matched: number, skipped: number}>}
* @protected
*/
async _importDownloadLedgerFromFileList(fileList, pattern, replace, onProgress = null)
{
let regex = this._buildDownloadLedgerFilenameRegex(pattern)
if (!regex) {
return {scanned: 0, matched: 0, skipped: 0}
}
let isValidId = this._getDownloadLedgerImportIsValidId()
let cm = this._configurationManager
if (!(await cm.beginDownloadLedgerFolderImport(replace))) {
throw new Error('download-ledger-import-unavailable')
}
let scanned = 0
let matched = 0
let skipped = 0
let total = fileList.length
/** @type {string[]} */
let batch = []
let batchSeen = new Set()
let progressState = this._createDownloadLedgerImportProgressState()
let flushBatch = async () => {
if (!batch.length) {
return
}
await cm.mergeDownloadLedgerImportBatch(batch)
batch = []
batchSeen = new Set()
await this._yieldDownloadLedgerImportTurn()
}
try {
for (let i = 0; i < total; i++) {
scanned++
let id = this._extractDownloadLedgerImportIdFromFile(fileList[i], regex, isValidId)
if (!id) {
skipped++
} else {
matched++
if (!batchSeen.has(id)) {
batchSeen.add(id)
batch.push(id)
if (batch.length >= DOWNLOAD_LEDGER_IMPORT_BATCH_SIZE) {
await flushBatch()
}
}
}
if (this._maybeDownloadLedgerImportProgress(i, total, {scanned, matched, skipped}, onProgress, progressState)) {
await this._yieldDownloadLedgerImportTurn()
}
}
await flushBatch()
await cm.finalizeDownloadLedgerFolderImport()
} catch (error) {
try {
await cm.finalizeDownloadLedgerFolderImport()
} catch (finalizeError) {
console.error(finalizeError)
}
throw error
}
return {scanned, matched, skipped}
}
/**
* @return {function(string): boolean}
* @protected
*/
_getDownloadLedgerImportIsValidId()
{
let field = this._getDownloadDuplicateLedgerField()
if (typeof field?.ledgerIsValidId === 'function') {
return field.ledgerIsValidId
}
if (typeof field?._ledgerIsValidId === 'function') {
return field._ledgerIsValidId()
}
return (value) => typeof value === 'string' && value.trim().length > 0
}
/**
* @param {HTMLSelectElement|null|undefined} modeSelect
* @return {'append'|'replace'}
* @protected
*/
_getDownloadLedgerImportMode(modeSelect)
{
return modeSelect?.value === 'replace' ? 'replace' : 'append'
}
/**
* Site-specific pattern pane for ledger folder import (token builder + validation).
*
* @param {{patternInput: HTMLInputElement, syncImportButton: function(): void}} host
* @return {HTMLElement}
* @protected
*/
_createDownloadLedgerImportPatternPane(host)
{
let config = this._downloadLedgerImportConfig
if (!config) {
return Utilities.makeEl('div')
}
let patternInput = host.patternInput
let patternValidation = Utilities.makeEl('p', {
class: 'bv-ledger-import-validation',
text: '',
})
let hiddenSubfolder = this._uiGen.createFormGroupInput('text')
hiddenSubfolder.hidden = true
let builder = this._uiGen.createSharedPatternTokenBuilder(
config.patternSections,
config.patternSeparators,
)
this._uiGen.patchPatternTokenBuilder(
patternInput,
hiddenSubfolder,
builder,
config.patternBuilderEventNamespace ?? 'bvledgerimport',
)
let syncValidation = () => {
let hasId = this._downloadLedgerImportPatternHasIdToken(patternInput.value)
patternValidation.textContent = hasId ?
'' :
`Filename pattern must include the ${this._getDownloadLedgerImportIdTokenLabel()} token so post ids can be extracted.`
patternValidation.classList.toggle('bv-ledger-import-validation-error', !hasId)
host.syncImportButton()
}
patternInput.addEventListener('input', syncValidation)
syncValidation()
let patternGroup = this._uiGen.createDetailFormFieldGroup(
'Filename pattern',
[patternInput, builder, patternValidation],
'Must match your saved filenames. Include the ID token.',
)
patternGroup.classList.add('bv-pattern-group')
return patternGroup
}
/**
* @param {{title: string, lines: string[], note?: string}} summary
* @param {HTMLElement} summarySection
* @param {HTMLElement} summaryTitle
* @param {HTMLElement} summaryBody
* @protected
*/
_renderDownloadLedgerImportSummary(summarySection, summaryTitle, summaryBody, summary)
{
summaryTitle.textContent = summary.title
summaryBody.replaceChildren()
for (let line of summary.lines) {
summaryBody.append(Utilities.makeEl('p', {
class: 'bv-ledger-import-summary-line',
text: line,
}))
}
if (summary.note) {
summaryBody.append(Utilities.makeEl('p', {
class: 'bv-ledger-import-summary-note',
text: summary.note,
}))
}
}
/**
* @param {{scanned: number, matched: number, skipped: number}} stats
* @param {'append'|'replace'} mode
* @return {string[]}
* @protected
*/
_formatDownloadLedgerImportSummaryLines(stats, mode)
{
let modeLabel = mode === 'replace' ? 'Replace ledger' : 'Append to ledger'
return [
`Scanned: ${stats.scanned.toLocaleString()}`,
`Matched: ${stats.matched.toLocaleString()}`,
`Skipped: ${stats.skipped.toLocaleString()}`,
`Mode: ${modeLabel}`,
]
}
/**
* @protected
*/
_openDownloadLedgerImportPanel()
{
if (!this._getDownloadDuplicateLedgerField()) {
alert('Download ledger is not configured for this script.')
return
}
if (!this._downloadLedgerImportConfig) {
alert('Download ledger folder import is not configured for this script.')
return
}
let ViewLayer = this._uiGen.constructor
/** @type {FileList|null} */
let chosenFiles = null
let filenameKey = this._downloadLedgerImportConfig.filenamePatternConfigKey ?? 'filename-pattern'
let folderInput = this._uiGen.createFormGroupInput('file')
folderInput.classList.add('bv-ledger-import-folder-input')
folderInput.setAttribute('webkitdirectory', '')
folderInput.setAttribute('directory', '')
folderInput.setAttribute('multiple', '')
let folderStatus = Utilities.makeEl('p', {
class: 'bv-ledger-import-status',
text: 'No folder chosen.',
})
let modeGroup = this._uiGen.createDetailFormSelectGroup(
'Add mode',
[['append', 'Append to ledger'], ['replace', 'Replace ledger']],
'Append merges new ids with the ledger. Replace clears the ledger first, then writes only the imported ids.',
)
let modeSelect = modeGroup.querySelector('select')
let patternInput = this._uiGen.createFormGroupInput('text')
patternInput.value = String(this._getConfig(filenameKey) ?? this._getDownloadLedgerImportIdTokenLabel())
let importButton = this._uiGen.createFormButton(
'Import',
'Scan the chosen folder and write matching post ids to the download ledger.',
() => {},
)
let syncImportButton = () => {
let hasFolder = !!(chosenFiles && chosenFiles.length)
let hasId = this._downloadLedgerImportPatternHasIdToken(patternInput.value)
importButton.disabled = !hasFolder || !hasId
}
let chooseFolderButton = this._uiGen.createFormButton(
'Choose folder…',
'Pick a folder on your computer. Filenames are parsed locally only.',
() => folderInput.click(),
)
folderInput.addEventListener('change', () => {
chosenFiles = null
syncImportButton()
folderStatus.textContent = 'Reading folder…'
setTimeout(() => {
chosenFiles = folderInput.files?.length ? folderInput.files : null
folderStatus.textContent = chosenFiles ?
`${chosenFiles.length.toLocaleString()} files` :
'No folder chosen.'
syncImportButton()
}, 0)
})
syncImportButton()
let folderControls = Utilities.makeEl('div')
folderControls.append(chooseFolderButton, folderInput, folderStatus)
let folderGroup = this._uiGen.createDetailFormFieldGroup(
'Choose folder',
folderControls,
'Select a folder on your computer. Filenames are parsed locally; nothing is uploaded or sent anywhere. ' +
'Your browser may show an “upload” warning in its own dialog — that is normal for folder access.',
)
let patternGroup = this._createDownloadLedgerImportPatternPane({patternInput, syncImportButton})
let actions = this._uiGen.createDetailFormActions([importButton])
let configSection = Utilities.makeEl('div', {class: 'bv-ledger-import-config'})
configSection.append(modeGroup, patternGroup, folderGroup, actions)
let runSection = Utilities.makeEl('div', {class: 'bv-ledger-import-run'})
let runProgressPanel = ViewLayer.createSettingsDetailProgressPanel()
runSection.append(runProgressPanel)
let summarySection = Utilities.makeEl('div', {class: 'bv-ledger-import-summary'})
let summaryTitle = Utilities.makeEl('p', {class: 'bv-ledger-import-summary-title', text: ''})
let summaryBody = Utilities.makeEl('div', {class: 'bv-ledger-import-summary-body'})
summarySection.append(summaryTitle, summaryBody)
let content = Utilities.makeEl('div', {class: 'bv-ledger-import-panel'})
content.append(configSection, runSection, summarySection)
let resetPanel = () => {
content.classList.remove('bv-ledger-import-running', 'bv-ledger-import-done')
}
let showSummary = (summary) => {
content.classList.remove('bv-ledger-import-running')
content.classList.add('bv-ledger-import-done')
this._renderDownloadLedgerImportSummary(summarySection, summaryTitle, summaryBody, summary)
}
importButton.addEventListener('click', () => {
content.classList.add('bv-ledger-import-running')
content.classList.remove('bv-ledger-import-done')
void this._runDownloadLedgerFolderImport({
chosenFiles: () => chosenFiles,
getMode: () => this._getDownloadLedgerImportMode(modeSelect),
getPattern: () => patternInput.value,
progressPanel: runProgressPanel,
showSummary,
})
})
ViewLayer.openSettingsDetailPane({
title: 'Import Ledger from Folder',
content,
onClose: () => {
chosenFiles = null
resetPanel()
},
})
}
/**
* @param {{chosenFiles: function(): FileList|null, getMode: function(): string, getPattern: function(): string, progressPanel?: HTMLElement, showSummary?: function({title: string, lines: string[], note?: string}): void}} options
* @return {Promise<void>}
* @protected
*/
async _runDownloadLedgerFolderImport(options)
{
let ViewLayer = this._uiGen.constructor
let showSummary = (summary) => options.showSummary?.(summary)
let files = options.chosenFiles?.()
if (!files?.length) {
showSummary?.({
title: 'Import failed',
lines: ['Choose a folder first.'],
})
return
}
let pattern = String(options.getPattern?.() ?? '').trim()
if (!this._downloadLedgerImportPatternHasIdToken(pattern)) {
showSummary?.({
title: 'Import failed',
lines: [`Filename pattern must include the ${this._getDownloadLedgerImportIdTokenLabel()} token.`],
})
return
}
let progressPanel = options.progressPanel
let reportScanProgress = ({scanned, matched, skipped, total}) => {
ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
label: 'Scanning filenames…',
current: scanned,
total,
matched,
skipped,
})
}
let reportImportProgress = ({scanned, matched, skipped, total}) => {
ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
label: 'Writing ledger…',
current: scanned,
total,
matched,
skipped,
})
}
ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
label: 'Scanning filenames…',
indeterminate: true,
})
let mode = options.getMode?.() ?? 'append'
let replace = mode === 'replace'
let preview
try {
preview = await this._countDownloadLedgerImportFiles(files, pattern, reportScanProgress)
} catch (error) {
console.error(error)
showSummary?.({
title: 'Import failed',
lines: ['Could not scan the folder.', 'See the browser console for details.'],
})
return
}
if (!preview.matched) {
showSummary?.({
title: 'No matching files',
lines: this._formatDownloadLedgerImportSummaryLines(preview, mode),
note: 'Check that your filename pattern matches saved files.',
})
return
}
ViewLayer.updateSettingsDetailProgressPanel(progressPanel, {
label: 'Writing ledger…',
indeterminate: true,
})
try {
let result = await this._importDownloadLedgerFromFileList(files, pattern, replace, reportImportProgress)
this._validateCompliance()
showSummary?.({
title: 'Import complete',
lines: this._formatDownloadLedgerImportSummaryLines(result, mode),
note: replace ?
'The ledger was replaced with imported ids only.' :
'Duplicate post ids were merged in IndexedDB.',
})
void this._refreshDownloadLedgerToolboxCount()
} catch (error) {
console.error(error)
showSummary?.({
title: 'Import failed',
lines: ['Could not write to the download ledger.', 'See the browser console for details.'],
})
}
}
/**
* @protected
*/
_onClearDownloadLedger()
{
if (!this._getDownloadDuplicateLedgerField()) {
alert('Download ledger is not configured for this script.')
return
}
if (!confirm(
'Clear the download ledger?\n\n' +
'This permanently deletes every remembered download id. Previously downloaded posts can be downloaded again, and Hide Downloaded Media will stop hiding them until they are recorded again.',
)) {
return
}
void this.clearDownloadDuplicateLedger().
then(() => {
alert('Download ledger cleared.')
void this._refreshDownloadLedgerToolboxCount()
this._validateCompliance()
}).
catch((error) => {
console.error(error)
alert('Failed to clear the download ledger. See the browser console for details.')
})
}
/**
* @protected
*/
_onClearScriptDatabase()
{
if (!confirm(
'Clear this script’s entire database?\n\n' +
'Settings, bookmarks, download memory, tags, and download queues will be permanently deleted. ' +
'The page will reload so the script can rebuild an empty database.',
)) {
return
}
void this.clearScriptDatabase().
catch((error) => {
console.error(error)
alert('Failed to clear the database. See the browser console for details.')
})
}
/**
* @param {{setProgress?: Function, setRunning?: Function, reset?: Function}} progressApi
* @protected
*/
_onResetAllTagsDiscovered(progressApi)
{
if (!confirm(
'Reset tag discovery for all tags?\n\n' +
'Every tag that was marked discovered will be treated as undiscovered again. ' +
'Tag types, bookmarks, and rulesets are not changed.',
)) {
return
}
progressApi?.setRunning?.(true)
progressApi?.setProgress?.(0, 1)
void this.resetAllTagsDiscovered((progress) => {
progressApi?.setProgress?.(progress?.current ?? 0, progress?.total ?? 1)
}).
then((result) => {
let updated = result?.updated ?? 0
if (updated === 0) {
alert('No discovered tags needed resetting.')
} else if (updated === 1) {
alert('Reset tag discovery for 1 tag.')
} else {
alert(`Reset tag discovery for ${updated.toLocaleString()} tags.`)
}
progressApi?.reset?.()
}).
catch((error) => {
console.error(error)
alert('Failed to reset tag discovery. See the browser console for details.')
progressApi?.reset?.()
})
}
/**
* @protected
* @return {HTMLElement}
*/
_createSubscriptionLoaderControls()
{
return this._subscriptionsLoaderButton
}
/**
* @return {HTMLElement}
* @private
*/
_mountSettingsUserInterface()
{
let section = this._uiGen.createSettingsSection()
let primary = section._bvSettingsPrimary ?? section.querySelector('#bv-ui-primary') ?? section
Utilities.appendChildren(primary, this._userInterface)
BrazenViewLayer.syncSettingsColumnOrder(section, this._getDockOrientation())
let columnWidth = this._dockConfig.settingsPanelWidth ?? BrazenViewLayer.DEFAULT_SETTINGS_COLUMN_WIDTH
BrazenViewLayer.applySettingsColumnWidth(section, columnWidth)
return section
}
/**
* @param {HTMLElement} 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
const cancelScheduledHide = () => {
this._cancelMainPanelHideTimer()
}
if (panelNode?.addEventListener) {
panelNode.addEventListener('mouseenter', cancelScheduledHide, {signal})
panelNode.addEventListener('mouseleave', () => {
if (!this._isSettingsAutoHideEnabled() || this._uiGen.isSettingsPaneBeingResized()) {
return
}
cancelScheduledHide()
this._mainPanelHideTimer = setTimeout(() => {
this._mainPanelHideTimer = null
if (!this._isSettingsAutoHideEnabled() || this._uiGen.isSettingsPaneBeingResized()) {
return
}
if (!this._anyReviewDockSlidePanelVisible()) {
this._hideMainPanel(panelNode)
}
}, 1000)
}, {signal})
}
this._uiGen.constructor.appendToDockPanelStack(UISection)
this._uiSection = UISection
if (this._complianceRules) {
this._complianceRulesPanel = this._uiGen.createComplianceRulesSlidePanel()
this._wireComplianceRulesSlidePanel(this._complianceRulesPanel)
this._uiGen.constructor.appendToDockPanelStack(this._complianceRulesPanel)
}
this._buildDock(UISection)
this._ensureFrameworkUnloadTeardown()
}
/**
* @private
*/
_teardownMainPanelUiListeners()
{
this._cancelMainPanelHideTimer()
this._mainPanelUiAbort?.abort()
this._mainPanelUiAbort = null
}
/**
* @private
*/
_cancelMainPanelHideTimer()
{
if (this._mainPanelHideTimer != null) {
clearTimeout(this._mainPanelHideTimer)
this._mainPanelHideTimer = null
}
}
/**
* @return {boolean}
* @private
*/
_isSettingsAutoHideEnabled()
{
return !!this._getConfig(OPTION_AUTO_HIDE_SETTINGS_PANE)
}
/**
* @return {boolean}
* @private
*/
_anyReviewDockSlidePanelVisible()
{
for (let id of [
'bv-download-interruption-panel',
'bv-human-interaction-panel-resolution',
'bv-human-interaction-panel-download',
'bv-human-interaction-panel',
'bv-tag-discovery-panel',
'bv-compliance-rules',
]) {
let panel = document.getElementById(id)
if (panel && this._isDockSlidePanelVisible(panel)) {
return true
}
}
return false
}
/**
* Cancel pending settings hide when the pointer enters a review slide panel beside settings.
* @param {HTMLElement} panel
* @private
*/
_attachSettingsAutoHideBridge(panel)
{
if (!panel || panel.id === 'bv-ui') {
return
}
panel._bvSettingsAutoHideBridgeAbort?.abort()
let controller = new AbortController()
panel._bvSettingsAutoHideBridgeAbort = controller
panel.addEventListener('mouseenter', () => this._cancelMainPanelHideTimer(), {signal: controller.signal})
}
/**
* 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
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._dockResizeAbort?.abort()
this._dockResizeAbort = null
this._hideDownloadedResyncAbort?.abort()
this._hideDownloadedResyncAbort = null
this._paginatorKeyboardNavAbort?.abort()
this._paginatorKeyboardNavAbort = null
this._disconnectItemListChildObservers()
this._teardownMainPanelUiListeners()
if (this._afterComplianceRunTimer != null) {
clearTimeout(this._afterComplianceRunTimer)
this._afterComplianceRunTimer = null
}
if (this._ledgerComplianceRefreshTimer) {
clearTimeout(this._ledgerComplianceRefreshTimer)
this._ledgerComplianceRefreshTimer = null
}
this._configurationManager?.disposeBookmarkFields?.()
})
}
/**
* @param {HTMLElement} [railBody]
* @param {{layout?: boolean}} [options] Pass `{ layout: false }` to refresh button state only.
* @return {BrazenFramework}
*/
refreshDockInterface(railBody, options = {})
{
railBody = railBody ?? document.querySelector('.bv-dock .bv-dock-rail-body')
this._configurationManager.setDockIncludeContext(this)
// Bookmarks pageMatch / reloadBookmarkFields can fire during panel createElement and
// init hydrate — before `_buildDock` mounts `.bv-dock-rail-body`. Skip layout until then.
if (options.layout !== false) {
if (!railBody) {
return this
}
this.composeDockRail(railBody)
this._dockRailMembershipSignature = this._configurationManager.getDockRailMembershipSignature()
}
this._configurationManager.refreshDockButtonStates()
return this
}
/**
* @param {HTMLElement} railBody
*/
composeDockRail(railBody)
{
throw new Error('composeDockRail(railBody) must be implemented when configureDock() is used.')
}
/**
* @param {HTMLElement} 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.querySelector('.bv-dock-rail-head')
let railBody = dock.querySelector('.bv-dock-rail-body')
let railFoot = dock.querySelector('.bv-dock-rail-foot')
let openPanel = this._dockConfig.onOpenMainPanel ?? (() => this._toggleMainPanel(UISection))
let mainBtn = this._uiGen.createDockButton({
icon: 'menu',
tooltip: 'Toggle Main UI',
onClick: () => openPanel(),
})
mainBtn.classList.add('bv-dock-main-ui-btn')
Utilities.appendChildren(railHead, [mainBtn, 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')) {
let rulesBtn = this._uiGen.createDockButton({
icon: 'rules',
tooltip: 'Active hide rules — click to show',
onClick: () => this._toggleComplianceRulesPanel(),
})
rulesBtn.classList.add('bv-dock-compliance-rules-btn')
Utilities.appendChildren(railFoot, [
this._uiGen.createDockSeparator(),
rulesBtn,
])
}
if (this._dockConfig.orientations.length > 1) {
Utilities.appendChildren(railFoot, [
this._uiGen.createDockSeparator(),
this._uiGen.createDockPositionButton({
orientations: this._dockConfig.orientations,
value: orientation,
onCycle: () => this._cycleDockOrientation(),
}),
])
}
this._uiGen.constructor.appendToBody(dock)
this._configurationManager.refreshDockButtonStates()
this._observeDockPanelSync(dock)
let panel = UISection
let wasVisible = panel && this._isDockSlidePanelVisible(panel)
this._dockResizeAbort?.abort()
this._dockResizeAbort = new AbortController()
window.addEventListener('resize', () => this._scheduleDockPanelResizeSync(), {signal: this._dockResizeAbort.signal})
requestAnimationFrame(() => {
this._syncDockPanelPosition()
if (wasVisible) {
this._showMainPanel(panel)
} else {
this._hideMainPanel(panel, false)
}
})
}
_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()
{
this._dockResizeAbort?.abort()
this._dockResizeAbort = null
if (this._dockPanelResizeSyncTimer != null) {
clearTimeout(this._dockPanelResizeSyncTimer)
this._dockPanelResizeSyncTimer = null
}
if (this._dockPanelResizeObserver) {
this._dockPanelResizeObserver.disconnect()
this._dockPanelResizeObserver = null
}
this._dockRailMembershipSignature = null
for (let dockEl of document.querySelectorAll('.bv-dock')) {
dockEl.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
}
/**
* Keep the migrating panel visible after init (for style review). When set, hide is ignored.
* @param {boolean} force
* @return {BrazenFramework}
*/
setForceDockMigrationStatus(force)
{
this._forceDockMigrationStatus = !!force
return this
}
/**
* @return {void}
* @private
*/
_ensureMigrationPanel()
{
if (this._migrationPanel || this._disableUI) {
return
}
this._migrationPanel = BrazenViewLayer.createMigrationPanel({
id: 'bv-migration-panel',
scriptName: this._dockConfig ? this._getDockNameBarText() : '',
})
BrazenViewLayer.appendToBody(this._migrationPanel)
}
/**
* @param {{consentRequired: boolean, peerWaitOnly: boolean, schemaTooNew?: boolean, installedSchemaVersion?: number|null, supportedSchemaVersion?: number|null, steps: string[], backupFilename: string|null}} plan
* @return {Promise<'migrate'|'reset'>}
* @private
*/
_awaitMigrationConsent(plan)
{
this._ensureMigrationPanel()
BrazenViewLayer.showMigrationPanel(this._migrationPanel)
this._migrationPanelActive = true
return new Promise((resolve) => {
let settled = false
let finish = (choice) => {
if (settled) {
return
}
settled = true
let buttons = this._migrationPanel.querySelectorAll('.bv-migration-actions .bv-button')
for (let button of buttons) {
button.disabled = true
}
if (choice === 'migrate') {
BrazenViewLayer.restoreMigrationProgressShell(this._migrationPanel)
}
resolve(choice)
}
BrazenViewLayer.showMigrationConsentPanel(this._migrationPanel, {
steps: plan.steps,
onStart: () => finish('migrate'),
onReset: () => finish('reset'),
})
})
}
/**
* @param {MigrationProgress} progress
* @return {Promise<void>}
* @private
*/
async _handleMigrationProgress(progress)
{
this._ensureMigrationPanel()
if (!this._migrationPanelActive) {
BrazenViewLayer.showMigrationPanel(this._migrationPanel)
BrazenViewLayer.startMigrationPanelElapsedTimer(this._migrationPanel)
this._migrationPanelActive = true
await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
}
BrazenViewLayer.updateMigrationPanel(this._migrationPanel, progress)
}
/**
* @return {void}
* @private
*/
_hideMigrationPanel()
{
if (this._forceDockMigrationStatus) {
return
}
if (!this._migrationPanel) {
return
}
BrazenViewLayer.hideMigrationPanel(this._migrationPanel)
this._migrationPanelActive = false
}
/**
* @param {{schemaTooNew?: boolean, installedSchemaVersion?: number|null, supportedSchemaVersion?: number|null}} plan
* @return {void}
* @private
*/
_showSchemaTooNewPanel(plan)
{
this._ensureMigrationPanel()
BrazenViewLayer.showMigrationPanel(this._migrationPanel)
this._migrationPanelActive = true
BrazenViewLayer.showMigrationSchemaTooNewPanel(this._migrationPanel, {
installedVersion: plan.installedSchemaVersion,
supportedVersion: plan.supportedSchemaVersion,
onRetry: () => location.reload(),
onReset: () => {
void this.clearScriptDatabase()
},
})
}
/**
* @param {Error|*} error
* @return {void}
* @private
*/
_showMigrationFailure(error)
{
this._ensureMigrationPanel()
BrazenViewLayer.showMigrationPanel(this._migrationPanel)
if (!this._migrationPanelActive) {
BrazenViewLayer.startMigrationPanelElapsedTimer(this._migrationPanel)
}
BrazenViewLayer.showMigrationFailurePanel(this._migrationPanel, {
backupHint: this._configurationManager.getPreMigrationBackupFilenameHint?.() ??
'your pre-migration backup zip',
errorMessage: error?.message ? String(error.message) : String(error),
onRetry: () => location.reload(),
onWipe: () => {
void this.clearScriptDatabase()
},
})
this._migrationPanelActive = true
}
/**
* @deprecated Use migration slide panel during init. Kept for compatibility.
* @param {string} [message='Migrating database…']
*/
showDockMigrationStatus(message = 'Migrating database…')
{
if (this._disableUI) {
return
}
void this._handleMigrationProgress({phase: 'generic', label: message, indeterminate: true})
}
/**
* @deprecated Use {@link _hideMigrationPanel} during init.
*/
hideDockMigrationStatus()
{
this._hideMigrationPanel()
}
/**
* @deprecated
* @param {string} [message='Migrating database…']
* @return {Promise<void>}
* @private
*/
async _paintDockMigrationStatus(message = 'Migrating database…')
{
await this._handleMigrationProgress({phase: 'generic', label: message, indeterminate: true})
}
/**
* @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)
if (this._uiSection) {
BrazenViewLayer.syncSettingsColumnOrder(this._uiSection, orientation)
}
this._syncDockPanelPosition()
}
/**
* @param {HTMLElement} panel
* @private
*/
_wireComplianceRulesSlidePanel(panel)
{
BrazenViewLayer.wireDockSlidePanel(panel, {
onClose: () => this._hideDockSlidePanel(panel),
})
}
/**
* Trailing debounce for resize-driven stack sync (maximize/minimize, drag-resize).
* @private
*/
_scheduleDockPanelResizeSync()
{
if (!this._dockConfig || this._disableUI) {
return
}
if (this._dockPanelResizeSyncTimer != null) {
clearTimeout(this._dockPanelResizeSyncTimer)
}
this._dockPanelResizeSyncTimer = setTimeout(() => {
this._dockPanelResizeSyncTimer = null
this._syncDockPanelPositionNow()
}, 120)
}
/**
* Pin dock-anchored slide panels beside the dock (and stack outward when needed).
* Order is near-dock → outward: settings adjacent to dock, review panels outward.
* @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
}
if (this._dockPanelSyncInProgress) {
return
}
let dock = document.querySelector('.bv-dock')
if (!dock) {
return
}
this._dockPanelSyncInProgress = true
try {
BrazenViewLayer.syncDockPanelStackHost(dock, this._getDockOrientation(), [
document.getElementById('bv-ui'),
document.getElementById('bv-download-interruption-panel'),
document.getElementById('bv-human-interaction-panel-resolution'),
document.getElementById('bv-human-interaction-panel-download'),
document.getElementById('bv-human-interaction-panel'),
document.getElementById('bv-tag-discovery-panel'),
document.getElementById('bv-compliance-rules'),
].filter(Boolean))
} finally {
this._dockPanelSyncInProgress = false
}
}
/**
* @param {HTMLElement} dock
* @private
*/
_observeDockPanelSync(dock)
{
if (this._dockPanelResizeObserver) {
this._dockPanelResizeObserver.disconnect()
this._dockPanelResizeObserver = null
}
if (!dock) {
return
}
this._dockPanelResizeObserver = new ResizeObserver(() => {
this._scheduleDockPanelResizeSync()
})
this._dockPanelResizeObserver.observe(dock)
let stack = BrazenViewLayer.getDockPanelStack()
if (stack) {
this._dockPanelResizeObserver.observe(stack)
}
}
/**
* @param {HTMLElement} panel
* @return {boolean}
* @private
*/
_isDockSlidePanelVisible(panel)
{
return BrazenViewLayer.isDockSlidePanelVisible(panel)
}
/**
* @param {HTMLElement} panel
* @protected
*/
_showDockSlidePanel(panel)
{
this._cancelMainPanelHideTimer()
this._attachSettingsAutoHideBridge(panel)
let orientation = this._getDockOrientation()
// Dynamically created panes (HI / interruption) miss the dock-build orientation pass.
panel.classList.remove('bv-dock-panel-left', 'bv-dock-panel-right', 'bv-dock-panel-bottom')
panel.classList.add('bv-dock-panel-' + orientation)
BrazenViewLayer.showDockSlidePanel(panel, orientation, () => this._syncDockPanelPositionNow())
requestAnimationFrame(() => {
this._syncDockPanelPositionNow()
try {
let stack = BrazenViewLayer.getDockPanelStack()
if (stack) {
this._dockPanelResizeObserver?.observe(stack)
}
} catch (_) { /* ignore */ }
})
this._updateDockSlidePanelButtonStates()
}
/**
* @param {HTMLElement} panel
* @param {boolean} [animate]
* @protected
*/
_hideDockSlidePanel(panel, animate = true)
{
if (panel?.id === 'bv-ui') {
BrazenViewLayer.closeSettingsDetailPane()
}
BrazenViewLayer.hideDockSlidePanel(panel, this._getDockOrientation(), animate, () => {
this._syncDockPanelPosition()
})
this._updateDockSlidePanelButtonStates()
}
/**
* @param {HTMLElement} UISection
* @protected
*/
_toggleMainPanel(UISection)
{
let panel = 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 = document.querySelector('.bv-dock .bv-dock-rail-body')
if (!railBody) {
// Dock not mounted yet (UI build / init hydrate before `_buildDock`).
return
}
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, detail?: {tags?: string[], fieldKeys?: string[]}|null}} event
* @private
*/
_scheduleConfigurationChange(event)
{
if (event?.source === 'ledger') {
this._ledgerComplianceDirty = true
}
let pending = this._pendingConfigurationChange
if (pending) {
let detail = this._configurationManager.mergeChangeDetails(pending.detail, event?.detail)
let source = event?.source ?? 'all'
if (pending.source !== source && source !== 'all') {
// Preserve ledger dirty when coalescing ledger + queue/state into `all`.
if (pending.source === 'ledger' || event?.source === 'ledger') {
this._ledgerComplianceDirty = true
}
source = 'all'
} else if (pending.source === 'all') {
source = 'all'
}
event = {
manager: event?.manager ?? pending.manager,
source,
local: event?.local !== false,
detail,
}
}
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 {{tags?: string[], fieldKeys?: string[]}|null|undefined} detail
* @return {boolean}
* @private
*/
_tagDetailTouchesCompliance(detail)
{
let fieldKeys = detail?.fieldKeys
if (!Array.isArray(fieldKeys) || !fieldKeys.length) {
// Unknown tag fan-out — keep prior behaviour (re-validate).
return true
}
return fieldKeys.some((fieldKey) =>
fieldKey === FILTER_TAG_BLACKLIST || fieldKey === 'explored-tags-tracker')
}
/**
* @param {{tags?: string[], fieldKeys?: string[]}|null|undefined} detail
* @return {boolean}
* @private
*/
_tagDetailNeedsDockRefresh(detail)
{
let fieldKeys = detail?.fieldKeys
if (!Array.isArray(fieldKeys) || !fieldKeys.length) {
return true
}
// Pure download-path attribute toggles do not change dock chrome.
return fieldKeys.some((fieldKey) =>
fieldKey !== 'filename-tag-ignore-list' && fieldKey !== 'filename-tag-substitutions')
}
/**
* @param {{manager?: BrazenConfigurationManager, source?: string, local?: boolean, detail?: {tags?: string[], fieldKeys?: string[]}|null}} event
* @return {Promise<void>}
* @private
*/
async _handleConfigurationChange(event)
{
let manager = event.manager ?? this._configurationManager
let source = event.source ?? 'all'
let local = event.local !== false
let detail = event.detail ?? null
// 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.
let refreshDock = !runtimePipeline &&
(source !== 'tags' || !local || this._tagDetailNeedsDockRefresh(detail))
if (refreshDock) {
this._refreshDockButtonStates()
}
if (reloadsSettingsControls || (source === 'tags' && this._tagDetailTouchesCompliance(detail))) {
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()
this._scheduleDownloadLedgerToolboxCountRefresh()
}
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 compliance rule counts.
void this._runComplianceValidation(false)
}, 400)
}
/**
* Debounced Hide Downloaded refresh when the tab regains focus or visibility.
* @private
*/
_wireHideDownloadedFocusVisibilityResync()
{
this._hideDownloadedResyncAbort?.abort()
let controller = new AbortController()
this._hideDownloadedResyncAbort = controller
let resync = () => {
if (document.visibilityState !== 'visible') {
return
}
this._scheduleLedgerComplianceRefresh()
}
document.addEventListener('visibilitychange', resync, {signal: controller.signal})
window.addEventListener('focus', resync, {signal: controller.signal})
}
/**
* @param {HTMLElement} 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) {
return
}
this._uiGen.renderComplianceRulesPanelContent(this._complianceRulesPanel, this._getComplianceRuleReport(), {
canRemoveRule: (filterKey, ruleLabel) => this._canRemoveComplianceRule(filterKey, ruleLabel),
onRemoveRule: (filterKey, ruleLabel) => {
void Promise.resolve(this._removeComplianceRule(filterKey, ruleLabel)).then(() => {
// Tag-registry removes notify → validateCompliance (re-renders when visible).
// Fallback refresh covers sync no-ops / non-persist paths.
if (this._complianceRulesPanel && this._isDockSlidePanelVisible(this._complianceRulesPanel)) {
this._renderComplianceRulesPanelContent()
}
})
},
getRuleColor: (filterKey, ruleLabel) => this._getComplianceRuleColor(filterKey, ruleLabel),
})
}
/**
* @protected
*/
_showComplianceRulesModal()
{
this._toggleComplianceRulesPanel()
}
/**
* @param {string} filterKey
* @param {string} ruleLabel
* @return {boolean}
* @protected
*/
_canRemoveComplianceRule(filterKey, ruleLabel)
{
if (!this._isRemovableTagComplianceFilterKey(filterKey)) {
return false
}
return this._configurationManager.matchesTagRuleLabel(filterKey, ruleLabel,
this._removableTagComplianceFilters?.normalizeRuleLine)
}
/**
* @param {string} filterKey
* @param {string} ruleLabel
* @return {void|Promise<void>}
* @protected
*/
async _removeComplianceRule(filterKey, ruleLabel)
{
if (!this._isRemovableTagComplianceFilterKey(filterKey)) {
return
}
let field = this._configurationManager.getField(filterKey)
if (!field) {
return
}
let normalize = this._removableTagComplianceFilters?.normalizeRuleLine
if (typeof normalize !== 'function') {
return
}
await this._configurationManager.removeTagRuleByLabel(filterKey, ruleLabel, normalize)
}
/**
* Optional name color for an Active Hide Rules row (CSS color string).
* Return null/empty for non-tag filters and untyped rules — never gates row visibility.
* @param {string} filterKey
* @param {string} ruleLabel
* @return {string|null}
* @protected
*/
_getComplianceRuleColor(filterKey, ruleLabel)
{
if (!this._isRemovableTagComplianceFilterKey(filterKey)) {
return null
}
let normalize = this._removableTagComplianceFilters?.normalizeRuleLine
if (typeof normalize !== 'function') {
return null
}
let normalizedLabel = normalize(ruleLabel)
if (!normalizedLabel || normalizedLabel.includes(' & ')) {
return null
}
let getColor = this._removableTagComplianceFilters?.getRuleColor
return typeof getColor === 'function' ? (getColor(filterKey, normalizedLabel) ?? null) : null
}
/**
* @param {string} filterKey
* @return {boolean}
* @private
*/
_isRemovableTagComplianceFilterKey(filterKey)
{
return !!this._removableTagComplianceFilters?.fieldKeys?.has(filterKey)
}
/**
* @private
*/
_onApplyNewSettings()
{
this._configurationManager.update()
this._validateCompliance()
this.refreshDockInterface(document.querySelector('.bv-dock .bv-dock-rail-body'))
}
/**
* @private
*/
_onBackupSettings()
{
void this._configurationManager.backup()
}
/**
* @private
*/
_onResetSettings()
{
void this._configurationManager.revertChanges()
}
/**
* @private
*/
_onRestoreSettings()
{
let restoreInput = document.getElementById('restore-settings')
void this._configurationManager.restore(new Response(restoreInput?.files?.[0]))
}
/**
* @protected
*/
_showIdbRequiredFatal()
{
this._ensureMigrationPanel()
BrazenViewLayer.showMigrationPanel(this._migrationPanel)
BrazenViewLayer.showMigrationFailurePanel(this._migrationPanel, {
errorMessage: 'This script requires IndexedDB and cannot run without it.',
onRetry: () => location.reload(),
})
this._migrationPanelActive = true
}
/**
* @protected
* @deprecated Use {@link _showIdbRequiredFatal}
*/
_showIdbBlockedBanner()
{
this._showIdbRequiredFatal()
}
/**
* @private
*/
async _onSaveSettings()
{
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').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.PAGINATION_LIMIT)
this._configurationManager.addNumberField(CONFIG_PAGINATOR_THRESHOLD, 1, 1000).
setTitle('Pagination Threshold').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.PAGINATION_THRESHOLD)
}
/**
* @return {BrazenSubscriptionsLoader}
* @protected
*/
_setupSubscriptionLoader()
{
this._subscriptionsLoader = new BrazenSubscriptionsLoader(
(status) => {
this._subscriptionsLoaderButton.textContent = status
},
(subscriptions) => {
this._configurationManager.getField(STORE_SUBSCRIPTIONS).value = subscriptions.length ? '"' +
subscriptions.join('""') + '"' : ''
this._configurationManager.save()
let loaderBtn = document.getElementById('subscriptions-loader')
if (loaderBtn) {
loaderBtn.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.disabled = true
this._subscriptionsLoader.run()
} else {
this._showNotLoggedInAlert()
}
})
this._subscriptionsLoaderButton.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))
}
/**
* Defer the first compliance pass until after the dock and settings panel paint.
* @protected
*/
_scheduleInitialCompliance()
{
let run = () => this._validateCompliance(true)
if (typeof requestIdleCallback === 'function') {
requestIdleCallback(run, {timeout: 250})
} else {
setTimeout(run, 0)
}
}
/**
* @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 = document.querySelectorAll(this._config.itemListSelectors)
if (firstRun) {
this._disconnectItemListChildObservers()
for (let itemList of itemLists) {
if (this._paginator && itemList.matches(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(itemList)
}
} else {
if (this._complianceRules) {
this._complianceRules.reset()
}
for (let itemsList of itemLists) {
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 {HTMLElement} 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
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 {string}
* @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)
let plan = await this._configurationManager.getPendingMigrationPlan()
if (plan.idbUnavailable) {
if (!this._disableUI) {
this._showIdbRequiredFatal()
}
return
}
if (plan.schemaTooNew) {
if (!this._disableUI) {
this._showSchemaTooNewPanel(plan)
}
return
}
if (plan.consentRequired && !this._disableUI) {
let choice = await this._awaitMigrationConsent(plan)
if (choice === 'reset') {
await this.clearScriptDatabase()
return
}
}
try {
await this._configurationManager.initialize({
onMigrationProgress: (progress) => this._handleMigrationProgress(progress),
})
} catch (error) {
console.log('[Brazen] migration failed:', error)
let schemaConflict = await this._configurationManager.resolveSchemaVersionConflictFromError?.(error)
if (schemaConflict && !this._disableUI) {
this._showSchemaTooNewPanel({
installedSchemaVersion: schemaConflict.installed,
supportedSchemaVersion: schemaConflict.supported,
})
return
}
if (!this._disableUI) {
this._showMigrationFailure(error)
}
return
}
this._hideMigrationPanel()
await this._configurationManager.reloadBookmarkFields()
Utilities.processEventHandlerQueue(this._onBookmarksHydrate)
await this._configurationManager.ensureRulesetFieldsCompiled()
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) => {
let nameNode = item.querySelector(this._config.itemNameSelector)
return nameNode?.textContent?.trim() ?? ''
})
}
if (this._paginator) {
this._paginator.initialize()
}
Utilities.processEventHandlerQueue(this._onBeforeUIBuild)
if (!this._disableUI) {
this._embedUI(this._mountSettingsUserInterface())
Utilities.processEventHandlerQueue(this._onAfterUIBuild)
this._configurationManager.updateInterface()
if (this._forceDockMigrationStatus) {
this._ensureMigrationPanel()
BrazenViewLayer.showMigrationPanel(this._migrationPanel)
BrazenViewLayer.updateMigrationPanel(this._migrationPanel, {
phase: 'generic',
label: 'Updating database…',
detail: 'Debug: forced migration panel',
indeterminate: true,
})
this._migrationPanelActive = true
}
}
// After the dock exists — early DM init must not flash/hide review panels or paint
// progress onto a detached slot created before `_buildDock`.
if (this._downloadManager) {
await this._downloadManager.afterDockReady()
await this._downloadManager.restoreTagDiscoveryPanelIfNeeded()
}
this._scheduleInitialCompliance()
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
}
/**
* Fired when the settings secondary detail pane opens/closes or tag inputs focus changes.
* @param {Function} handler `(isOpen, { activeTagInputs }) => void`
* @return {BrazenFramework}
*/
onSettingsDetailPaneChange(handler)
{
if (typeof handler === 'function') {
this._settingsDetailPaneListeners.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 notifies `tags` with detail — gated chrome refresh follows.
await this._configurationManager.clearTagSubstitution(normalized, fieldKey)
break
}
case 'linkingTarget': {
let source = this._tagSubstitutionLinkSource
let sourceType = this._tagSubstitutionLinkSourceType
this._tagSubstitutionLinkSource = null
this._tagSubstitutionLinkSourceType = null
// Link-mode cleared above; tags notify after persist refreshes subject + alias rows.
if (source) {
await this._configurationManager.setTagSubstitution(source, normalized, fieldKey, {
subjectTypeName: sourceType,
replacementTypeName: tag.type ?? null,
source: options.source ?? 'tag-action',
})
} else {
console.log('[BrazenFramework] tag substitution commit skipped: link source missing')
}
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 {HTMLElement}
*/
createTagSubstitutionActionButton(tagName, options = {})
{
let normalize = options.normalize
let fieldKey = options.fieldKey
let pending = false
return BrazenViewLayer.createTagSubstitutionActionButton({
state: this.getTagSubstitutionUiState(tagName, {normalize, fieldKey}),
className: options.className,
iconClass: options.iconClass,
onClick: () => {
if (pending) {
return
}
pending = true
// Link-mode notify runs sync; commit/clear await IDB — refresh after the step settles.
let toggle = this.toggleTagSubstitutionLink(tagName, {
normalize,
fieldKey,
tag: options.tag ?? {name: tagName},
})
void toggle.finally(() => {
pending = false
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 ruleset sole-attribute row (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 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.refreshTagComplianceSpecs([fieldKey])
}
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 {HTMLElement}
*/
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
let setIconActive = (buttonEl, isActive) => {
if (!buttonEl) {
return
}
let nextIcon = kind === 'ignore' ? BrazenViewLayer.createTagIgnoreIcon(isActive, iconClass) :
kind === 'explore' ? BrazenViewLayer.createTagExploreIcon(isActive, iconClass) :
BrazenViewLayer.createTagBlacklistIcon(isActive, iconClass)
let svg = buttonEl.querySelector('svg')
if (svg) {
svg.replaceWith(nextIcon)
} else {
buttonEl.replaceChildren(nextIcon)
}
}
let titleForActive = (isActive) => isActive ?
(kind === 'ignore' ? 'Stop ignoring in filenames' :
kind === 'blacklist' ? 'Stop hiding posts with this tag' :
'Remove tag from explore list') :
(kind === 'ignore' ? 'Ignore in filenames' :
kind === 'blacklist' ? 'Hide posts with the tag' :
'Add tag to explore list')
return BrazenViewLayer.createTagAttributeActionButton({
title: titles[kind] ?? '',
icon,
className: options.className ?? 'bv-tag-action-btn',
onClick: (event) => {
if (pending) {
return
}
pending = true
let button = event?.currentTarget instanceof HTMLElement ? event.currentTarget : null
active = !active
setIconActive(button, active)
if (button) {
button.title = titleForActive(active)
}
let persist = this.toggleTagSoleAttribute(tagName, {
fieldKey,
normalize,
ensureOptionKey: options.ensureOptionKey,
tag: options.tag ?? {name: tagName},
})
options.onAfterToggle?.()
void persist.finally(() => {
pending = false
let actual = this.hasTagSoleAttributeActive(tagName, {fieldKey, normalize})
if (actual !== active) {
active = actual
setIconActive(button, active)
if (button) {
button.title = titleForActive(active)
}
}
options.onAfterToggle?.()
})
},
})
}
/**
* @param {ConfigurationField} field
* @return {*[]}
* @private
*/
_getBookmarkRulesetRows(field)
{
return Array.isArray(field._rulesetRows) ? field._rulesetRows : []
}
/**
* @param {*} entry
* @param {function(string): string} normalizeUrl
* @return {string}
* @private
*/
_getBookmarkEntryUrl(entry, normalizeUrl)
{
let payload = entry?.payload ?? entry
return normalizeUrl(payload?.url ?? '')
}
/**
* Resolve the persisted bookmark row for a normalized URL. In-memory `field._rulesetRows` only
* holds the paginated first page, so a bookmark added from another surface (tag shortcut, an
* earlier page) may not be loaded — fall back to a full-field storage scan so toggle/remove is
* authoritative regardless of pagination.
* @param {ConfigurationField} field
* @param {string} normalizedUrl
* @param {function(string): string} normalizeUrl
* @return {Promise<*|null>}
* @private
*/
async _findPersistedBookmarkEntry(field, normalizedUrl, normalizeUrl)
{
if (!normalizedUrl) {
return null
}
let inMemory = this._getBookmarkRulesetRows(field)
.find((entry) => !entry._optimistic &&
this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)
if (inMemory) {
return inMemory
}
if (!this._configurationManager.isStorageReady()) {
return null
}
try {
let rows = await this._configurationManager.getRepos().rulesetEntries.listAllForField(field.key)
return rows.find((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl) ?? null
} catch (error) {
console.log('[BrazenFramework] bookmark lookup failed:', error)
return null
}
}
/**
* @param {ConfigurationField} field
* @param {{label: string, tags: string, url: string}} payload
* @param {object} [entryMeta]
* @return {Promise<*>}
* @private
*/
async _persistBookmarkRulesetEntry(field, payload, entryMeta = {})
{
let template = typeof RulesetTemplateRegistry !== 'undefined' ?
RulesetTemplateRegistry.get('bookmarks') : null
if (!template) {
throw new Error('Bookmarks ruleset template unavailable')
}
let ctx = this._configurationManager._buildRulesetTemplateCtx(field)
return template.persist(payload, ctx, entryMeta)
}
/**
* Idempotent URL-keyed bookmark toggle (optimistic row mutate + widget render).
* Shared by single-tag and current-search dock bookmark paths.
* @param {{
* fieldKey?: string,
* url: string,
* tags?: string,
* formatLabel?: function(string): string,
* normalizeUrl?: function(string): string,
* requireTagsToAdd?: boolean,
* }} options
* @return {Promise<void>}
* @private
*/
async _toggleBookmarkAtUrl(options = {})
{
let fieldKey = options.fieldKey ?? 'bookmarks'
let field = this._configurationManager.getField(fieldKey)
let url = options.url
if (!field || !url) {
return
}
let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
let formatLabel = options.formatLabel ?? ((tags) => String(tags).replaceAll('_', ' '))
let tags = String(options.tags ?? '').trim()
let normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) {
return
}
let persistKey = `${fieldKey}\0url:${normalizedUrl}`
if (this._tagAttributePersistKeys.has(persistKey)) {
return
}
this._tagAttributePersistKeys.add(persistKey)
let rows = this._getBookmarkRulesetRows(field)
let previousRows = rows.slice()
let refreshBookmarkUi = () => {
field.rebuildRulesetTagIndex?.()
field.updateUserInterface?.()
}
let storageReady = this._configurationManager.isStorageReady()
// Sync-visible state — optimistic row mutate + widget render before any IDB await so tag
// shortcut stars and the open bookmarks panel flip immediately on click.
let syncEntry = rows.find((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl) ?? null
let syncBookmarked = !!syncEntry ||
(!storageReady && !!field.widget?.isCurrentPageBookmarked(url))
let removing = syncBookmarked
try {
if (removing) {
if (syncEntry) {
field._rulesetRows = rows.filter((entry) => entry.entryId !== syncEntry.entryId)
}
refreshBookmarkUi()
if (!storageReady) {
return
}
let existingEntry = await this._findPersistedBookmarkEntry(field, normalizedUrl, normalizeUrl)
if (!existingEntry) {
field._rulesetRows = previousRows
refreshBookmarkUi()
return
}
let entryId = existingEntry.entryId
if (entryId == null) {
await field.reload?.()
refreshBookmarkUi()
return
}
field._rulesetRows = this._getBookmarkRulesetRows(field)
.filter((entry) => entry.entryId !== entryId)
refreshBookmarkUi()
let removeId = Number.isFinite(Number(entryId)) ? Number(entryId) : entryId
await this._configurationManager.getRepos().rulesetEntries.remove(removeId)
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._configurationManager.getRepos(), field.key)
}
await field.getOptimized?.()
if (typeof RulesetMutationBus !== 'undefined') {
RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [removeId]})
}
this._configurationManager.notifyConfigurationChange(field.key, true)
refreshBookmarkUi()
return
}
if (options.requireTagsToAdd !== false && !tags) {
return
}
if (rows.some((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)) {
return
}
let label = formatLabel(tags)
let optimisticId = typeof crypto !== 'undefined' && crypto.randomUUID ?
crypto.randomUUID() :
String(Date.now()) + Math.random()
let optimistic = {
entryId: optimisticId,
payload: {label, tags, url},
rawLine: label,
sortOrder: 0,
_optimistic: true,
}
field._rulesetRows = [optimistic, ...rows]
refreshBookmarkUi()
if (!storageReady) {
return
}
let existingEntry = await this._findPersistedBookmarkEntry(field, normalizedUrl, normalizeUrl)
if (existingEntry) {
// Off-page row — toggle means remove, not add.
field._rulesetRows = previousRows.filter((entry) => entry.entryId !== existingEntry.entryId)
refreshBookmarkUi()
let entryId = existingEntry.entryId
if (entryId == null) {
await field.reload?.()
refreshBookmarkUi()
return
}
let removeId = Number.isFinite(Number(entryId)) ? Number(entryId) : entryId
await this._configurationManager.getRepos().rulesetEntries.remove(removeId)
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._configurationManager.getRepos(), field.key)
}
await field.getOptimized?.()
if (typeof RulesetMutationBus !== 'undefined') {
RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [removeId]})
}
this._configurationManager.notifyConfigurationChange(field.key, true)
refreshBookmarkUi()
return
}
// Resolve tag types before persisting the row. Shortcut (single-tag) bookmarks carry
// the row type directly; multi-tag search bookmarks defer to the consumer's optional
// `resolveTagTypes(tags, ctx)` hook (e.g. sidebar/registry/search lookup by their scheme).
await this._resolveBookmarkTagTypes(options, tags, url)
let row = await this._persistBookmarkRulesetEntry(field, {label, tags, url}, {sortOrder: 0})
field._rulesetRows = field._rulesetRows.filter((entry) => !entry._optimistic)
field.patchRow?.(row)
let sorted = await this._configurationManager._applyRulesetAutoSortIfEnabled(field)
if (!sorted) {
if (typeof compileRulesetField === 'function') {
await compileRulesetField(this._configurationManager.getRepos(), field.key)
}
await field.getOptimized?.()
}
if (typeof RulesetMutationBus !== 'undefined') {
RulesetMutationBus.notify({fieldKeys: [field.key], entryIds: [row.entryId]})
}
this._configurationManager.notifyConfigurationChange(field.key, true)
refreshBookmarkUi()
} catch (error) {
field._rulesetRows = previousRows
refreshBookmarkUi()
throw error
} finally {
this._tagAttributePersistKeys.delete(persistKey)
}
}
/**
* Resolve tag types into the registry for a bookmark being added. Combines the direct
* `ensureTypedTags` (single-tag shortcut) with the consumer's optional `resolveTagTypes`
* hook for multi-tag search bookmarks. The hook is site-specific (sidebar scrape, registry
* lookup, remote search, etc.); it receives the raw tags string and a context and returns
* `{name, type}[]` (or a promise of one). Failures are swallowed so a bookmark never blocks.
*
* @param {{normalize?: function(string): string, ensureTypedTags?: {name: string, type?: string|null}[], resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>)}} options
* @param {string} tags Raw (space-joined) bookmark tags.
* @param {string} url Bookmark URL.
* @return {Promise<void>}
* @private
*/
async _resolveBookmarkTagTypes(options, tags, url)
{
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let typedTags = []
if (Array.isArray(options.ensureTypedTags)) {
typedTags.push(...options.ensureTypedTags)
}
if (typeof options.resolveTagTypes === 'function') {
try {
let resolved = await options.resolveTagTypes(tags, {url, normalize})
if (Array.isArray(resolved)) {
typedTags.push(...resolved)
}
} catch (error) {
console.log('[BrazenFramework] resolveTagTypes failed:', error)
}
}
for (let typedTag of typedTags) {
if (typedTag?.name && typedTag?.type != null) {
await this._ensureTagEntityForAction(typedTag, normalize, 'bookmark')
}
}
}
/**
* Refresh tag-action chrome only on surfaces that reference the changed tags.
* Without detail.tags, all provided handlers run (safe full refresh).
*
* @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
* @param {{
* refreshSidebarRows?: function(string[]|null): void,
* refreshBookmarks?: function(): void,
* refreshDiscovery?: function(string[]|null): void,
* refreshDock?: function(): void,
* sidebarContainsTag?: function(string): boolean,
* discoveryContainsAnyTag?: function(string[]): boolean,
* }} [handlers]
*/
refreshTagActionSurfaces(detail = null, handlers = {})
{
let tags = Array.isArray(detail?.tags) ? detail.tags.filter(Boolean) : null
let scoped = !!(tags && tags.length)
let sidebarTags = null
if (!scoped) {
handlers.refreshSidebarRows?.(null)
} else if (typeof handlers.sidebarContainsTag === 'function') {
sidebarTags = tags.filter((tag) => handlers.sidebarContainsTag(tag))
if (sidebarTags.length) {
handlers.refreshSidebarRows?.(sidebarTags)
}
} else {
handlers.refreshSidebarRows?.(tags)
}
if (!scoped || this._configurationManager.rulesetContainsAnyTag(tags)) {
handlers.refreshBookmarks?.()
}
if (!scoped) {
handlers.refreshDiscovery?.(null)
} else if (typeof handlers.discoveryContainsAnyTag !== 'function' ||
handlers.discoveryContainsAnyTag(tags)) {
handlers.refreshDiscovery?.(tags)
}
if (!scoped || this._tagDetailNeedsDockRefresh(detail)) {
handlers.refreshDock?.()
}
}
/**
* @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 || !options.buildUrl) {
return false
}
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
let url = options.buildUrl(normalize(tagName))
let normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) {
return false
}
let rows = this._getBookmarkRulesetRows(field)
if (rows.some((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)) {
return true
}
return field.widget?.isCurrentPagePresent?.(url) ?? false
}
/**
* Whether the current search page URL is bookmarked (normalized URL identity).
* @param {{
* fieldKey?: string,
* getCurrentUrl?: function(): string,
* normalizeUrl?: function(string): string,
* }} [options]
* @return {boolean}
*/
isCurrentSearchBookmarked(options = {})
{
let fieldKey = options.fieldKey ?? 'bookmarks'
let field = this._configurationManager.getField(fieldKey)
if (!field) {
return false
}
let getCurrentUrl = options.getCurrentUrl ?? (() => location.href)
let url = getCurrentUrl()
let normalizeUrl = options.normalizeUrl ?? ((value) => String(value ?? '').trim())
let normalizedUrl = normalizeUrl(url)
if (!normalizedUrl) {
return false
}
let rows = this._getBookmarkRulesetRows(field)
if (rows.some((entry) => this._getBookmarkEntryUrl(entry, normalizeUrl) === normalizedUrl)) {
return true
}
return field.widget?.isCurrentPagePresent?.(url) ?? false
}
/**
* 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,
* tag?: {name: string, type?: string|null},
* resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
* }} options
* @return {Promise<void>}
*/
async toggleTagSearchBookmark(tagName, options = {})
{
if (!options.buildUrl) {
return
}
let normalize = options.normalize ?? ((value) => String(value ?? '').trim())
let tags = normalize(tagName)
// Single-tag shortcut bookmarks carry a known type — resolve it into the registry so
// the bookmarked tag colorizes/classifies (search-box URLs have no per-tag type).
let typedTag = options.tag ?? {name: tagName}
return this._toggleBookmarkAtUrl({
fieldKey: options.fieldKey ?? 'bookmarks',
url: options.buildUrl(tags),
tags,
formatLabel: options.formatLabel,
normalizeUrl: options.normalizeUrl,
requireTagsToAdd: true,
ensureTypedTags: typedTag?.type != null ? [typedTag] : null,
resolveTagTypes: options.resolveTagTypes,
normalize,
})
}
/**
* Toggle bookmark for the current search page. Decision / add / remove share one
* normalized URL identity (`normalizeUrl(getCurrentUrl())`); new rows store the raw
* current URL so dock/pageMatch flip active immediately. Empty tags = silent no-op
* when adding; duplicate URL = remove. No alerts.
* @param {{
* fieldKey?: string,
* getCurrentUrl?: function(): string,
* getTags?: function(): string,
* formatLabel?: function(string): string,
* normalizeUrl?: function(string): string,
* normalize?: function(string): string,
* resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
* }} [options]
* @return {Promise<void>}
*/
async toggleCurrentSearchBookmark(options = {})
{
let getCurrentUrl = options.getCurrentUrl ?? (() => location.href)
let getTags = options.getTags ?? (() => '')
let url = getCurrentUrl()
let tags = String(getTags() ?? '').trim()
return this._toggleBookmarkAtUrl({
fieldKey: options.fieldKey ?? 'bookmarks',
url,
tags,
formatLabel: options.formatLabel,
normalizeUrl: options.normalizeUrl,
normalize: options.normalize,
resolveTagTypes: options.resolveTagTypes,
requireTagsToAdd: true,
})
}
/**
* Enable one-click remove + optional row colors for tag ruleset compliance filters (Active Hide Rules).
* @param {{
* fieldKeys: string[],
* normalizeRuleLine: function(string): string,
* getRuleColor?: function(string, string): (string|null),
* }} options
*/
registerRemovableTagComplianceFilters(options)
{
let fieldKeys = options?.fieldKeys
if (!Array.isArray(fieldKeys) || !fieldKeys.length || typeof options.normalizeRuleLine !== 'function') {
return
}
this._removableTagComplianceFilters = {
fieldKeys: new Set(fieldKeys),
normalizeRuleLine: options.normalizeRuleLine,
getRuleColor: options.getRuleColor,
}
}
/**
* Register a framework-owned dock bookmark action field (state/tooltip/click).
* Consumer supplies site config only (`getTags`, URL helpers, `include`).
* @param {string} actionKey
* @param {{
* fieldKey?: string,
* title?: string,
* help?: string,
* getCurrentUrl?: function(): string,
* getTags?: function(): string,
* formatLabel?: function(string): string,
* normalizeUrl?: function(string): string,
* normalize?: function(string): string,
* resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
* include?: function(): boolean,
* tooltipOn?: string,
* tooltipOff?: string,
* }} [options]
* @return {ConfigurationField}
*/
registerCurrentSearchBookmarkDock(actionKey, options = {})
{
let bookmarkOptions = {
fieldKey: options.fieldKey ?? 'bookmarks',
getCurrentUrl: options.getCurrentUrl,
getTags: options.getTags,
formatLabel: options.formatLabel,
normalizeUrl: options.normalizeUrl,
normalize: options.normalize,
resolveTagTypes: options.resolveTagTypes,
}
return this._configurationManager.addActionField(actionKey).
setTitle(options.title ?? 'Bookmark Search').
setHelp(options.help ?? FRAMEWORK_FIELD_DETAILED_HELP.BOOKMARK_SEARCH).
setAction(() => {
// Optimistic row mutate + widget.render run sync before the first await.
let persist = this.toggleCurrentSearchBookmark(bookmarkOptions)
this._refreshDockButtonStates()
void persist.finally(() => this._refreshDockButtonStates())
return persist
}).
setDockButton({
icon: 'bookmark',
tooltip: () => this.isCurrentSearchBookmarked(bookmarkOptions) ?
(options.tooltipOn ?? 'Bookmarked — click to remove') :
(options.tooltipOff ?? 'Bookmark this search'),
getState: () => this.isCurrentSearchBookmarked(bookmarkOptions) ? 'bv-dock-btn-active' : '',
include: options.include,
})
}
/**
* Hide search tiles whose post id is below {@link OPTION_LAST_ID} when enabled.
* @param {{optionKey?: string, lastIdKey?: string, getPostId: function(HTMLElement): *, dockTemplate?: string}} options
* @return {BrazenFramework}
*/
registerHideOlderPostsFilter(options)
{
if (typeof options?.getPostId !== 'function') {
throw new Error('registerHideOlderPostsFilter() requires getPostId(item).')
}
let optionKey = options.optionKey ?? OPTION_HIDE_OLDER_POSTS
let lastIdKey = options.lastIdKey ?? OPTION_LAST_ID
let getPostId = options.getPostId
this._configurationManager.addFlagField(optionKey).
setTitle('Hide Older Posts').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.HIDE_OLDER_POSTS).
applyDockTemplate(options.dockTemplate ?? 'hideOlderPosts')
this._configurationManager.addNumberField(lastIdKey, 0, Number.MAX_SAFE_INTEGER).
setTitle('Last ID').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.LAST_ID)
this._addItemComplexComplianceFilter(
optionKey,
(enabled) => enabled && this._getConfig(lastIdKey) > 0,
(item) => {
let id = getPostId(item)
let lastId = this._getConfig(lastIdKey)
if (id === null || id === undefined || id >= lastId) {
return true
}
return {complies: false, rule: 'Post id < ' + lastId}
},
)
return this
}
/**
* Dock slide-out actions that capture Last ID from the current search page or media post.
* @param {{
* optionKey?: string,
* searchActionKey?: string,
* mediaActionKey?: string,
* onSetLatestFromSearch: function(): void,
* onSetLatestFromMedia: function(): void,
* searchInclude?: function(): boolean,
* mediaInclude?: function(): boolean,
* hideOlderInclude?: function(): boolean,
* }} options
* @return {BrazenFramework}
*/
registerHideOlderPostsDockActions(options)
{
if (typeof options?.onSetLatestFromSearch !== 'function' ||
typeof options?.onSetLatestFromMedia !== 'function') {
throw new Error('registerHideOlderPostsDockActions() requires onSetLatestFromSearch and onSetLatestFromMedia.')
}
let optionKey = options.optionKey ?? OPTION_HIDE_OLDER_POSTS
let searchActionKey = options.searchActionKey ?? DOCK_SET_LATEST_ID
let mediaActionKey = options.mediaActionKey ?? DOCK_SET_POST_ID
this._configurationManager.addActionField(searchActionKey).
setTitle('Set Latest ID').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.SET_LATEST_ID).
setAction(() => options.onSetLatestFromSearch()).
setDockButton({
icon: 'target',
tooltip: 'Capture highest post id as Last ID',
include: options.searchInclude,
})
this._configurationManager.addActionField(mediaActionKey).
setTitle('Set Post ID').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.SET_POST_ID).
setAction(() => options.onSetLatestFromMedia()).
setDockButton({
icon: 'target',
tooltip: 'Capture this post id as Last ID',
include: options.mediaInclude,
})
this._configurationManager.getField(optionKey)?.
setDockSlideOut([searchActionKey, mediaActionKey]).
setDockButton({
include: options.hideOlderInclude,
})
return this
}
/**
* When enabled, advance to the next search page after compliance hides every tile on the current page.
* @param {{optionKey?: string, dockTemplate?: string, nextPageSelector?: string, pageGuard?: function(): boolean, onAfterCompliance?: function(): void}} [options]
* @return {BrazenFramework}
*/
registerAutoNextPageFilter(options = {})
{
let optionKey = options.optionKey ?? OPTION_AUTO_NEXT_PAGE
this._configurationManager.addFlagField(optionKey).
setTitle('Auto Next Page').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.AUTO_NEXT_PAGE).
applyDockTemplate(options.dockTemplate ?? 'autoNextPage')
if (typeof options.onAfterCompliance === 'function') {
this._onAfterComplianceRun.push(options.onAfterCompliance)
} else if (options.nextPageSelector) {
let selector = options.nextPageSelector
let pageGuard = options.pageGuard
this._onAfterComplianceRun.push(() => {
if (typeof pageGuard === 'function' && !pageGuard.call(this)) {
return
}
if (this._getConfig(optionKey) && document.querySelectorAll('.' + CLASS_COMPLIANT_ITEM).length === 0) {
document.querySelector(selector)?.click()
}
})
}
return this
}
/**
* Left / right arrow keys navigate paginator prev/next links (skips form fields and modifier keys).
* @param {{
* pages?: string|string[],
* backSelector: string,
* nextSelector: string,
* }} options
* @return {this}
*/
registerPaginatorKeyboardNav(options = {})
{
let backSelector = options.backSelector
let nextSelector = options.nextSelector
if (!backSelector || !nextSelector) {
throw new Error('registerPaginatorKeyboardNav requires backSelector and nextSelector')
}
let handler = (event) => {
if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) {
return
}
let target = event.target
if (target instanceof HTMLElement &&
(target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) {
return
}
let selector = event.key === 'ArrowLeft' ? backSelector
: event.key === 'ArrowRight' ? nextSelector
: null
if (!selector) {
return
}
let link = document.querySelector(selector)
if (link) {
event.preventDefault()
link.click()
}
}
let setup = () => this._wirePaginatorKeyboardNav(handler)
if (options.pages) {
this._forPage(options.pages, setup)
} else {
setup()
}
return this
}
/**
* @param {function(KeyboardEvent): void} handler
* @private
*/
_wirePaginatorKeyboardNav(handler)
{
this._paginatorKeyboardNavAbort?.abort()
let controller = new AbortController()
this._paginatorKeyboardNavAbort = controller
document.addEventListener('keydown', handler, {signal: controller.signal})
}
/**
* @param {string} tagName
* @param {{
* buildUrl: function(string): string,
* formatLabel?: function(string): string,
* normalizeUrl?: function(string): string,
* fieldKey?: string,
* normalize?: function(string): string,
* tag?: {name: string, type?: string|null},
* resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>),
* className?: string,
* iconClass?: string,
* onAfterToggle?: function(): void,
* }} options
* @return {HTMLElement|null}
*/
createTagBookmarkActionButton(tagName, options = {})
{
let fieldKey = options.fieldKey ?? 'bookmarks'
if (!this._configurationManager.hasField(fieldKey) || !options.buildUrl) {
return null
}
let iconClass = options.iconClass ?? 'bv-tag-action-icon'
let bookmarked = this.isTagSearchBookmarked(tagName, options)
let pending = false
return BrazenViewLayer.createTagAttributeActionButton({
title: bookmarked ? 'Remove bookmark' : 'Bookmark',
icon: BrazenViewLayer.createTagBookmarkStarIcon(bookmarked, iconClass),
className: options.className ?? 'bv-tag-action-btn',
onClick: (event) => {
if (pending) {
return
}
pending = true
let button = event.currentTarget
bookmarked = !bookmarked
BrazenViewLayer.setTagBookmarkStarFilled(button, bookmarked, iconClass)
button.title = bookmarked ? 'Remove bookmark' : 'Bookmark'
// Row mutate + widget.render run sync before the first await; rebuild chrome after.
let persist = this.toggleTagSearchBookmark(tagName, options)
options.onAfterToggle?.()
void persist.finally(() => {
pending = false
let actual = this.isTagSearchBookmarked(tagName, options)
if (actual !== bookmarked) {
bookmarked = actual
BrazenViewLayer.setTagBookmarkStarFilled(button, bookmarked, iconClass)
button.title = bookmarked ? 'Remove bookmark' : 'Bookmark'
}
options.onAfterToggle?.()
})
},
})
}
/**
* Append framework-owned tag attribute actions (bookmark, ignore, substitute, blacklist, explore).
* Consumer supplies incidence options (`buildUrl`, field keys, CSS classes, ensureOptionKey).
* @param {HTMLElement} 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, resolveTagTypes?: function(string, {url: string, normalize: function(string): string}): ({name: string, type?: string|null}[]|Promise<{name: string, type?: string|null}[]>)}|false,
* ignore?: {fieldKey?: string}|false,
* substitute?: {fieldKey?: string}|false,
* blacklist?: {fieldKey?: string, ensureOptionKey?: string}|false,
* explore?: {fieldKey?: string, ensureOptionKey?: string}|false,
* onAfterToggle?: function(): void,
* }} [config]
*/
appendTagAttributeActions(actionsElement, tag, config = {})
{
if (!actionsElement || !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,
// Carry the row's tag type so a shortcut bookmark resolves it into the registry.
tag,
className,
iconClass: config.bookmark.iconClass ?? iconClass,
// Bookmark toggles notify `bookmarks`, not `tags` — allow a dedicated after hook.
onAfterToggle: config.bookmark.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'
if (this._configurationManager.hasField(exploreKey)) {
buttons.push(this.createTagSoleAttributeActionButton(tag.name, {
kind: 'explore',
fieldKey: exploreKey,
ensureOptionKey: config.explore?.ensureOptionKey,
normalize,
tag,
className,
iconClass,
onAfterToggle: after,
}))
}
}
Utilities.appendChildren(actionsElement, buttons)
}
/**
* Remediation-only blacklist/explore buttons for a bookmarks-panel row.
* Not the full sidebar/discovery action set — shows status when the bookmark
* already matches blacklist/explore (sole or combo), with one-click remove
* for the single-tag sole case.
*
* @param {{tags?: string}} bookmark
* @param {{
* tokenize: function(string): string[],
* normalize?: function(string): string,
* className?: string,
* iconClass?: string,
* blacklist?: {fieldKey?: string}|false,
* explore?: {fieldKey?: string}|false,
* onAfterToggle?: function(): void,
* }} config
* @return {HTMLElement[]}
*/
createBookmarkRowAttributeActions(bookmark, config = {})
{
if (!bookmark || typeof config.tokenize !== 'function') {
return []
}
let normalize = config.normalize ?? ((value) => String(value ?? '').trim())
let className = config.className ?? 'bv-ruleset-panel-action'
let iconClass = config.iconClass ?? 'bv-tag-action-icon'
let after = config.onAfterToggle
let tokens = config.tokenize(bookmark.tags ?? '')
if (!Array.isArray(tokens)) {
return []
}
let tagNames = tokens.map((token) => normalize(token)).filter(Boolean)
let actions = []
if (config.blacklist !== false && tagNames.length) {
let blacklistKey = config.blacklist?.fieldKey ?? FILTER_TAG_BLACKLIST
if (this._configurationManager.hasField(blacklistKey)) {
let verdict = this._configurationManager.evaluateTagCompliance(tagNames, blacklistKey)
// Sole check also covers cache-miss via hasTagSoleAttribute → ruleset fallback.
let soleRemovable = tokens.length === 1 && this.hasTagSoleAttributeActive(tagNames[0], {
fieldKey: blacklistKey,
normalize,
})
if (!verdict.complies || soleRemovable) {
if (soleRemovable) {
let tag = tagNames[0]
let pending = false
actions.push(BrazenViewLayer.createTagAttributeActionButton({
className,
title: 'Remove tag from blacklist',
icon: BrazenViewLayer.createTagBlacklistIcon(true, iconClass),
onClick: () => {
if (pending) {
return
}
pending = true
let persist = this.toggleTagSoleAttribute(tag, {
fieldKey: blacklistKey,
normalize,
source: 'sidebar',
})
after?.()
void persist.catch(() => after?.()).finally(() => {
pending = false
})
},
}))
} else {
actions.push(BrazenViewLayer.createTagAttributeActionButton({
className,
title: 'Blacklisted by rule: ' + (verdict.rule ?? 'tag blacklist'),
icon: BrazenViewLayer.createTagBlacklistIcon(true, iconClass),
onClick: () => {},
}))
}
}
}
}
if (config.explore !== false && tagNames.length) {
let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
if (this._configurationManager.hasField(exploreKey)) {
let verdict = this._configurationManager.evaluateTagCompliance(tagNames, exploreKey)
let soleRemovable = tokens.length === 1 && this.hasTagSoleAttributeActive(tagNames[0], {
fieldKey: exploreKey,
normalize,
})
if (!verdict.complies || soleRemovable) {
if (soleRemovable) {
let tag = tagNames[0]
let pending = false
actions.push(BrazenViewLayer.createTagAttributeActionButton({
className,
title: 'Remove tag from explore list',
icon: BrazenViewLayer.createTagExploreIcon(true, iconClass),
onClick: () => {
if (pending) {
return
}
pending = true
let persist = this.toggleTagSoleAttribute(tag, {
fieldKey: exploreKey,
normalize,
source: 'sidebar',
})
after?.()
void persist.catch(() => after?.()).finally(() => {
pending = false
})
},
}))
} else {
actions.push(BrazenViewLayer.createTagAttributeActionButton({
className,
title: 'Explored by rule: ' + (verdict.rule ?? 'explore list'),
icon: BrazenViewLayer.createTagExploreIcon(true, iconClass),
onClick: () => {},
}))
}
}
}
}
return actions
}
/**
* Prefetch tag cache + compliance specs for visible bookmark rows so attribute
* status icons do not false-negative on cold cache / late specs. Self-heals via
* config.onReady when rows or specs newly load. In-flight guards prevent duplicate
* work; names are re-fetched after eviction / clearCache.
*
* @param {Array<{tags?: string}>} bookmarks
* @param {{
* tokenize: function(string): string[],
* normalize?: function(string): string,
* onReady?: function(): void,
* attemptedKey?: string,
* blacklist?: {fieldKey?: string}|false,
* explore?: {fieldKey?: string}|false,
* }} config
* @return {Promise<void>}
*/
async prepareBookmarkRowAttributeData(bookmarks, config = {})
{
let tagRuntime = this._configurationManager.getTagRuntime()
if (!tagRuntime || typeof config.tokenize !== 'function' || !Array.isArray(bookmarks) || !bookmarks.length) {
return
}
let normalize = config.normalize ?? ((value) => String(value ?? '').trim())
let attemptedKey = config.attemptedKey ?? 'default'
let inFlight = this._bookmarkRowAttributePrefetchInFlight.get(attemptedKey)
if (!inFlight) {
inFlight = new Set()
this._bookmarkRowAttributePrefetchInFlight.set(attemptedKey, inFlight)
}
let fieldKeys = []
if (config.blacklist !== false) {
let blacklistKey = config.blacklist?.fieldKey ?? FILTER_TAG_BLACKLIST
if (this._configurationManager.hasField(blacklistKey)) {
fieldKeys.push(blacklistKey)
}
}
if (config.explore !== false) {
let exploreKey = config.explore?.fieldKey ?? 'explored-tags-tracker'
if (this._configurationManager.hasField(exploreKey)) {
fieldKeys.push(exploreKey)
}
}
let itemTagNameLists = []
let names = []
let seen = new Set()
for (let bookmark of bookmarks) {
let tokens = config.tokenize(bookmark?.tags ?? '')
if (!Array.isArray(tokens)) {
continue
}
let rowNames = []
for (let token of tokens) {
let name = normalize(token)
if (!name) {
continue
}
rowNames.push(name)
if (seen.has(name) || inFlight.has(name)) {
continue
}
seen.add(name)
if (this._configurationManager.getCachedTagEntry(name)) {
continue
}
names.push(name)
}
if (rowNames.length) {
itemTagNameLists.push(rowNames)
}
}
let specsMissing = fieldKeys.some((fieldKey) => !this._configurationManager.getTagComplianceSpec(fieldKey))
let specFlightKey = '__compliance-specs__'
let refreshingSpecs = specsMissing && !inFlight.has(specFlightKey)
if (!names.length && !refreshingSpecs) {
return
}
for (let name of names) {
inFlight.add(name)
}
if (refreshingSpecs) {
inFlight.add(specFlightKey)
}
try {
let specsLoaded = false
if (refreshingSpecs) {
await this._configurationManager.refreshTagComplianceSpecs(fieldKeys)
specsLoaded = fieldKeys.some((fieldKey) => !!this._configurationManager.getTagComplianceSpec(fieldKey))
}
let specs = fieldKeys.map((fieldKey) => this._configurationManager.getTagComplianceSpec(fieldKey)).
filter(Boolean)
if (itemTagNameLists.length && specs.length) {
await tagRuntime.ensureComplianceLookups(itemTagNameLists, specs)
} else if (names.length) {
await tagRuntime.ensureNames(names)
}
let namesLoaded = names.some((name) => this._configurationManager.getCachedTagEntry(name))
if (specsLoaded || namesLoaded) {
config.onReady?.()
}
} catch (_) {
return
} finally {
for (let name of names) {
inFlight.delete(name)
}
if (refreshingSpecs) {
inFlight.delete(specFlightKey)
}
}
}
/**
* @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,
* settingsPanelWidth?: number,
* 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,
settingsPanelWidth: typeof config.settingsPanelWidth === 'number' ?
Math.max(150, Math.round(config.settingsPanelWidth)) :
undefined,
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 (!this._configurationManager.getField(OPTION_AUTO_HIDE_SETTINGS_PANE)) {
this._configurationManager.addFlagField(OPTION_AUTO_HIDE_SETTINGS_PANE).
setTitle('Auto-Hide Settings Panel').
setHelp(FRAMEWORK_FIELD_DETAILED_HELP.AUTO_HIDE_SETTINGS_PANE)
}
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** /
* **Done — resume — close tab** on this media page; standalone browsing shows
* **Done — reload**. Requires {@link configureDownloadManager}.
* @param {{title?: string, message?: string, confirmLabel?: string, confirmAndCloseLabel?: string}} [options]
* @return {Promise<void>}
*/
handleMediaCloudflarePage(options = {})
{
return this._downloadManager?.handleMediaCloudflarePage(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()
}
/**
* Append post ids to the download duplicate ledger (merge; existing ids kept).
*
* @param {Iterable<string|number|null|undefined>} ids
* @return {Promise<void>}
*/
async importDownloadLedgerIds(ids)
{
let field = this._getDownloadDuplicateLedgerField()
if (!field) {
return 0
}
let cm = this._configurationManager
let batch = []
let written = 0
let started = false
let ensureStarted = async () => {
if (!started) {
await cm.beginDownloadLedgerFolderImport(false)
started = true
}
}
for (let raw of ids) {
if (raw === null || raw === undefined || !String(raw).length) {
continue
}
await ensureStarted()
batch.push(String(raw).trim())
if (batch.length >= DOWNLOAD_LEDGER_IMPORT_BATCH_SIZE) {
written += await cm.mergeDownloadLedgerImportBatch(batch)
batch = []
await this._yieldDownloadLedgerImportTurn()
}
}
if (!started) {
return 0
}
if (batch.length) {
written += await cm.mergeDownloadLedgerImportBatch(batch)
}
await cm.finalizeDownloadLedgerFolderImport()
return written
}
/**
* Replace the download duplicate ledger with the supplied post ids.
*
* @param {Iterable<string|number|null|undefined>} ids
* @return {Promise<number>} count of ids written
*/
replaceDownloadLedgerIds(ids)
{
return this._configurationManager.replaceDownloadLedgerIds(ids)
}
/**
* 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()
}
/**
* Clear {@link TagEntry.isDiscovered} for all tags (`true` → `null`). Types and rulesets unchanged.
*
* @param {function({current?: number, total?: number, label?: string}): void|Promise<void>|null} [onProgress]
* @return {Promise<{updated: number, total: number}>}
*/
resetAllTagsDiscovered(onProgress = null)
{
return this._configurationManager.resetAllTagsDiscovered(onProgress)
}
/**
* @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
}
/**
* True when the active Download Manager page has the `dashboard` role — a dedicated
* control / future-analytics host with page-oriented dock actions gated off.
* Does not claim or prefer processor leadership; crown / sole-tab rules unchanged.
* @return {boolean}
*/
isDashboardPage()
{
return this.isDownloadPageRole('dashboard')
}
/**
* @return {boolean}
*/
isDownloadManagerEnabled()
{
return this._downloadManager?.isDownloadManagerEnabled() ?? false
}
/**
* @return {boolean}
*/
isDownloadManagerLeaderTab()
{
return this._downloadManager?.isDownloadManagerLeaderTab() ?? false
}
/**
* Take download-manager processor leadership when idle.
* @return {Promise<boolean>}
*/
requestDownloadManagerLeadership()
{
return this._downloadManager?.requestDownloadManagerLeadership() ?? Promise.resolve(false)
}
}
/** @type {Readonly<Record<string, string>>} */
BrazenFramework.FIELD_DETAILED_HELP = FRAMEWORK_FIELD_DETAILED_HELP
BrazenFramework.DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN = DOWNLOAD_PATTERN_CHIP_REGEX_BY_TOKEN
BrazenFramework.DEFAULT_DOWNLOAD_PATTERN_SECTIONS = DEFAULT_DOWNLOAD_PATTERN_SECTIONS
BrazenFramework.DEFAULT_LEDGER_IMPORT_LABEL_TO_REGEX = DEFAULT_LEDGER_IMPORT_LABEL_TO_REGEX
BrazenFramework.buildLedgerImportLabelToRegex = buildLedgerImportLabelToRegex
BrazenFramework.rulesetFieldSeedFromSpec = rulesetFieldSeedFromSpec