Cross-tab download queue, resolution pipeline, and immediate downloads for Brazen user scripts
이 스크립트는 직접 설치하는 용도가 아닙니다. 다른 스크립트에서 메타 지시문 // @require https://update.greasyfork.org/scripts/587126/1909491/Brazen%20Framework%20-%20Download%20Manager.js을(를) 사용하여 포함하는 라이브러리입니다.
// ==UserScript==
// @name Brazen Framework - Download Manager
// @namespace brazenvoid
// @version 4.0.0
// @author brazenvoid
// @license GPL-3.0-only
// @description Cross-tab download queue and resolution pipeline for Brazen user scripts
// @grant GM_download
// ==/UserScript==
// @ts-nocheck
/** Build id for Tampermonkey Resource-override checks (must match local `base-scripts` file). */
const BRAZEN_DOWNLOAD_MANAGER_BUILD = 'local-3.0.0'
/** @typedef {'resolution'|'download'} BrazenDownloadManagerLaneId */
/** @typedef {{itemId: string|null, promptTabId: string|null, openUrl: string|null, at: number}} BrazenHumanInteractionLane */
/** @typedef {{resolution: BrazenHumanInteractionLane|null, download: BrazenHumanInteractionLane|null}} BrazenHumanInteractionMap */
const OPTION_ENABLE_DOWNLOAD_MANAGER = 'enable-download-manager'
/** Behaviours: search pages open with selection mode active (toggleable from the dock per visit). */
const OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT = 'download-selection-mode-default'
/** Behaviours: reopen tag discovery when ignored tags empty filename pins. */
const OPTION_REVIEW_IGNORED_FILENAME_PINS = 'review-ignored-filename-pins'
/** Behaviours: skip batch downloads with no tags for any filename/subfolder tag-type pin. */
const OPTION_SKIP_EMPTY_FILENAME_PINS = 'skip-empty-filename-pins'
/** Behaviours: defer tag-discovery review until the resolution queue drains (unattended batches). */
const OPTION_DEFER_TAG_DISCOVERY = 'defer-tag-discovery-unattended'
/** Canonical config field keys for download-path settings (SSOT for DM registration). */
const BRAZEN_DOWNLOAD_PATH_FIELD_KEYS = Object.freeze({
folder: 'download-folder',
filenamePattern: 'filename-pattern',
subfolderPattern: 'subfolder-pattern',
substitutions: 'filename-tag-substitutions',
tagIgnore: 'filename-tag-ignore-list',
stripCharacterSeries: 'strip-series-from-character-tags',
})
const DOCK_TAG_DISCOVERY_MODE = 'dock-tag-discovery-mode'
const DOCK_SELECTION_MODE = 'dock-selection-mode'
const DOCK_SELECTION_SELECT_ALL = 'dock-selection-select-all'
const DOCK_ADD_TO_QUEUE = 'dock-add-to-queue'
const DOCK_DOWNLOAD_START_PAUSE = 'dock-download-start-pause'
const DOCK_CLEAR_DOWNLOAD_QUEUE = 'dock-clear-download-queue'
/** Max similar tags shown in the discovery panel. */
const SIMILAR_DISCOVERY_LIMIT = 10
/** Page size when scanning registry tags by type for similarity. */
const SIMILAR_DISCOVERY_PAGE_SIZE = 200
// Shared with BrazenIndexedDBStorage (loaded before this module).
const RESOLUTION_TERMINAL = RESOLUTION_QUEUE_TERMINAL_SET
const DOWNLOAD_TERMINAL = DOWNLOAD_QUEUE_TERMINAL_SET
/** Reactor bridge command — wake coordinator pipeline pump (BroadcastChannel). */
const REACTOR_PIPELINE_PUMP = 'pipeline-pump'
/** DM state keys published as coordinator patch stream for follower dock progress. */
const DM_STATE_PATCH_FIELDS = Object.freeze([
'completedResolutionCount',
'completedDownloadCount',
'paused',
'resolutionBlocked',
'discoveryLanePhaseActive',
])
/** dm.state keys that drive follower HI / tag-discovery panel restore. */
const DM_STATE_GATE_PATCH_FIELDS = Object.freeze([
'resolutionBlocked',
'discoveryPanelTags',
'discoveryPanelKnownTags',
'discoveryReviewMode',
'tagDiscoveryPanelTabId',
'humanInteraction',
])
/** Coalesce bulk-enqueue pipeline-pump Commands on the Reactor bus. */
const PROCESSORS_WAKE_SIGNAL_DEBOUNCE_MS = 150
/** Coalesce stacked wake resumes on the receiving tab. */
const PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS = 150
/** Coalesce runtime pipeline store writes into one read-only DM UI refresh. */
const RUNTIME_UI_REFRESH_DEBOUNCE_MS = 50
/** Backoff before retrying follower snapshot handshake when coordinator is slow to answer. */
const FOLLOWER_SNAPSHOT_RETRY_MS = 500
/** DM runtime IDB stores — high-frequency pipeline writes; not config-domain UI reactions. */
if (!globalThis.__brazenRuntimePipelineConfigSources) {
globalThis.__brazenRuntimePipelineConfigSources = new Set([
'downloadResolutionQueue',
'downloadQueue',
'downloadManagerState',
])
}
/**
* Default detailed help HTML for Download Manager settings and dock actions.
* Consumer scripts may override per field via {@link ConfigurationField#setHelp}.
*
* @type {Readonly<Record<string, string>>}
*/
const DOWNLOAD_MANAGER_FIELD_DETAILED_HELP = Object.freeze({
ENABLE_DOWNLOAD_MANAGER:
'<p>Shows download-manager dock controls (selection mode, queue start/pause, tag discovery, and related actions). ' +
'When off, batch enqueue and queue processing UI are hidden.</p>',
SELECTION_MODE_DEFAULT:
'<p>When enabled, search pages open with selection mode active so you can click posts to enqueue downloads.</p>' +
'<p>Turn selection on/off from the dock for each visit without changing this default. Workflow:</p>' +
'<ol><li>Click each media thumb you want — tiles show queue progress.</li>' +
'<li>When items are Ready, use <strong>Download Queue Start/Pause</strong>.</li></ol>',
REVIEW_IGNORED_FILENAME_PINS:
'<p>When a post would download with an empty filename or folder segment because all matching tags are on the Filename Tag Ignore List, ' +
'Tag Discovery opens with those tags and similar suggestions so you can substitute aliases or remove ignores before the save continues.</p>' +
'<p>Runs after unknown-tag review, or alone when there are no unknown tags.</p>',
SKIP_EMPTY_FILENAME_PINS:
'<p>Skips enqueue/download when your filename or subfolder pattern expects tag-type tokens but the post provides none for those slots. ' +
'The Filename Tag Ignore List does not affect this check. Patterns without tag slots are unaffected.</p>',
DEFER_TAG_DISCOVERY:
'<p>For unattended batches: posts needing tag review wait in a discovery lane until ordinary resolution finishes. ' +
'The resolution progress bar counts both lanes. New enqueue items wait while discovery review is active. Requires Tag Discovery on the dock.</p>',
DOCK_TAG_DISCOVERY_MODE:
'<p>Pauses resolution when a post has tags not yet in your database. Review mappings in the discovery panel, then confirm to continue. ' +
'Downloads already marked Ready keep going unless blocked by other review steps.</p>',
DOCK_SELECTION_MODE:
'<p>Toggle on search pages to click posts for batch enqueue. Selected items enter the resolution queue; click again to remove. ' +
'Works with Start in Selection Mode under Behaviours.</p>',
DOCK_SELECTION_SELECT_ALL:
'<p>While selection mode is on, enqueue every visible search tile on this page that is not already in the download pipeline.</p>',
DOCK_ADD_TO_QUEUE:
'<p>On media pages, enqueue or dequeue the open post for resolution/download processing. ' +
'The button state shows whether this post is already queued.</p>',
DOCK_CLEAR_DOWNLOAD_QUEUE:
'<p>Clears pending download saves only. Items still resolving, tag-discovery review, and selection-mode state are unchanged.</p>',
DOCK_DOWNLOAD_START_PAUSE:
'<p>Controls saving of Ready downloads only. Resolution and tag discovery continue unless rate-limited or blocked by review.</p>',
DOWNLOAD_FOLDER:
'<p>Root folder all downloads are saved under. It can be renamed but not removed — clearing it restores the default.</p>' +
'<p>Subfolder and filename patterns build paths inside this folder under your browser download location.</p>',
FILENAME_PATTERN:
'<p>Build the saved file name from the pattern field below. The file extension is added automatically.</p>' +
'<p>Tag substitutions and the Filename Tag Ignore List apply before the path is resolved. ' +
'When a tag-type segment has nothing to use, that part becomes <code>unknown</code>.</p>',
SUBFOLDER_PATTERN:
'<p>Optional path segments inside the download folder. Use the pattern field and <code>/</code> between folder levels.</p>' +
'<p>Leave empty to save directly under the download folder.</p>',
TAG_SUBSTITUTIONS:
'<p>Rename tags for filename and subfolder tokens (Subject → Alias). Use underscores, not spaces.</p>' +
'<p>Add rules in the panel below. Scripts with sidebar tag actions may also add rules from there.</p>' +
'<p>The same mappings can appear in Tag Discovery while resolution is paused for review.</p>',
TAG_IGNORE:
'<p>Tags excluded from tag-type tokens in file names. One tag per row (underscores, not spaces).</p>' +
'<p>Ignored tags stay out of Artists, Characters, and other filename tokens.</p>',
STRIP_CHARACTER_SERIES:
'<p>Shortens disambiguated character tags in filename and subfolder tokens only — for example ' +
'<code>sciel_(clair_obscur_expedition_33)</code> → <code>sciel</code>.</p>' +
'<p>Does not change search tags or compliance rules.</p>',
})
class BrazenDownloadManager
{
// -------------------------------------------------------------------------
// Static public methods
// -------------------------------------------------------------------------
/**
* Prefix-scoped localStorage / sessionStorage key for Download Manager cross-tab signals.
* @param {string|null|undefined} scriptPrefix
* @param {string} suffix
* @return {string}
*/
static storageKey(scriptPrefix, suffix)
{
return (scriptPrefix ?? 'brazen-') + suffix
}
/** Phase-1 HI static API. */
/** Default soft-expiry TTL before coordinator clears an HI lane. */
static SOFT_EXPIRE_MS = 120000
/** Periodic panel restore / stale promptTabId reclaim interval. */
static WATCHDOG_MS = 6000
/**
* Lane entry from DM state (`humanInteraction[ctx]`).
* @param {object|null|undefined} state
* @param {BrazenHumanInteractionLaneId} ctx
* @return {BrazenHumanInteractionLane|null}
*/
static _hiLaneState(state, ctx)
{
if (!state) {
return null
}
let map = state.humanInteraction
if (map && typeof map === 'object' && map[ctx]) {
return map[ctx]
}
return null
}
/**
* True when any per-lane human-interaction entry is set.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
static _anyHumanInteractionState(state)
{
if (!state) {
return false
}
let map = state.humanInteraction
return !!(map && (map.resolution || map.download))
}
/**
* True when this script's shared human-interaction block is mirrored in localStorage.
* Usable in Phase-1 page ops before Download Manager initialize().
* @param {string|null|undefined} scriptPrefix
* @return {boolean}
*/
static peekHumanInteractionBlockedMirror(scriptPrefix)
{
try {
return localStorage.getItem(BrazenDownloadManager.storageKey(scriptPrefix, 'dm-human-interaction-blocked')) === '1'
} catch (e) {
return false
}
}
/**
* True when any per-lane human-interaction entry is set (or legacy single-slot block).
* Usable in Phase-1 before Download Manager initialize() — survives CF stripping
* `brazen_hi` and Chrome's default `noopener` on `window.open`.
* @param {string|null|undefined} scriptPrefix
* @return {Promise<boolean>}
*/
static async peekHumanInteractionBlockedIdb(scriptPrefix)
{
let prefix = BrazenDownloadManager.storageKey(scriptPrefix, '')
try {
if (typeof BrazenStorageRepositories !== 'function') {
return false
}
let repos = new BrazenStorageRepositories(prefix)
if (!repos.storage.available) {
return false
}
await repos.storage.open()
let state = await repos.downloadManagerState.get()
return BrazenDownloadManager._anyHumanInteractionState(state)
} catch (e) {
return false
}
}
/**
* Lane ids that currently have a human-interaction block.
* @param {object|null|undefined} state
* @return {BrazenHumanInteractionLaneId[]}
* @private
*/
static _hiContextsState(state)
{
/** @type {BrazenHumanInteractionLaneId[]} */
let out = []
if (BrazenDownloadManager._hiLaneState(state, 'resolution')) {
out.push('resolution')
}
if (BrazenDownloadManager._hiLaneState(state, 'download')) {
out.push('download')
}
return out
}
/**
* Read `brazen_hi_ctx` from the live URL (query or hash).
* @return {BrazenHumanInteractionLaneId|null}
* @private
*/
static _peekHumanInteractionContextFromLocation()
{
try {
let liveParams = new URLSearchParams(location.search)
let fromQuery = liveParams.get('brazen_hi_ctx')
if (fromQuery === 'download' || fromQuery === 'resolution') {
return fromQuery
}
let hash = location.hash || ''
let match = /(?:^|[&#?])brazen_hi_ctx=(resolution|download)\b/.exec(hash)
if (match) {
return /** @type {BrazenHumanInteractionLaneId} */ (match[1])
}
} catch (e) {
// ignore
}
return null
}
/**
* When `brazen_hi_ctx` was stripped, pick the blocked lane that matches this challenge page.
* @param {object|null|undefined} state
* @return {BrazenHumanInteractionLaneId|null}
* @private
*/
static _resolveChallengeHumanInteractionContext(state)
{
let contexts = BrazenDownloadManager._hiContextsState(state)
if (!contexts.length) {
return null
}
if (contexts.length === 1) {
return contexts[0]
}
// Prefer the lane whose openUrl points at this document (sans brazen_hi markers).
let current = null
try {
current = new URL(location.href)
current.searchParams.delete('brazen_hi')
current.searchParams.delete('brazen_hi_ctx')
current.hash = ''
} catch (e) {
current = null
}
if (current) {
for (let ctx of contexts) {
let openUrl = BrazenDownloadManager._hiLaneState(state, ctx)?.openUrl
if (!openUrl) {
continue
}
try {
let marked = new URL(openUrl, location.href)
marked.searchParams.delete('brazen_hi')
marked.searchParams.delete('brazen_hi_ctx')
marked.hash = ''
if (marked.origin === current.origin && marked.pathname === current.pathname) {
return ctx
}
} catch (e) {
// ignore bad openUrl
}
}
}
return contexts[0]
}
/**
* True when the URL still carries a queue-verification marker (`brazen_hi` query or hash).
* @return {boolean}
*/
static hasBrazenHumanInteractionMarker()
{
try {
let liveParams = new URLSearchParams(location.search)
if (liveParams.get('brazen_hi') === '1') {
return true
}
let hash = location.hash || ''
if (/(^|[&#?])brazen_hi=1\b/.test(hash) || hash === '#brazen_hi') {
return true
}
} catch (e) {
// ignore
}
return false
}
/**
* True when this tab was opened by same-origin `window.open` / `<a rel="opener">`
* (queue verification). Chrome's default `noopener` on `window.open(_blank)` breaks this
* unless the opener used {@link BrazenDownloadManager#_openHumanInteractionTab}.
* @return {boolean}
*/
static isQueueOpenedVerificationTab()
{
try {
let opener = window.opener
if (!opener || opener.closed) {
return false
}
void opener.location.href
return true
} catch (e) {
return false
}
}
/**
* Whether a mediaCloudflare / challenge page should stay silent (queue owns the leader pane).
* Fail closed toward silence. Usable in Phase-1 before `initialize()`.
* @param {string|null|undefined} scriptPrefix
* @param {BrazenDownloadManager|null|undefined} [instance] optional live instance for sync state
* @return {Promise<boolean>}
*/
static async shouldSilenceMediaCloudflarePrompt(scriptPrefix, instance = null)
{
if (BrazenDownloadManager.hasBrazenHumanInteractionMarker()) {
return true
}
if (BrazenDownloadManager.peekHumanInteractionBlockedMirror(scriptPrefix)) {
return true
}
if (instance?.isHumanInteractionBlockedSync?.()) {
return true
}
if (BrazenDownloadManager.isQueueOpenedVerificationTab()) {
return true
}
if (await BrazenDownloadManager.peekHumanInteractionBlockedIdb(scriptPrefix)) {
return true
}
return false
}
// -------------------------------------------------------------------------
// Constructor
// -------------------------------------------------------------------------
/**
* @param {BrazenFramework} framework
* @param {BrazenConfigurationManager} configurationManager
* @param {object} config
*/
constructor(framework, configurationManager, config)
{
this._framework = framework
this._cm = configurationManager
this._config = config
this._tabId = Utilities.generateId('dm-tab-')
/** @type {boolean} Reactor scheduler `linkQueues` mirrors `config.linkQueues`. */
this._schedulerLinkQueues = config.linkQueues === true
/** @type {boolean} Interleaved-policy serial dispatcher lock. */
this._processing = false
/** @type {boolean} Set when interleaved dispatcher is kicked while already running. */
this._dispatcherWakeRequested = false
/** @type {boolean} Resolution lane processor running. */
this._resolutionProcessing = false
/** @type {boolean} Download lane processor running. */
this._downloadProcessing = false
/** @type {boolean} Resolution lane wake while processor running. */
this._resolutionWakeRequested = false
/** @type {boolean} Download lane wake while processor running. */
this._downloadWakeRequested = false
/** @type {object|null} Lazy lane descriptors for the dispatcher. */
this._lanes = null
/** @type {boolean} Reactor Core modules wired (`BrazenKernel` / scheduler / registry / bus). */
this._reactorReady = false
/** @type {object|null} */
this._kernel = null
/** @type {object|null} */
this._scheduler = null
/** @type {object|null} */
this._jobRegistry = null
/** @type {object|null} */
this._reactorBus = null
/** @type {(() => void)|null} */
this._reactorBusUnsubscribe = null
/** @type {number} Last patch seq applied on this tab (follower reconciliation). */
this._lastAppliedPatchSeq = 0
/**
* @type {{paths: string[], sinceSeq: number}[]}
* Follower commands awaiting authoritative patch seq > sinceSeq.
*/
this._followerPendingCommands = []
/** @type {object|null} Reactor `reactor.localEditsDirty` atom */
this._localEditsDirtyAtom = null
/** @type {number|null} Debounced Reactor pipeline-pump command publish. */
this._reactorPumpSignalTimer = null
/** @type {number|null} Debounced read-only UI refresh after runtime pipeline store writes. */
this._runtimeUiRefreshHandle = null
/** @type {string|null} In-tab resolution item currently owned by this tab's processor. */
this._activeResolutionItemId = null
/** @type {string|null} In-tab download item currently owned by this tab's processor. */
this._activeDownloadItemId = null
/**
* True after `pagehide`/`beforeunload` until a live resume clears it.
* @type {boolean}
*/
this._documentSuspended = false
this._selectionClickHandler = null
/** @type {((event: DragEvent) => void)|null} Capture-phase drag guard while selection mode is on. */
this._selectionDragStartHandler = null
this._crossTabVisibilityHandler = null
this._crossTabPageshowHandler = null
this._crossTabPagehideHandler = null
this._selectionModeActive = false
/** @type {boolean} Sync mirror for media-page Add/Remove queue dock chrome (signal-backed). */
this._currentMediaQueued = false
/** @type {object|null} Reactor `dm.currentMediaQueued` atom */
this._currentMediaQueuedAtom = null
/** @type {object|null} Reactor `dm.state.snapshot` atom */
this._dmStateAtom = null
/** @type {object|null} */
this._pendingDownloadCountAtom = null
/** @type {object|null} */
this._pendingResolutionCountAtom = null
/** @type {object|null} */
this._dockProgressEpochAtom = null
/** @type {object|null} */
this._tagDiscoveryRefreshAtom = null
/** @type {boolean} */
this._dmUiEffectsReady = false
/** @type {boolean} Framework dock mounted — coordinator pump must not run before this. */
this._processorsUiReady = false
/** @type {boolean} Coordinator acquired before dock; kick pump in {@link afterDockReady}. */
this._processorsDeferredWake = false
/** @type {number|null} Pending idle/setTimeout handle for coalesced processor kick. */
this._processorsKickHandle = null
/** @type {object[]} */
this._dmUiEffectDisposers = []
/** @type {boolean} Next dock progress paint should refresh all item tiles. */
this._dockProgressRefreshAllItems = false
/** @type {number|null} rAF/setTimeout handle for coalesced dock progress paint. */
this._dockProgressPaintRaf = null
/** @type {function(): void|null} Cancels a pending coalesced dock progress paint. */
this._dockProgressPaintCancel = null
/** @type {number|null} rAF/setTimeout handle for coalesced dock progress epoch bump. */
this._dockProgressEpochBumpRaf = null
/** @type {function(): void|null} Cancels a pending coalesced dock progress epoch bump. */
this._dockProgressEpochBumpCancel = null
/** @type {number|null} rAF/setTimeout handle for coalesced tag-discovery refresh bump. */
this._tagDiscoveryRefreshBumpRaf = null
/** @type {function(): void|null} Cancels a pending coalesced tag-discovery refresh bump. */
this._tagDiscoveryRefreshBumpCancel = null
/**
* Pending onlyTags filter merged across coalesced tag-discovery refresh schedules.
* `undefined` = none pending; `null` = unrestricted; `string[]` = filtered.
* @type {string[]|null|undefined}
*/
this._tagDiscoveryRefreshPendingOnlyTags = undefined
/** @type {string|null} Last follower gate signature for panel-restore dedupe. */
this._lastFollowerGateSignature = null
/** @type {string|null} JSON of last state published to dm.state.snapshot atom. */
this._lastPublishedStateJson = null
/** @type {number|null} Last painted pending download count (slide-out transition). */
this._lastPaintedDownloadPending = null
/** @type {number|null} Last painted pending resolution count (slide-out transition). */
this._lastPaintedResolutionPending = null
/** @type {number|null} Last pending download count written to signal atoms. */
this._lastCommittedPendingDownload = null
/** @type {number|null} Last pending resolution count written to signal atoms. */
this._lastCommittedPendingResolution = null
/** @type {string|null} Active discovery review dedup signature (itemId+mode+tag names). */
this._activeDiscoveryReviewSignature = null
this._progressElement = null
/** @type {boolean} True while Confirm/Skip is finishing work after an immediate hide. */
this._tagDiscoveryActionInFlight = false
/**
* When set, the next discovery refresh runs only if the open panel lists one of these names.
* `null` means unrestricted; `undefined` means no pending filter from the last schedule.
* @type {string[]|null|undefined}
*/
this._tagDiscoveryRefreshOnlyTags = undefined
/** Per-lane human-interaction dock panels. */
this._humanInteractionPanels = {resolution: null, download: null}
/** @type {number|null} */
this._humanInteractionWatchdogTimer = null
/** @type {number|null} Debounced follower dock panel restore after gate patches. */
this._followerPanelRestoreTimer = null
/** @type {number|null} Debounced follower selection-mark reconcile after queue count patches. */
this._followerSelectionMarksTimer = null
/** @type {number|null} Retry timer for follower snapshot handshake. */
this._followerSnapshotRetryTimer = null
/** @type {boolean} Follower snapshot request in flight (suppress duplicate retries). */
this._followerSnapshotInFlight = false
/** @type {HTMLElement|null} */
this._tagDiscoveryPanel = null
/** @type {number} */
this._similarDiscoveryLoadId = 0
/** @type {number|null} */
this._similarDiscoveryRaf = null
/** @type {string|null} */
this._similarDiscoveryFingerprint = null
/** @type {Array<{name: string, type: string|null, count: null}>|null} */
this._similarDiscoveryCachedTags = null
/** @type {number|null} */
this._tagDiscoveryRefreshTimer = null
/**
* True while a navigation-interrupted in-progress download awaits a timed-panel decision.
* Gates the download lane so nothing new starts in parallel.
* @type {boolean}
*/
this._downloadInterruptionPending = false
/**
* Orphan `downloading` rows with `inProgress` awaiting the interruption panel decision.
* @type {object[]|null}
*/
this._downloadInterruptionRows = null
/** @type {HTMLElement|null} */
this._downloadInterruptionPanel = null
this._trackedItemElements = new Map()
/** @type {Map<string, number>} Bumps per itemId so a later click can cancel an in-flight toggle. */
this._selectionOpEpoch = new Map()
/**
* @type {Set<string>}
* Item ids whose overlay was cleared by a local deselect/cancel. Blocks stale
* `_syncFromStorage` / processor paints until the row is actually gone from the queues.
*/
this._selectionUiHidden = new Set()
/**
* @type {Set<string>}
* Optimistic select in flight (painted, IDB put not settled). Keeps refresh/reconcile
* from clearing the overlay when `isQueued` is still false.
*/
this._selectionUiPending = new Set()
this._resolutionGapMs = config.resolutionInitiationGapMs ?? 2000
this._downloadGapMs = config.downloadInitiationGapMs ?? 2000
/** @type {number} In-tab clock — avoids IDB lost-update races wiping the download gap. */
this._lastDownloadInitiationAt = 0
/** @type {number} */
this._lastResolutionInitiationAt = 0
/** @type {Promise<void>} Serializes DM state mutations that must re-read before write. */
this._coordinatorStateTail = Promise.resolve()
/** @type {Map<string, number>} Per-item paint generation — drops stale async _renderItemProgress. */
this._itemProgressPaintGen = new Map()
/** @type {Promise<void>} Serializes download initiation pacing (batch + immediate). */
this._downloadInitiateTail = Promise.resolve()
/** @type {Promise<void>} Serializes resolution / same-origin site fetch pacing. */
this._resolutionInitiateTail = Promise.resolve()
/** @type {number|null} Debounce timer for incoming wake / focus resume. */
this._processorsWakeReceiveTimer = null
/** @type {Promise<void>} Serializes `_resumeProcessorsFromExternalWake` work. */
this._processorsWakeResumeTail = Promise.resolve()
/** @type {boolean} One-shot guard for sticky coordinator reclaim after same-tab reload. */
this._coordinatorStickyReclaimAttempted = false
/** @type {boolean} OR of refreshAllItems across coalesced refresh requests. */
this._orphanDownloadRequeueAttempted = false
this._registerConfigFields()
this._registerDownloadPathFields()
this._setupDock()
}
/**
* @private
*/
_registerConfigFields()
{
let seeds = {}
if (this._config.enabled !== true) {
let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
if (!this._cm.getField(enableKey)) {
seeds[enableKey] = !!this._config.enableDefault
this._cm.addFlagField(enableKey).
setTitle('Enable Download Manager').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.ENABLE_DOWNLOAD_MANAGER)
}
}
let selectionDefaultKey = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
if (!this._cm.getField(selectionDefaultKey)) {
seeds[selectionDefaultKey] = false
this._cm.addFlagField(selectionDefaultKey).
setTitle('Start in Selection Mode').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SELECTION_MODE_DEFAULT)
}
if (!this._cm.getField(OPTION_REVIEW_IGNORED_FILENAME_PINS)) {
seeds[OPTION_REVIEW_IGNORED_FILENAME_PINS] = false
this._cm.addFlagField(OPTION_REVIEW_IGNORED_FILENAME_PINS).
setTitle('Review Ignored Download Path Tags').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.REVIEW_IGNORED_FILENAME_PINS)
}
if (!this._cm.getField(OPTION_SKIP_EMPTY_FILENAME_PINS)) {
seeds[OPTION_SKIP_EMPTY_FILENAME_PINS] = false
this._cm.addFlagField(OPTION_SKIP_EMPTY_FILENAME_PINS).
setTitle('Skip Media Without Download Path Tags').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SKIP_EMPTY_FILENAME_PINS)
}
if (!this._cm.getField(OPTION_DEFER_TAG_DISCOVERY)) {
seeds[OPTION_DEFER_TAG_DISCOVERY] = false
this._cm.addFlagField(OPTION_DEFER_TAG_DISCOVERY).
setTitle('Defer Tag Discovery Until Resolution Completes').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DEFER_TAG_DISCOVERY)
}
if (Object.keys(seeds).length) {
this._cm.registerFieldSeeds(seeds)
}
}
/**
* Registers download-path settings fields when {@link BrazenDownloadManager} config includes `downloadPaths`.
* Consumer scripts may override titles, defaults, or detailed help per field after `configureDownloadManager()`.
*
* @private
*/
_registerDownloadPathFields()
{
let paths = this._config.downloadPaths
if (!paths) {
return
}
let seeds = {}
let folderKey = paths.folderConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.folder
if (!this._cm.getField(folderKey)) {
if (paths.defaultFolder) {
seeds[folderKey] = paths.defaultFolder
}
this._cm.addTextField(folderKey).
setTitle('Download Folder').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOWNLOAD_FOLDER)
}
let filenameKey = paths.filenamePatternConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.filenamePattern
if (!this._cm.getField(filenameKey)) {
seeds[filenameKey] = paths.defaultFilenamePattern ?? 'ID'
this._cm.addTextField(filenameKey).
setTitle('Filename Pattern').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.FILENAME_PATTERN)
}
let subfolderKey = paths.subfolderPatternConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.subfolderPattern
if (!this._cm.getField(subfolderKey)) {
this._cm.addTextField(subfolderKey).
setTitle('Subfolder Pattern').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.SUBFOLDER_PATTERN)
}
let substitutionsKey = paths.substitutionsFieldKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.substitutions
if (!this._cm.getField(substitutionsKey)) {
seeds[substitutionsKey] = {
templateId: 'substitution',
templateConfig: {attributeKind: 'requiresTag'},
config: {...DEFAULT_RULESET_USER_CONFIG, autoSort: true},
}
this._cm.addRulesetField(substitutionsKey).
setTitle('Filename Tag Substitutions').
setTemplate('substitution').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.TAG_SUBSTITUTIONS)
}
let ignoreKey = paths.tagIgnoreFieldKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.tagIgnore
if (!this._cm.getField(ignoreKey)) {
seeds[ignoreKey] = {
templateId: 'tag-sole-ignore',
templateConfig: {attributeKind: 'toggle'},
config: {...DEFAULT_RULESET_USER_CONFIG, autoSort: true},
}
this._cm.addRulesetField(ignoreKey).
setTitle('Filename Tag Ignore List').
setTemplate('tag-sole-ignore').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.TAG_IGNORE)
}
let stripSeriesKey = paths.stripCharacterSeriesConfigKey || BRAZEN_DOWNLOAD_PATH_FIELD_KEYS.stripCharacterSeries
if (!this._cm.getField(stripSeriesKey)) {
this._cm.addFlagField(stripSeriesKey).
setTitle('Strip Series From Character Tags').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.STRIP_CHARACTER_SERIES)
}
if (Object.keys(seeds).length) {
this._cm.registerFieldSeeds(seeds)
}
}
/**
* @return {Promise<void>}
*/
async initialize()
{
let profiler = globalThis.__brazenReactor
let bootStop = profiler?.time?.('boot', 'initialize')
await this._ensureState()
this._syncDiscoveryTagTypesFromPatterns()
this._selectionModeActive = this._shouldStartSelectionMode()
try {
sessionStorage.removeItem(BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-selection'))
} catch (error) {
// ignore unavailable sessionStorage
}
this._setupCrossTabSync()
if (this._cm.canPersist()) {
this._hydrateInitiationClocksFromState(await this._getState())
}
this._framework.onConfigurationChange((event) => {
let source = event?.source
// Queue / DM state / ledger puts fire on every processor step — UI updates are owned
// by task-complete hooks, not by this broadcast.
if (source === 'downloadResolutionQueue' || source === 'downloadQueue' ||
source === 'downloadManagerState' || source === 'ledger') {
return
}
if (source === 'tags') {
// Tag attribute chrome — consumer refreshTagActionSurfaces owns discovery repaint.
return
}
if (source === 'foreign-ruleset-sync') {
// Cross-tab ruleset hydrate only — compliance effects already re-validated tiles.
return
}
let dmState = this.readDmState()
if (dmState?.resolutionBlocked || (Array.isArray(dmState?.discoveryPanelTags) &&
dmState.discoveryPanelTags.length)) {
this._requestTagDiscoveryPanelRefresh(null)
}
this._refreshDockProgress()
this._refreshSelectionMarks()
this._syncDiscoveryTagTypesFromPatterns()
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
this.registerSelectionDragGuard()
}
})
if (this.isDownloadPageRole('selection')) {
this._bindSelectionHandlers()
this.registerSelectionDragGuard()
}
// Signal-backed dock paint before first storage sync so pending counts paint coalesced.
let uiEffectsStop = profiler?.time?.('boot', 'ensureDmUiEffects')
this._ensureDmUiEffects()
uiEffectsStop?.()
let syncStop = profiler?.time?.('boot', 'syncFromStorage')
await this._syncFromStorage()
syncStop?.()
let reactorStop = profiler?.time?.('boot', 'startReactor')
await this._startReactor()
reactorStop?.()
// HI restore + dock progress remount run after the dock exists (Framework post-UI).
// Painting progress here targets a detached slot; HI panels also need the dock stack.
this._startHumanInteractionWatchdog()
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
}
this._kickProcessorsIfReady()
bootStop?.()
profiler?.mark?.('boot', 'initialize-end')
}
/**
* Start the coordinator scheduler pump only after the Framework dock exists so init
* IDB/reactor work cannot starve click handlers and dock orientation apply.
* @private
*/
_kickProcessorsIfReady()
{
globalThis.__brazenReactor?.mark?.('boot', 'kickProcessorsIfReady')
if (!this._shouldRunProcessors()) {
return
}
if (!this._processorsUiReady) {
this._processorsDeferredWake = true
return
}
if (this._processorsKickHandle != null) {
return
}
let run = () => {
this._processorsKickHandle = null
this._processorsDeferredWake = false
void this._runProcessors()
}
if (typeof requestIdleCallback === 'function') {
this._processorsKickHandle = requestIdleCallback(run, {timeout: 500})
} else {
this._processorsKickHandle = setTimeout(run, 0)
}
}
/**
* After Framework mounts the dock: remount progress onto the live slot, restore HI /
* discovery panels. Safe to call once UI exists (or again on focus).
* @return {Promise<void>}
*/
async afterDockReady()
{
this._processorsUiReady = true
// Live `.bv-dock-progress-panel` now exists — sync queue counts before first paint.
if (this._cm.canPersist()) {
await this._syncPendingCountSignalsFromIdb()
}
this._refreshDockProgress({refreshAllItems: true})
await this._restoreVisibleDockPanelsIfNeeded()
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
}
this._kickProcessorsIfReady()
}
/**
* Re-sync dock queue chrome after Framework `hydrateBootFieldsFromStorage()` — ruleset
* compile can contend with the first boot count read and leave Start/Pause idle until now.
* @return {Promise<void>}
*/
async afterBootHydrate()
{
await this._syncFromStorage()
this._kickProcessorsIfReady()
}
/**
* Restore tag-discovery and HI panels after dock mount or external wake.
* @return {Promise<void>}
* @private
*/
async _restoreVisibleDockPanelsIfNeeded()
{
await this._restoreHumanInteractionPanelsIfNeeded()
await this.restoreTagDiscoveryPanelIfNeeded()
}
/**
* Drop terminal queue rows (and their fat payloads) left after crashes / missed finally.
* Safe across tabs — only deletes terminal statuses.
* @return {Promise<void>}
* @private
*/
async _pruneAllTerminalQueueRows()
{
if (!this._cm.canPersist() || !this.isCoordinator()) {
return
}
try {
await this._repos().download.pruneAllTerminalRows()
} catch (error) {
console.log('Terminal queue prune failed:', error)
}
}
/**
* @return {boolean}
* @private
*/
_shouldStartSelectionMode()
{
if (!this.isDownloadManagerEnabled() || !this.isDownloadPageRole('selection')) {
return false
}
let key = this._config.selectionModeDefaultConfigKey ?? OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT
return !!this._framework._getConfig(key)
}
// -------------------------------------------------------------------------
// Public API
// -------------------------------------------------------------------------
/**
* Adds a post to the resolution queue when it is not already in the pipeline.
* Dedupe checks both queues: resolution (pending/in-progress resolve) and download
* (already resolved, waiting or downloading). Items already in the download queue
* are never re-enqueued for resolution.
*
* @param {object} context
* @return {Promise<boolean>} whether the item was added to the queue
*/
async enqueueDownload(context)
{
let itemId = this._config.getQueueItemId(context)
if (!itemId) {
return false
}
if (await this.isQueued(itemId)) {
return false
}
let row = {
itemId,
downloadType: context.downloadType ?? this._getActivePageConfig()?.defaultDownloadType ?? 'post',
sourceUrl: context.sourceUrl ?? '',
status: 'queued',
originTabId: this._tabId,
resolveContext: context.resolveContext ?? {},
pendingTagGroups: null,
addedAt: Date.now(),
error: null,
}
await this.sendCommand({type: 'enqueue-download', payload: {itemId, row}})
await this._syncFromStorage()
this._signalProcessorsWakeAndRunIfCoordinator()
return true
}
/**
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _clearTerminalQueueRows(itemId)
{
await this._repos().download.removeTerminal(itemId)
}
/**
* @param {string} itemId
* @return {Promise<void>}
*/
async dequeueDownload(itemId)
{
let state = this.readDmState()
let blockedId = state?.resolutionBlockedItemId
let cancelDiscovery = blockedId != null
&& String(itemId) === String(blockedId)
&& !this._tagDiscoveryActionInFlight
let itemKey = String(itemId)
let clearHiContexts = /** @type {BrazenDownloadManagerLaneId[]} */ ([])
for (let ctx of /** @type {BrazenDownloadManagerLaneId[]} */ (['resolution', 'download'])) {
let lane = this._hiLane(state, ctx)
if (lane?.itemId != null && String(lane.itemId) === itemKey) {
clearHiContexts.push(ctx)
}
}
await this.sendCommand({
type: 'dequeue-download',
payload: {itemId, cancelDiscovery, clearHiContexts},
})
if (cancelDiscovery) {
this._hideTagDiscoveryPanel()
if (String(this._activeResolutionItemId) === String(itemId)
|| String(this._activeDownloadItemId) === String(itemId)) {
this._activeResolutionItemId = null
this._activeDownloadItemId = null
}
await this._maybeEndDiscoveryLanePhase()
this._signalProcessorsWakeAndRunIfCoordinator()
}
for (let ctx of clearHiContexts) {
this._hideHumanInteractionPanel(ctx)
}
if (clearHiContexts.length) {
this._signalProcessorsWakeAndRunIfCoordinator()
}
await this._syncFromStorage()
this._refreshSelectionMarks()
}
/**
* Whether a post is anywhere in the active download pipeline (either queue).
* Resolution queue: queued, resolving, tagReview, etc.
* Download queue: queued or downloading (already resolved — no re-resolution).
*
* @param {string} itemId
* @return {Promise<boolean>}
*/
async isQueued(itemId)
{
let resolution = await this._repos().downloadResolutionQueue.get(itemId)
if (resolution && !RESOLUTION_TERMINAL.has(resolution.status)) {
return true
}
let download = await this._repos().downloadQueue.get(itemId)
return !!(download && !DOWNLOAD_TERMINAL.has(download.status))
}
/**
* @return {Promise<void>}
*/
async confirmTagDiscoveryMappings()
{
this._hideTagDiscoveryPanel()
await this.sendCommand({type: 'confirm-tag-discovery', payload: {}})
}
/**
* @param {() => (void|Promise<void>)} fn
* @return {Promise<void>}
* @private
*/
async _withTagDiscoveryActionInFlight(fn)
{
if (this._tagDiscoveryActionInFlight) {
return
}
this._tagDiscoveryActionInFlight = true
try {
await fn()
} finally {
this._tagDiscoveryActionInFlight = false
}
}
/**
* Coordinator write-through body for confirm-tag-discovery Command.
* @return {Promise<void>}
* @private
*/
async _executeConfirmTagDiscoveryCoordinator()
{
await this._withTagDiscoveryActionInFlight(async () => {
let state = await this._getState()
if (!state.resolutionBlocked) {
return
}
let reviewMode = state.discoveryReviewMode ?? 'unknown'
if (reviewMode !== 'ignoredPins') {
await this._confirmDiscoveryPanelTagTypes(state.discoveryPanelTags)
}
let itemId = state.resolutionBlockedItemId
if (!itemId || !(await this._repos().downloadResolutionQueue.get(itemId))?.pendingTagGroups) {
await this._abortDiscoveryReviewIfNoSubject()
return
}
let item = await this._repos().downloadResolutionQueue.get(itemId)
if (!item) {
await this._abortDiscoveryReviewIfNoSubject()
return
}
let outcome = await this._reconcileDiscoveryGateFromPending(item, {wakeProcessors: true})
if (outcome === 'cleared') {
await this._abortDiscoveryReviewIfNoSubject()
}
})
}
/**
* Clear discovery gate when confirm/skip has no review subject.
* @return {Promise<void>}
* @private
*/
async _abortDiscoveryReviewIfNoSubject()
{
await this._commitCoordinatorDmState((next) => {
this._clearDiscoveryReviewState(next)
})
await this._maybeEndDiscoveryLanePhase()
this._signalProcessorsWakeAndRunIfCoordinator()
}
/**
* Open tag-discovery review panel with gate state.
* @param {'unknown'|'ignoredPins'} mode
* @param {object[]} tags
* @param {object[]} knownTags
* @param {{stealOwnership?: boolean, itemId?: string|null, wakeProcessors?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _beginDiscoveryReview(mode, tags, knownTags, options = {})
{
let stealOwnership = options.stealOwnership !== false
let itemId = options.itemId ?? null
let known = mode === 'unknown' ? knownTags : []
let signature = this._discoveryReviewSignature(itemId, mode, tags)
let state = this.readDmState()
if (signature === this._activeDiscoveryReviewSignature &&
state?.resolutionBlocked &&
state.resolutionBlockedItemId === itemId &&
state.discoveryReviewMode === mode &&
this._tagDiscoveryPanel &&
BrazenViewLayer.isDockSlidePanelVisible(this._tagDiscoveryPanel)) {
return
}
this._activeDiscoveryReviewSignature = signature
let base = state
if (base && typeof base === 'object') {
let optimistic = structuredClone(base)
if (itemId) {
optimistic.resolutionBlocked = true
optimistic.resolutionBlockedItemId = itemId
}
optimistic.discoveryReviewMode = mode
optimistic.discoveryPanelTags = tags
optimistic.discoveryPanelKnownTags = known
if (stealOwnership) {
// Background processor leaves panel unowned so a focused peer can steal on wake.
optimistic.tagDiscoveryPanelTabId = document.visibilityState === 'visible' ? this._tabId : null
}
this._publishDmStateToAtoms(optimistic)
}
void this._commitCoordinatorDmState((next) => {
if (itemId) {
next.resolutionBlocked = true
next.resolutionBlockedItemId = itemId
}
next.discoveryReviewMode = mode
next.discoveryPanelTags = tags
next.discoveryPanelKnownTags = known
if (stealOwnership) {
next.tagDiscoveryPanelTabId = document.visibilityState === 'visible' ? this._tabId : null
}
}).catch(() => {})
if (options.wakeProcessors) {
await this._presentTagDiscoveryReview(tags, known)
} else {
await this._showTagDiscoveryPanel(tags, known)
}
}
/**
* Resolution funnel: fresh payload → defer, open review, finish, or promote tail.
* @param {object} item
* @param {object} resolved
* @param {{registerTags?: boolean, forceImmediate?: boolean, endDiscoveryLaneOnFinish?: boolean, presentPanel?: boolean}} [options]
* @return {Promise<'deferred'|'unknownReview'|'ignoredPinReview'|'finished'|'noop'>}
* @private
*/
async _applyTagDiscoveryOutcome(item, resolved, options = {})
{
let registerTags = options.registerTags !== false
let forceImmediate = !!options.forceImmediate
let endDiscoveryLaneOnFinish = !!options.endDiscoveryLaneOnFinish
let presentPanel = options.presentPanel !== false
let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
let tagIncidences = resolved.tagIncidences ?? {}
let deferRegistration = () => {
if (registerTags) {
void this._registerResolvedTagGroups(tagGroups).catch(() => {})
}
}
let discoveryActive = this._isTagDiscoveryActive() && this._shouldRunTagDiscovery('mediaPost')
if (discoveryActive) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
if (this._shouldDeferTagDiscoveryReview() && !forceImmediate) {
item.status = 'discoveryQueued'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return 'noop'
}
deferRegistration()
return 'deferred'
}
item.status = 'tagReview'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return 'noop'
}
await this._beginDiscoveryReview('unknown', lists.unknown, lists.known, {
itemId: item.itemId,
wakeProcessors: presentPanel,
})
deferRegistration()
return 'unknownReview'
}
}
if (registerTags) {
await this._registerResolvedTagGroups(tagGroups)
}
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
let ignoredTags = ignoredPin.tags
if (ignoredPin.pinJoinEmpty && ignoredTags.length) {
if (this._shouldDeferTagDiscoveryReview() && !forceImmediate) {
item.status = 'discoveryQueued'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return 'noop'
}
return 'deferred'
}
item.status = 'tagReview'
item.pendingTagGroups = resolved
if (!(await this._commitResolutionRow(item))) {
return 'noop'
}
await this._beginDiscoveryReview('ignoredPins', ignoredTags, [], {
itemId: item.itemId,
wakeProcessors: presentPanel,
})
return 'ignoredPinReview'
}
if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
await this._dropResolvedWithoutDownload(item)
if (endDiscoveryLaneOnFinish) {
await this._maybeEndDiscoveryLanePhase()
}
return 'finished'
}
return 'noop'
}
/**
* Stale gate / orphan row / post-confirm re-partition from pending tag groups.
* @param {object|string|null} itemOrId
* @param {{refreshIgnoredOnly?: boolean, wakeProcessors?: boolean, stealOwnership?: boolean, mode?: string}} [options]
* @return {Promise<'unknownReview'|'ignoredPinReview'|'finished'|'cleared'|'unchanged'>}
* @private
*/
async _reconcileDiscoveryGateFromPending(itemOrId, options = {})
{
// Gate open/reconcile writes dm.state — coordinator-only (followers restore UI from patches).
if (!this.isCoordinator()) {
return 'unchanged'
}
let itemId = typeof itemOrId === 'object' ? itemOrId?.itemId : itemOrId
let item = typeof itemOrId === 'object' ? itemOrId : null
if (!item && itemId) {
item = await this._repos().downloadResolutionQueue.get(itemId)
}
if (!item?.pendingTagGroups) {
this._hideTagDiscoveryPanel()
await this._commitCoordinatorDmState((next) => {
this._clearDiscoveryReviewState(next)
})
if (options.wakeProcessors) {
this._signalProcessorsWakeAndRunIfCoordinator()
}
return 'cleared'
}
let resolved = item.pendingTagGroups
let tagGroups = this._tagGroupsFromResolvedPayload(resolved)
let tagIncidences = resolved.tagIncidences ?? {}
let state = this.readDmState()
let mode = options.mode ?? state?.discoveryReviewMode
if (options.refreshIgnoredOnly || mode === 'ignoredPins') {
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
let ignoredTags = ignoredPin.tags
if (!ignoredPin.pinJoinEmpty || !ignoredTags.length) {
await this._finishResolutionAfterTagDiscovery(item)
return 'finished'
}
await this._beginDiscoveryReview('ignoredPins', ignoredTags, [], {
itemId: item.itemId,
stealOwnership: options.stealOwnership,
wakeProcessors: options.wakeProcessors,
})
if (!options.wakeProcessors) {
this._renderTagDiscoveryPanel(ignoredTags, [])
}
return 'ignoredPinReview'
}
if (this._isTagDiscoveryActive()) {
let lists = await this._partitionDiscoveryTags(tagGroups, tagIncidences)
if (lists.unknown.length) {
await this._beginDiscoveryReview('unknown', lists.unknown, lists.known, {
itemId: item.itemId,
stealOwnership: options.stealOwnership,
wakeProcessors: options.wakeProcessors,
})
return 'unknownReview'
}
}
let ignoredPin = await this._collectIgnoredFilenamePinTags(tagGroups, tagIncidences)
if (ignoredPin.pinJoinEmpty && ignoredPin.tags.length) {
await this._beginDiscoveryReview('ignoredPins', ignoredPin.tags, [], {
itemId: item.itemId,
stealOwnership: options.stealOwnership,
wakeProcessors: options.wakeProcessors,
})
return 'ignoredPinReview'
}
await this._finishResolutionAfterTagDiscovery(item)
return 'finished'
}
/**
* Promote or skip-empty after discovery/ignored-pin review when nothing remains to review.
* @param {object} item Resolution queue row with `pendingTagGroups`
* @return {Promise<void>}
* @private
*/
async _finishResolutionAfterTagDiscovery(item)
{
let itemId = item?.itemId ?? null
try {
if (!item?.pendingTagGroups) {
return
}
let resolvedPayload = item.pendingTagGroups
let tagGroups = this._tagGroupsFromResolvedPayload(resolvedPayload)
if (this._shouldSkipEmptyFilenamePins(tagGroups)) {
await this._dropResolvedWithoutDownload(item, {clearDiscoveryGate: false})
return
}
if (!(await this._promoteToDownloadQueue(item, resolvedPayload))) {
return
}
await this._repos().download.removeResolution(itemId)
if (itemId) {
void this._renderItemProgress(itemId)
}
await this._incrementResolutionProgress()
} catch (error) {
console.error('[BrazenDownloadManager] finish after tag discovery failed', error)
} finally {
await this._commitCoordinatorDmState((next) => {
this._clearDiscoveryReviewState(next)
})
try {
await this._syncFromStorage()
} catch (syncError) {
/* ignore */
}
await this._maybeEndDiscoveryLanePhase()
this._signalProcessorsWakeAndRunIfCoordinator()
}
}
/**
* Clear discovery panel gate fields on a state snapshot.
* @param {object} state
* @private
*/
_clearDiscoveryReviewState(state)
{
state.resolutionBlocked = false
state.resolutionBlockedItemId = null
state.discoveryPanelTags = null
state.discoveryPanelKnownTags = null
state.discoveryReviewMode = null
state.tagDiscoveryPanelTabId = null
this._activeDiscoveryReviewSignature = null
}
/**
* @param {string|null|undefined} itemId
* @param {'unknown'|'ignoredPins'|string|null|undefined} mode
* @param {Array<{name?: string}>|null|undefined} tags
* @return {string}
* @private
*/
_discoveryReviewSignature(itemId, mode, tags)
{
let names = (tags ?? []).map((tag) => tag?.name).filter(Boolean).sort().join('\0')
return `${itemId ?? ''}\0${mode ?? ''}\0${names}`
}
/**
* Skip the current resolution item waiting on tag review (do not promote to download;
* do not set isDiscovered so discovery can fire again later).
* @return {Promise<void>}
*/
async skipTagDiscoveryInclusion()
{
this._hideTagDiscoveryPanel()
let itemId = this.readDmState()?.resolutionBlockedItemId
await this.sendCommand({type: 'skip-tag-discovery', payload: {}})
if (itemId) {
let element = this._trackedItemElements.get(String(itemId))
this._untrackItemElement(itemId)
if (element) {
this._setItemProgress(element, null)
}
}
await this._syncFromStorage()
}
/**
* Coordinator post-skip hook (IDB already committed in kernelWriteThrough).
* @return {Promise<void>}
* @private
*/
async _executeSkipTagDiscoveryCoordinator()
{
await this._withTagDiscoveryActionInFlight(async () => {
await this._maybeEndDiscoveryLanePhase()
this._signalProcessorsWakeAndRunIfCoordinator()
await this._syncFromStorage()
})
}
/**
* Wake peers and run processors when this tab is coordinator.
* @private
*/
_signalProcessorsWakeAndRunIfCoordinator()
{
this._requestPipelineRun()
}
/**
* Open the media page for the item currently under tag review.
* @return {Promise<void>}
*/
async openTagDiscoveryMedia()
{
let state = await this._getState()
if (!state.resolutionBlocked || !state.resolutionBlockedItemId) {
return
}
let item = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
let openUrl = item?.sourceUrl
|| item?.pendingTagGroups?.metadata?.sourceUrl
|| null
if (openUrl) {
Utilities.openUrlInNewTab(openUrl)
}
}
/**
* @return {Promise<void>}
*/
async toggleDownloadManagerPaused()
{
let state = await this._getState()
// Linked / interleaved: any HI owns Start. Independent: only download-lane HI.
if (this._isStartPauseShowingHumanInteraction(state)) {
void this._promptHumanInteractionResume('download')
if (this._isLinkQueues()) {
for (let ctx of this._hiContexts(state)) {
if (ctx !== 'download') {
void this._promptHumanInteractionResume(ctx)
}
}
}
return
}
// Start/Pause only affects the download queue — never resolution / tag discovery.
// Allow toggle while resolution is still in-batch (download may be empty mid-batch).
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
if (this._isDownloadBatchIdle(downloadPending, resolutionPending)) {
return
}
let nextPaused = !state.paused
await this.sendCommand({type: 'toggle-paused', payload: {paused: nextPaused}})
await this._syncFromStorage()
if (!nextPaused) {
this._signalProcessorsWakeAndRunIfCoordinator()
}
}
/**
* Clears pending/in-progress download-queue rows only. Does not touch the resolution
* queue, tag discovery, selection mode, or processor leadership.
* @return {Promise<void>}
*/
async clearDownloadQueue()
{
await this.sendCommand({type: 'clear-download-queue', payload: {}})
this._hideHumanInteractionPanel('download')
await this._syncFromStorage()
this._refreshSelectionMarks()
if (this._shouldRunProcessors()) {
void this._runProcessors()
}
}
/**
* @return {Promise<{resolution: {current: number, total: number}, download: {current: number, total: number}}>}
*/
async getDownloadManagerProgress()
{
let downloadActive = await this._getPendingDownloadCount()
let resolutionActive = await this._getPendingResolutionCount()
let state = await this._getState()
let completedResolution = Number(state.completedResolutionCount) || 0
let completedDownload = Number(state.completedDownloadCount) || 0
return {
resolution: {
current: completedResolution,
total: completedResolution + resolutionActive,
},
download: {
current: completedDownload,
total: completedDownload + downloadActive,
},
}
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingDownloadCount()
{
return this._repos().downloadQueue.countActive()
}
/**
* @return {number}
*/
getPendingResolutionCountSync()
{
return this._pendingResolutionCountAtom?.value ?? 0
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingResolutionCount()
{
return this._repos().downloadResolutionQueue.countActive()
}
/**
* Non-terminal items in both queues (item progress / internal pipeline checks).
* @return {Promise<number>}
*/
async getPendingPipelineCount()
{
return this._getPendingPipelineCount()
}
/**
* @return {Promise<number>}
* @private
*/
async _getPendingPipelineCount()
{
let downloadCount = await this._getPendingDownloadCount()
let resolutionCount = await this._getPendingResolutionCount()
return resolutionCount + downloadCount
}
/**
* @return {number}
*/
getPendingDownloadCountSync()
{
if (typeof this._pendingDownloadCountAtom?.read === 'function') {
return this._pendingDownloadCountAtom.read()
}
return this._pendingDownloadCountAtom?.value ?? 0
}
/**
* @return {Promise<number>}
*/
async getDownloadQueueCount()
{
let downloadCount = await this._getPendingDownloadCount()
if (this._isTagDiscoveryActive()) {
return downloadCount
}
let resolutionCount = await this._getPendingResolutionCount()
return resolutionCount + downloadCount
}
/**
* @return {HTMLElement}
*/
getOrCreateProgressSlot()
{
// Prefer the node currently mounted in the dock — slide-out remounts can leave
// `_progressElement` pointing at a detached panel while the rail shows a stale copy.
let live = document.querySelector('.bv-dock .bv-dock-progress-panel')
if (live) {
this._progressElement = live
return this._progressElement
}
if (this._progressElement != null && this._isProgressSlotConnected(this._progressElement)) {
return this._progressElement
}
this._progressElement = BrazenViewLayer.createDownloadManagerProgressSlot()
return this._progressElement
}
/**
* @param {HTMLElement|null|undefined} slot
* @return {boolean}
* @private
*/
_isProgressSlotConnected(slot)
{
return !!(slot && slot.isConnected)
}
/**
* Rebuild Start/Pause slide-out so a detached progress panel can remount.
* @private
*/
_remountDownloadStartPauseSlot()
{
let field = this._cm.getField(DOCK_DOWNLOAD_START_PAUSE)
if (!field?.dockElement) {
return
}
this._cm._refreshDockRootSlot(field)
}
/**
* @return {Promise<void>}
*/
async toggleTagDiscoveryMode()
{
if (!this._config.tagDiscovery) {
return
}
await this._commitCoordinatorDmState((state) => {
state.tagDiscoveryEnabled = !state.tagDiscoveryEnabled
})
this._framework.refreshDockInterface(undefined, {layout: false})
}
/**
* @return {void}
*/
toggleSelectionMode()
{
if (!this.isDownloadPageRole('selection')) {
return
}
this._selectionModeActive = !this._selectionModeActive
this._framework.refreshDockInterface(undefined, {layout: false})
this._bindSelectionHandlers()
this.registerSelectionDragGuard()
if (!this._selectionModeActive) {
this._clearAllItemProgress()
return
}
this._refreshSelectionMarks()
}
/**
* Enqueue every visible search tile not already in the resolution/download pipeline.
* @return {Promise<void>}
*/
async selectAllVisibleItems()
{
if (!this.isDownloadManagerEnabled() || !this.isDownloadPageRole('selection') ||
!this._isSelectionModeActive()) {
return
}
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !pageConfig?.resolveItem) {
return
}
for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [element], null)
if (!resolved?.itemId) {
continue
}
let itemKey = String(resolved.itemId)
if (this._trackedItemElements.has(itemKey) || this._selectionUiPending.has(itemKey)) {
continue
}
if (await this.isQueued(resolved.itemId)) {
continue
}
await this._enqueueSelectionItem(resolved, element)
}
this._refreshSelectionMarks()
}
/**
* @return {Promise<void>}
*/
async toggleCurrentMediaQueued()
{
if (!this.isDownloadPageRole('enqueueMedia')) {
return
}
let itemId = this._config.getQueueItemId({sourceUrl: location.href})
if (!itemId) {
return
}
let wasQueued = await this.isQueued(itemId)
// Optimistic chrome so the click always flips icon/active before IDB settles.
this._setCurrentMediaQueued(!wasQueued)
this._paintCurrentMediaQueueDockButton()
if (wasQueued) {
// Stop an in-flight claim from rewriting the row after remove.
if (String(this._activeResolutionItemId) === String(itemId)
|| String(this._activeDownloadItemId) === String(itemId)) {
this._activeResolutionItemId = null
this._activeDownloadItemId = null
}
await this.dequeueDownload(itemId)
} else {
await this.enqueueDownload({
itemId,
sourceUrl: location.href,
downloadType: this._getActivePageConfig()?.defaultDownloadType ?? 'post',
resolveContext: {fromMediaPage: true},
})
}
// Authoritative re-read — dequeue/enqueue sync must not leave a stale active icon.
this._setCurrentMediaQueued(await this.isQueued(itemId))
this._paintCurrentMediaQueueDockButton()
}
/**
* Paint Add/Remove queue dock chrome from {@link _currentMediaQueued}.
* @private
*/
_paintCurrentMediaQueueDockButton()
{
let field = this._cm.getField(DOCK_ADD_TO_QUEUE)
if (!field?.dockElement) {
this._framework.refreshDockInterface(undefined, {layout: false})
return
}
this._cm._updateDockFieldButton(field)
}
/**
* Refresh sync cache for the media-page Add/Remove queue dock button.
* @param {{refreshDock?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _refreshCurrentMediaQueueState(options = {})
{
if (!this.isDownloadPageRole('enqueueMedia')) {
this._setCurrentMediaQueued(false)
if (options.refreshDock !== false) {
this._paintCurrentMediaQueueDockButton()
}
return
}
let itemId = this._config.getQueueItemId?.({sourceUrl: location.href})
if (!itemId) {
this._setCurrentMediaQueued(false)
} else {
this._setCurrentMediaQueued(await this.isQueued(itemId))
}
if (options.refreshDock !== false) {
this._paintCurrentMediaQueueDockButton()
}
}
/**
* Whether the active DM page config lists `role` in `roles`.
* Known roles: `selection`, `enqueueMedia`, `tagDiscoveryToggle`,
* `dashboard` (dedicated control / future-analytics host — does not grant page-oriented
* queue actions and does not auto-claim processor leadership).
* @param {string} role
* @return {boolean}
*/
isDownloadPageRole(role)
{
let pageName = this._getActivePageName()
if (!pageName) {
return false
}
let pageConfig = this._config.pages?.[pageName]
return !!(pageConfig?.roles?.includes(role))
}
/**
* @return {boolean}
*/
isDownloadManagerEnabled()
{
if (!this._cm.isDockActive()) {
return false
}
if (this._config.enabled === true) {
return true
}
let enableKey = this._config.enableConfigKey ?? OPTION_ENABLE_DOWNLOAD_MANAGER
return !!this._framework._getConfig(enableKey)
}
/**
* @return {boolean}
*/
isDownloadManagerRunning()
{
if (!this.isDownloadManagerEnabled()) {
return false
}
let state = this.readDmState()
if (!state) {
return false
}
if (this._processing || this._resolutionProcessing || this._downloadProcessing) {
return true
}
if (this._anyHumanInteraction(state) || state.resolutionBlocked) {
return true
}
if (!state.resolutionBlocked && this.getPendingResolutionCountSync() > 0) {
return true
}
if (!state.paused && this.getPendingDownloadCountSync() > 0) {
return true
}
return false
}
/**
* True when this tab holds the Web Locks script coordinator role.
* @return {boolean}
*/
isCoordinator()
{
return this._kernel?.isCoordinator() ?? false
}
/**
* Send a Reactor Command: coordinator dispatches locally; followers publish to the bus.
* Followers apply optimistic dm.state patches until a matching authoritative seq arrives.
* @param {object} command
* @return {Promise<void>}
*/
async sendCommand(command)
{
if (!command || typeof command.type !== 'string') {
throw new Error('BrazenDownloadManager.sendCommand: command.type required')
}
if (!this._ensureReactor() || !this._reactorBus) {
throw new Error('BrazenDownloadManager.sendCommand: Reactor bus not ready')
}
if (this.isCoordinator()) {
await this._kernel.dispatch(command)
return
}
this._applyFollowerCommandOptimistic(command)
this._reactorBus.publish({
kind: 'command',
tabId: this._reactorBus.tabId,
command,
})
}
/**
* @param {object} command
* @private
*/
_applyFollowerCommandOptimistic(command)
{
let patches = this._optimisticPatchesForCommand(command)
if (!patches.length) {
return
}
this._followerPendingCommands.push({
paths: patches.map((patch) => patch.path),
values: Object.fromEntries(patches.map((patch) => [patch.path, structuredClone(patch.value)])),
sinceSeq: this._lastAppliedPatchSeq,
})
this._setLocalEditsDirty(true)
let state = this.readDmState()
let pendingDownload = null
let pendingResolution = null
for (let patch of patches) {
if (patch.path === 'dm.pendingDownloadCount') {
pendingDownload = patch.value
continue
}
if (patch.path === 'dm.pendingResolutionCount') {
pendingResolution = patch.value
continue
}
if (!patch.path?.startsWith('dm.state.')) {
continue
}
if (!state) {
continue
}
let key = patch.path.slice('dm.state.'.length)
if (key && key !== 'snapshot') {
state[key] = structuredClone(patch.value)
}
}
if (state) {
this._publishDmStateToAtoms(state)
}
if (pendingDownload !== null || pendingResolution !== null) {
this._commitPendingCountSignals(
pendingDownload ?? this.getPendingDownloadCountSync(),
pendingResolution ?? this.getPendingResolutionCountSync(),
)
}
if (!this._documentSuspended && document.visibilityState === 'visible') {
this._requestDockProgressPaint({refreshAllItems: false})
}
}
/**
* @param {object} command
* @return {object[]}
* @private
*/
_optimisticPatchesForCommand(command)
{
switch (command.type) {
case 'toggle-paused':
return [{
path: 'dm.state.paused',
value: Boolean(command.payload?.paused),
}]
case 'enqueue-download': {
let resolution = this.getPendingResolutionCountSync() + 1
return [{path: 'dm.pendingResolutionCount', value: resolution}]
}
case 'dequeue-download': {
let resolution = Math.max(0, this.getPendingResolutionCountSync() - 1)
let download = Math.max(0, this.getPendingDownloadCountSync() - 1)
return [
{path: 'dm.pendingResolutionCount', value: resolution},
{path: 'dm.pendingDownloadCount', value: download},
]
}
case 'clear-download-queue':
case 'confirm-tag-discovery':
case 'skip-tag-discovery':
return []
case 'claim-tag-discovery-panel':
return [{
path: 'dm.state.tagDiscoveryPanelTabId',
value: command.payload?.tabId ?? null,
}]
case 'claim-hi-prompt': {
let state = this.readDmState()
if (!state) {
return []
}
let ctx = command.payload?.context === 'download' ? 'download' : 'resolution'
let hi = structuredClone(state.humanInteraction ?? {})
let lane = hi[ctx]
if (!lane || typeof lane !== 'object') {
return []
}
lane.promptTabId = command.payload?.tabId ?? null
return [{path: 'dm.state.humanInteraction', value: hi}]
}
default:
return []
}
}
/**
* @param {number} seq
* @param {object[]|null|undefined} patches
* @private
*/
_reconcileFollowerPendingCommands(seq, patches)
{
if (!this._followerPendingCommands.length || !Number.isFinite(seq)) {
return
}
let patchByPath = new Map(
(patches ?? []).filter((patch) => patch?.path).map((patch) => [patch.path, patch]),
)
let patchedPaths = new Set(patchByPath.keys())
let hadConflict = false
this._followerPendingCommands = this._followerPendingCommands.filter((pending) => {
if (seq <= pending.sinceSeq) {
return true
}
if (!pending.paths.every((path) => patchedPaths.has(path))) {
return true
}
for (let path of pending.paths) {
let patch = patchByPath.get(path)
let optimistic = pending.values?.[path]
if (patch && optimistic !== undefined &&
JSON.stringify(patch.value) !== JSON.stringify(optimistic)) {
hadConflict = true
}
}
return false
})
if (hadConflict && !this._documentSuspended && document.visibilityState === 'visible') {
this._requestDockProgressPaint({refreshAllItems: true})
void this._syncPendingCountSignalsFromIdb()
}
if (!this._followerPendingCommands.length) {
this._setLocalEditsDirty(false)
}
}
/**
* @param {boolean} dirty
* @private
*/
_setLocalEditsDirty(dirty)
{
this._localEditsDirtyAtom?.write?.(!!dirty) ??
this._localEditsDirtyAtom?.set?.(!!dirty)
}
/**
* Reactor-native sync read helper (v2 §1).
* @return {object}
*/
readDmState()
{
if (typeof this._dmStateAtom?.read === 'function') {
return this._dmStateAtom.read() ?? {}
}
return this._dmStateAtom?.value ?? {}
}
/**
* @return {string}
* @private
*/
_coordinatorStickyStorageKey()
{
return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'coordinator-sticky')
}
/**
* @private
*/
_markCoordinatorSticky()
{
try {
sessionStorage.setItem(this._coordinatorStickyStorageKey(), '1')
} catch (_error) {
// ignore unavailable sessionStorage
}
}
/**
* @private
*/
_clearCoordinatorSticky()
{
try {
sessionStorage.removeItem(this._coordinatorStickyStorageKey())
} catch (_error) {
// ignore unavailable sessionStorage
}
}
/**
* @return {boolean}
* @private
*/
_wasCoordinatorSticky()
{
try {
return sessionStorage.getItem(this._coordinatorStickyStorageKey()) === '1'
} catch (_error) {
return false
}
}
/**
* Try to become the script coordinator tab (config + downloads).
* Steal is allowed while work is active — the prior coordinator aborts via AbortSignal.
* @param {{steal?: boolean, ifAvailable?: boolean}} [options]
* @return {Promise<boolean>}
*/
async requestCoordinatorRole(options = {steal: true})
{
if (this._documentSuspended || !this._cm.canPersist() || !this.isDownloadManagerEnabled()) {
return false
}
if (this._isQueueVerificationTab()) {
return false
}
if (!this._ensureReactor() || !this._kernel) {
return false
}
if (this.isCoordinator()) {
return true
}
this._commitPendingCountSignals(
await this._getPendingDownloadCount(),
await this._getPendingResolutionCount(),
)
this._publishDmStateToAtoms(await this._getState())
let claimed = await this._kernel.requestCoordinatorRole(options)
if (!claimed) {
return false
}
this._cm.refreshDockButtonStates()
this._signalProcessorsWakeAndRunIfCoordinator()
return this.isCoordinator()
}
/**
* Consumer tag normalizer for filename ignore / substitution lookups (same as attribute actions).
* @return {function(string): string}
* @private
*/
_getDownloadTagNormalize()
{
let fromResolver = null
try {
fromResolver = this._config.downloadPaths?.getPatternResolver?.()?.normalizeTag
} catch (error) {
fromResolver = null
}
if (typeof fromResolver === 'function') {
return fromResolver
}
let discovery = this._config.tagDiscovery ?? {}
let fromActions = discovery.actions?.normalize ?? discovery.normalizeTag
if (typeof fromActions === 'function') {
return fromActions
}
return (value) => String(value ?? '').trim()
}
// -------------------------------------------------------------------------
// Path creation
// -------------------------------------------------------------------------
/**
* @param {string} text
* @return {string}
*/
decodeHtmlEntities(text)
{
return Utilities.decodeHtmlEntities(text)
}
/**
* @param {string} segment
* @return {string}
*/
sanitizePathSegment(segment)
{
return this.decodeHtmlEntities(String(segment)).
replace(/[<>:"/\\|?*]/g, '-').
replace(/[.\s]+$/, '').
trim()
}
/**
* @param {string} folder
* @param {string} name
* @return {string}
*/
buildDownloadPath(folder, name)
{
let folderPath = String(folder).split('/').
map((segment) => this.sanitizePathSegment(segment)).
filter((segment) => segment.length).
join('/').
substring(0, 120).
replace(/[.\s/]+$/, '')
let fileName = this.sanitizePathSegment(name) || 'media'
return folderPath ? folderPath + '/' + fileName : fileName
}
/**
* Prefetch tag registry rows needed for filename ignore / substitutions.
* @param {{}} tagGroups
* @return {Promise<void>}
*/
async _ensureDownloadPathTagsLoaded(tagGroups)
{
let tagRuntime = this._cm.getTagRuntime()
if (!tagRuntime) {
return
}
let normalize = this._getDownloadTagNormalize()
let stripCharacterSeries = false
try {
stripCharacterSeries = !!this._config.downloadPaths?.getPatternResolver?.()?.stripCharacterSeries
} catch (error) {
stripCharacterSeries = false
}
let names = []
for (let group of Object.values(tagGroups ?? {})) {
if (Array.isArray(group)) {
for (let name of group) {
if (!name) {
continue
}
names.push(name)
let normalized = normalize(name)
if (normalized && normalized !== name) {
names.push(normalized)
}
if (stripCharacterSeries && normalized) {
let stripped = normalized.replace(/_\([^)]*\)$/, '')
if (stripped && stripped !== normalized) {
names.push(stripped)
}
}
}
}
}
if (names.length) {
await tagRuntime.ensureNames(names)
} else {
await tagRuntime.warmCache()
}
try {
await tagRuntime.refreshDownloadRulesetMaps?.()
} catch (error) {
// Join / ignore review fall back to ruleset reads when maps are unavailable.
}
}
/**
* @param {{}} data
* @param {{}} tagGroups
* @return {string}
*/
buildDownloadPathFromPatterns(data, tagGroups)
{
let paths = this._config.downloadPaths
let resolver = paths.getPatternResolver()
let filenamePattern = this._framework._getConfig(paths.filenamePatternConfigKey)
let name = this._resolveDownloadPattern(filenamePattern, data, tagGroups, resolver)
if (!name) {
name = paths.nameFallback?.(data) ?? (data.md5 || data.id || 'media')
}
if (paths.appendExtension && data.ext) {
name += '.' + data.ext
}
let root = this._framework._getConfig(paths.folderConfigKey) || paths.defaultFolder || ''
let subfolderPattern = paths.subfolderPatternConfigKey ?
this._framework._getConfig(paths.subfolderPatternConfigKey) :
''
let subfolder = subfolderPattern ? this._resolveDownloadPattern(subfolderPattern, data, tagGroups, resolver) : ''
let folder = subfolder ? root + '/' + subfolder : root
return this.buildDownloadPath(folder, name)
}
/**
* True when {@link pattern} references a chip via label, {label}, or {token}.
* @param {string} pattern
* @param {{token?: string, label?: string}} chip
* @return {boolean}
*/
patternIncludesChip(pattern, chip)
{
if (!pattern || !chip) {
return false
}
let needles = []
if (chip.label) {
needles.push(chip.label, `{${chip.label}}`)
}
if (chip.token) {
needles.push(`{${chip.token}}`)
}
for (let needle of needles) {
if (needle && pattern.includes(needle)) {
return true
}
}
return false
}
/**
* @param {string[]|{subject: string, replacement: string}[]} lines
* @param {function(string): string} normalizeToken
* @return {{subject: string, replacement: string}[]}
*/
parseDownloadTagSubstitutionLines(lines, normalizeToken)
{
let rules = []
for (let line of lines) {
if (typeof line === 'object' && line?.subject && line?.replacement) {
let subject = normalizeToken(line.subject)
let replacement = normalizeToken(line.replacement)
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
continue
}
if (typeof line !== 'string') {
continue
}
let match = line.match(/^(.+?)\s*(?:→|->|-)\s+(.+)$/)
if (!match) {
continue
}
let subject = normalizeToken(match[1])
let replacement = normalizeToken(match[2])
if (subject.length && replacement.length) {
rules.push({subject, replacement})
}
}
return rules
}
/**
* @param {{subject: string, replacement: string}[]} rules
* @return {Map<string, string>}
*/
buildDownloadTagSubstitutionMap(rules)
{
let map = new Map()
for (let rule of rules) {
map.set(rule.subject, rule.replacement)
}
return map
}
/**
* @param {string} pattern
* @param {{}} data
* @param {{}} tagGroups
* @param {object} resolver
* @return {string}
* @private
*/
_resolveDownloadPattern(pattern, data, tagGroups, resolver)
{
let resolved = pattern
let chips = [...resolver.chips].sort((a, b) => b.label.length - a.label.length)
for (let chip of chips) {
let value
if (resolver.tagTypes.includes(chip.token)) {
value = this._resolveDownloadTagTypeTokenForPath(pattern, tagGroups[chip.token] ?? [], chip.token, resolver.ignore, resolver)
} else {
value = data[chip.token] ?? ''
}
resolved = resolved.replaceAll(chip.label, value)
}
return resolved.replace(/\s+/g, ' ').trim()
}
/**
* @param {string} pattern
* @param {string[]} tags
* @param {string} type
* @param {Set<string>} ignore
* @param {object} resolver
* @return {string}
* @private
*/
_resolveDownloadTagTypeTokenForPath(pattern, tags, type, ignore, resolver)
{
let value = this._joinTagsForDownloadPath(tags, type, ignore, resolver)
if (!value && this._patternEmploysDownloadTagTypeToken(pattern, type, resolver.chips, resolver.unknownTypes)) {
return resolver.unknownDefault
}
return value
}
/**
* @param {string} pattern
* @param {string} type
* @param {{token: string, label: string}[]} chips
* @param {Set<string>} unknownTypes
* @return {boolean}
* @private
*/
_patternEmploysDownloadTagTypeToken(pattern, type, chips, unknownTypes)
{
if (!unknownTypes.has(type)) {
return false
}
for (let chip of chips) {
if (chip.token === type && this.patternIncludesChip(pattern, chip)) {
return true
}
}
return false
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Set<string>|null} ignore
* @param {object} options
* @return {string}
* @private
*/
_joinTagsForDownloadPath(tags, type, ignore, options)
{
let normalize = typeof options.normalizeTag === 'function' ?
options.normalizeTag :
this._getDownloadTagNormalize()
tags = (Array.isArray(tags) ? tags : []).
map((tag) => normalize(tag)).
filter(Boolean)
let applyFilenameIgnore = options.applyFilenameIgnore !== false
if (options.tagRuntime?._warmed) {
tags = options.tagRuntime.applyDownloadAttributesSync(
tags, options.stripCharacterSeries, applyFilenameIgnore)
} else {
tags = this._applyDownloadTagSubstitutions(tags, type, options.substitutions, options.stripCharacterSeries)
if (applyFilenameIgnore && ignore?.size) {
tags = tags.filter((tag) => {
if (ignore.has(tag) || ignore.has(normalize(tag))) {
return false
}
if (options.stripCharacterSeries && type === 'character') {
let stripped = tag.replace(/_\([^)]*\)$/, '')
if (stripped !== tag && (ignore.has(stripped) || ignore.has(normalize(stripped)))) {
return false
}
}
return true
})
}
}
let seen = new Set()
let unique = []
for (let tag of tags) {
let formatted = this._formatTagForDownloadPath(tag, type, options.stripCharacterSeries)
if (!formatted || seen.has(formatted)) {
continue
}
seen.add(formatted)
unique.push(formatted)
}
return unique.
sort((left, right) => left.localeCompare(right, undefined, {sensitivity: 'base'})).
join(options.multiTagSeparator)
}
/**
* @param {string[]} tags
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string[]}
* @private
*/
_applyDownloadTagSubstitutions(tags, type, substitutions, stripCharacterSeries)
{
if (!substitutions.size) {
return tags
}
return tags.map((tag) => this._resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries))
}
/**
* @param {string} tag
* @param {string|null} type
* @param {Map<string, string>} substitutions
* @param {boolean} stripCharacterSeries
* @return {string}
* @private
*/
_resolveDownloadTagSubstitution(tag, type, substitutions, stripCharacterSeries)
{
if (substitutions.has(tag)) {
return substitutions.get(tag)
}
if (type === 'character' && stripCharacterSeries) {
let stripped = tag.replace(/_\([^)]*\)$/, '')
if (stripped !== tag && substitutions.has(stripped)) {
return substitutions.get(stripped)
}
}
return tag
}
/**
* @param {string} tag
* @param {string|null} type
* @param {boolean} stripCharacterSeries
* @return {string}
* @private
*/
_formatTagForDownloadPath(tag, type, stripCharacterSeries)
{
let formatted = this.decodeHtmlEntities(tag)
if (type === 'character' && stripCharacterSeries) {
formatted = formatted.replace(/_\([^)]*\)$/, '')
}
return formatted.replaceAll('_', ' ')
}
// -------------------------------------------------------------------------
// Download execution
// -------------------------------------------------------------------------
/**
* @param {{name: string|null, element: HTMLElement|null, url: string, downloadId?: string|null, restorePictureOnFailure?: boolean}} download
* @return {Promise<void>}
* @private
*/
_wrapDownloadTask(download)
{
let path = download.name
if (!path) {
alert('Download failed: missing file path.')
return Promise.resolve()
}
return new Promise((resolve) => {
download.element?.remove()
GM_download({
url: download.url,
name: path,
// Filenames come from user patterns — never uniquify (no file(1).ext).
conflictAction: 'overwrite',
onload: () => {
this._framework._handleDownloadSucceeded(download)
resolve()
},
onerror: (error) => {
this._framework._handleDownloadFailed(download, error)
resolve()
},
})
})
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processDownloadItem(item)
{
try {
// Status is already `downloading` from `_claimDownloadQueueItem`.
let payload = item.resolvedPayload ?? {}
await this._ensureDownloadPathTagsLoaded(payload.tagGroups ?? {})
let path = this.buildDownloadPathFromPatterns(payload.data ?? {}, payload.tagGroups ?? {})
let downloadUrl = payload.mediaUrl ?? ''
let downloadId = payload.downloadId ?? item.downloadId ?? item.itemId
// Claim once per queue item. Retries after lock steal / requeue must not re-claim —
// the ledger is reserved before GM_download (Tampermonkey often never fires onload).
if (!item.ledgerClaimed) {
// IndexedDB membership (bounded positive cache + postId index) — never getAll the ledger.
if (await this._framework._isDownloadDuplicateAsync(downloadId)) {
item.status = 'duplicate'
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
return
}
if (downloadId && this._framework._shouldClaimDownloadDuplicateLedger() &&
!(await this._framework._claimDownloadDuplicateLedgerSlot(downloadId))) {
item.status = 'duplicate'
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
return
}
if (this._framework._shouldClaimDownloadDuplicateLedger() && downloadId) {
item.ledgerClaimed = true
if (!(await this._commitDownloadRow(item))) {
return
}
}
}
if (!downloadUrl) {
item.status = 'failed'
item.error = 'Missing media URL'
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
return
}
try {
await this._paceDownloadInitiation()
if (!(await this._isActiveDownloadClaim(item.itemId))) {
return
}
// Persist before GM_download so a mid-transfer navigation can detect an orphan.
item.inProgress = true
item.startedAt = Date.now()
if (!(await this._commitDownloadRow(item))) {
return
}
await this._wrapDownloadTask({name: path, element: null, url: downloadUrl, downloadId})
item.status = 'done'
item.inProgress = false
} catch (error) {
item.inProgress = false
let rateLimit = await this._handleRateLimit('download', error, {sourceUrl: downloadUrl, resolvingUrl: downloadUrl}, item)
if (rateLimit === 'blocked') {
item.status = 'queued'
item.error = null
await this._commitDownloadRow(item)
return
}
item.status = 'failed'
item.error = String(error)
}
if (await this._commitDownloadRow(item)) {
await this._incrementDownloadProgress()
}
} finally {
await this._onPipelineTaskComplete(item.itemId)
}
}
// -------------------------------------------------------------------------
// Resolution pipeline
// -------------------------------------------------------------------------
/**
* @param {string} url
* @return {Promise<Document>}
* @private
*/
async _resolvePage(url)
{
let controller = new AbortController()
let timeoutId = setTimeout(() => controller.abort(), 30000)
let onPageHide = () => controller.abort()
let onCoordinatorAbort = () => controller.abort()
window.addEventListener('pagehide', onPageHide, {once: true})
this._kernel?.abortSignal?.addEventListener('abort', onCoordinatorAbort, {once: true})
try {
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
throw Object.assign(new Error('Page resolution aborted'), {status: 0, doc: null, url})
}
let response = await fetch(url, {
credentials: 'include',
signal: controller.signal,
headers: {Accept: 'text/html'},
})
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
throw Object.assign(new Error('Page resolution aborted'), {status: 0, doc: null, url})
}
let text = await response.text()
let doc = new DOMParser().parseFromString(text, 'text/html')
// Keep status on reject so timedReload / humanInteraction detectors can use it;
// still attach parsed HTML (site 429 / Cloudflare bodies often arrive as 200 or 4xx).
if (response.status === 429) {
throw Object.assign(new Error('HTTP 429'), {status: 429, doc, url})
}
if (!response.ok) {
throw Object.assign(new Error('HTTP ' + response.status), {status: response.status, doc, url})
}
return doc
} catch (error) {
if (error?.doc !== undefined || error?.status !== undefined) {
throw error
}
let message = error?.name === 'AbortError'
? 'Page resolution aborted'
: ('Page resolution failed: ' + url + ' — ' + (error?.message || error))
throw Object.assign(new Error(message), {status: 0, doc: null, url})
} finally {
clearTimeout(timeoutId)
window.removeEventListener('pagehide', onPageHide)
this._kernel?.abortSignal?.removeEventListener('abort', onCoordinatorAbort)
}
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processResolutionItem(item)
{
try {
// Status is already `resolving` from `_claimResolutionWorkItem`.
let typeHandler = this._config.downloadTypes?.[item.downloadType]
if (!typeHandler) {
item.status = 'failed'
item.error = 'Unknown download type: ' + item.downloadType
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
return
}
try {
let ctx = {
...item.resolveContext,
sourceUrl: item.sourceUrl,
itemId: item.itemId,
downloadType: item.downloadType,
resolvingUrl: item.sourceUrl,
}
if (ctx.fromMediaPage && typeHandler.resolveFromMedia) {
let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [document, ctx], null)
await this._handleResolvedPayload(item, resolved)
return
}
let searchStep = typeHandler.resolveFromSearch ?
await Utilities.callEventHandler(typeHandler.resolveFromSearch, [ctx], null) :
{nextUrl: item.sourceUrl, downloadType: item.downloadType}
let nextUrl = searchStep?.nextUrl ?? item.sourceUrl
ctx.resolvingUrl = nextUrl
let doc
while (true) {
// Pace every attempt (including timedReload retries and post–human-interaction
// resume) so a long Cloudflare pause cannot leave the clock stale relative to
// the next burst of resolves.
await this._paceResolutionInitiation()
if (!(await this._isActiveResolutionClaim(item.itemId))) {
return
}
try {
doc = await this._resolvePage(nextUrl)
} catch (error) {
let rateLimit = await this._handleRateLimit('resolution', error, ctx, item)
if (rateLimit === 'retry') {
continue
}
if (rateLimit === 'blocked') {
item.status = 'queued'
await this._commitResolutionRow(item)
return
}
throw error
}
let rateLimit = await this._handleRateLimit('resolution', doc, ctx, item)
if (rateLimit === 'retry') {
continue
}
if (rateLimit === 'blocked') {
item.status = 'queued'
await this._commitResolutionRow(item)
return
}
break
}
if (typeHandler.resolveFromMedia) {
let resolved = await Utilities.callEventHandler(typeHandler.resolveFromMedia, [doc, ctx], null)
if (resolved?.childUrls?.length) {
await this._fanOutChildUrls(item, resolved.childUrls, resolved.downloadType ?? 'post')
item.status = 'done'
if (await this._removeResolutionRowIfActive(item.itemId)) {
await this._incrementResolutionProgress()
}
return
}
await this._handleResolvedPayload(item, resolved)
return
}
item.status = 'failed'
item.error = 'No resolveFromMedia handler'
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
} catch (error) {
item.status = 'failed'
item.error = String(error?.message ?? error)
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
}
} finally {
await this._onPipelineTaskComplete(item.itemId)
}
}
/**
* @param {object} item
* @param {object|null} resolved
* @return {Promise<void>}
* @private
*/
async _handleResolvedPayload(item, resolved)
{
if (!resolved?.mediaUrl) {
item.status = 'failed'
item.error = 'Resolution produced no media URL'
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
return
}
let outcome = await this._applyTagDiscoveryOutcome(item, resolved)
if (outcome !== 'noop') {
return
}
if (!(await this._promoteToDownloadQueue(item, resolved))) {
return
}
if (!(await this._removeResolutionRowIfActive(item.itemId))) {
return
}
await this._incrementResolutionProgress()
}
/**
* @param {object} item
* @param {object} resolved
* @return {Promise<boolean>}
* @private
*/
async _promoteToDownloadQueue(item, resolved)
{
let itemId = item?.itemId
if (!itemId) {
return false
}
let promoted = await this._repos().download.promoteToDownload(item, resolved)
if (!promoted) {
return false
}
this._signalProcessorsWakeAndRunIfCoordinator()
return true
}
/**
* @param {object} item
* @param {string[]} childUrls
* @param {string} childType
* @return {Promise<void>}
* @private
*/
async _fanOutChildUrls(item, childUrls, childType)
{
for (let sourceUrl of childUrls) {
let childId = this._config.getQueueItemId({sourceUrl})
if (!childId) {
continue
}
await this.enqueueDownload({
itemId: childId,
sourceUrl,
downloadType: childType,
resolveContext: {parentItemId: item.itemId},
})
}
}
// -------------------------------------------------------------------------
// Rate limits
// -------------------------------------------------------------------------
/**
* @param {'resolution'|'download'} context
* @param {*} signal
* @param {object} ctx
* @param {object} item
* @return {Promise<'retry'|'blocked'|false>}
* @private
*/
async _handleRateLimit(context, signal, ctx, item)
{
let handlers = this._config.rateLimitHandlers?.[context]
if (!handlers) {
return false
}
// Prefer humanInteraction over timedReload. Cloudflare challenges often arrive as
// HTTP 429 with a CAPTCHA body — a bare status===429 must not auto-retry those.
let payload = signal?.doc ?? signal
if (handlers.humanInteraction) {
let detect = handlers.humanInteraction.detect
let matched = detect ?
Utilities.callEventHandler(detect, [payload, ctx], false) :
signal?.status === 429
if (matched) {
let openUrl = handlers.humanInteraction.openUrl?.(ctx) ?? ctx.resolvingUrl ?? ctx.sourceUrl
await this._beginHumanInteractionRateLimit(context, item, openUrl)
return 'blocked'
}
}
if (handlers.timedReload) {
let detect = handlers.timedReload.detect
if (Utilities.callEventHandler(detect, [payload, ctx], false)) {
let delayMs = typeof handlers.timedReload.delayMs === 'function' ?
handlers.timedReload.delayMs(ctx) :
handlers.timedReload.delayMs
await Utilities.sleep(delayMs ?? 5000)
return 'retry'
}
}
return false
}
// -------------------------------------------------------------------------
// Human interaction
// -------------------------------------------------------------------------
async _beginHumanInteractionRateLimit(context, item, openUrl)
{
let openUrlMarked = openUrl ? this._markHumanInteractionOpenUrl(openUrl, context) : null
// Sync mirror before the user opens the challenge so Phase-1 mediaCloudflare can detect HI.
this._writeHumanInteractionBlockMirror(true)
// Only a visible tab may own the Open/Done chrome. Background processor leaders leave
// `promptTabId` null so a focused follower can steal immediately on wake.
let canOwnPrompt = document.visibilityState === 'visible' && !this._documentSuspended
// Serialize through `_commitCoordinatorDmState` so a concurrent heartbeat/progress put cannot wipe
// initiation clocks (or clear this block) via a stale raw `_putState` snapshot.
// Per-lane: writing one lane never clobbers the other.
let began = false
await this._commitCoordinatorDmState((state) => {
this._ensureHumanInteractionMap(state)
if (state.humanInteraction[context]) {
// Refresh URL / item; do not re-pin ownership from a hidden initiator.
let existing = state.humanInteraction[context]
let promptTabId = canOwnPrompt
? this._tabId
: (existing.promptTabId === this._tabId ? null : (existing.promptTabId ?? null))
state.humanInteraction[context] = {
itemId: item?.itemId ?? existing.itemId ?? null,
promptTabId,
openUrl: openUrlMarked ?? existing.openUrl ?? null,
at: existing.at ?? Date.now(),
}
began = true
return
}
state.humanInteraction[context] = {
itemId: item?.itemId ?? null,
promptTabId: canOwnPrompt ? this._tabId : null,
openUrl: openUrlMarked,
at: Date.now(),
}
began = true
})
if (!began) {
return
}
this._refreshAllItemProgress()
// Show the dock panel only when this tab is focused — do not auto-open the challenge.
if (canOwnPrompt) {
await this._promptHumanInteractionResume(context)
}
// Wake other visible tabs so a focused follower can steal the panel if this tab
// stays backgrounded (same pattern as tag discovery).
this._signalProcessorsWake()
await this._syncFromStorage()
}
/**
* Marks verification URLs so the opened mediaCloudflare tab stays silent
* (queue confirmation stays on the processor dock panel).
* Query `brazen_hi=1` (+ `brazen_hi_ctx`) plus hash — CF often replaceState-strips
* search params but leaves the hash.
* @param {string} url
* @param {BrazenHumanInteractionLaneId} [context]
* @return {string}
* @private
*/
_markHumanInteractionOpenUrl(url, context = null)
{
try {
let parsed = new URL(url, location.href)
parsed.searchParams.set('brazen_hi', '1')
if (context === 'resolution' || context === 'download') {
parsed.searchParams.set('brazen_hi_ctx', context)
}
let hashParts = ['brazen_hi=1']
if (context === 'resolution' || context === 'download') {
hashParts.push('brazen_hi_ctx=' + context)
}
if (!/(^|[&#])brazen_hi=1\b/.test(parsed.hash)) {
parsed.hash = hashParts.join('&')
} else if (context && !/(^|[&#])brazen_hi_ctx=/.test(parsed.hash)) {
parsed.hash = parsed.hash.replace(/^#/, '') + '&brazen_hi_ctx=' + context
}
return parsed.href
} catch {
return url
}
}
/**
* Open the verification tab while keeping `window.opener` set.
* Chrome 88+ treats `window.open(url, '_blank')` as `noopener` by default, which
* made queue CF tabs look "standalone" and revived the old grey confirm dialog.
* @param {string} url
* @private
*/
_openHumanInteractionTab(url)
{
Utilities.openUrlInNewTab(url, {rel: 'opener'})
}
/**
* @return {string}
* @private
*/
_humanInteractionBlockMirrorKey()
{
return BrazenDownloadManager.storageKey(this._cm._scriptPrefix, 'dm-human-interaction-blocked')
}
/**
* @param {boolean} blocked
* @private
*/
_writeHumanInteractionBlockMirror(blocked)
{
try {
let key = this.humanInteractionBlockMirrorKey()
if (blocked) {
localStorage.setItem(key, '1')
} else {
localStorage.removeItem(key)
}
} catch (e) {
// ignore quota / private-mode localStorage failures
}
}
isHumanInteractionBlockedSync()
{
return this._anyHumanInteraction(this.readDmState())
}
async handleMediaCloudflarePage(options = {})
{
let scriptPrefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
if (await BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)) {
this._showQueueChallengeResumePanel(options)
return
}
this._showStandaloneCloudflareReloadPanel(options)
}
/**
* @return {Promise<boolean>}
*/
async shouldSilenceMediaCloudflarePrompt()
{
let scriptPrefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
return BrazenDownloadManager.shouldSilenceMediaCloudflarePrompt(scriptPrefix, this)
}
_hiLane(state, ctx)
{
return BrazenDownloadManager._hiLaneState(state, ctx)
}
/**
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_anyHumanInteraction(state)
{
return BrazenDownloadManager._anyHumanInteractionState(state)
}
/**
* @param {object|null|undefined} state
* @return {BrazenHumanInteractionLaneId[]}
* @private
*/
_hiContexts(state)
{
return BrazenDownloadManager._hiContextsState(state)
}
/**
* Ensure `state.humanInteraction` is a per-lane map (mutates in place).
* @param {object} state
* @private
*/
_ensureHumanInteractionMap(state)
{
if (!state.humanInteraction || typeof state.humanInteraction !== 'object') {
state.humanInteraction = {resolution: null, download: null}
}
if (!('resolution' in state.humanInteraction)) {
state.humanInteraction.resolution = null
}
if (!('download' in state.humanInteraction)) {
state.humanInteraction.download = null
}
}
/**
* Show the human-interaction dock panel for one lane on the focused tab (steals ownership).
* Includes the queue verification / media challenge tab so Done — resume appears
* where the CAPTCHA was completed.
* @param {BrazenHumanInteractionLaneId} context
* @param {{force?: boolean}} [options] `force` skips the visibility gate (rare; preferred
* path only shows on visible tabs so background leaders do not pin the chrome).
* @return {Promise<void>}
* @private
*/
async _promptHumanInteractionResume(context, options = {})
{
if (context !== 'resolution' && context !== 'download') {
return
}
let state = await this._getState()
if (!this._hiLane(state, context)) {
// Remote clear — drop a stale local panel instead of leaving a dead zombie.
this._hideHumanInteractionPanel(context)
return
}
if (!options.force && document.visibilityState !== 'visible') {
return
}
// Focused tab always claims / steals — same model as tag discovery (not processor-leader-only).
if (!(await this._claimHumanInteractionPromptOwnership(context))) {
return
}
state = this.readDmState()
let lane = this._hiLane(state, context)
if (!lane || lane.promptTabId !== this._tabId) {
return
}
let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
let onChallengeTab = this._isQueueVerificationTab()
let openLabel = this._resolveHumanInteractionOpenLabel(handlers)
let defaultTitle = this._humanInteractionDefaultTitle(context)
// Challenge media tab: always use the Phase-1 challenge pane (Done — resume +
// Done — resume — close tab). Do not reuse the leader Open/Done chrome.
if (onChallengeTab) {
this._showQueueChallengeResumePanel({
title: handlers.confirmTitle ?? defaultTitle,
message: handlers.challengeConfirmMessage
?? 'Complete the verification on this page, then resume the download queue.',
confirmLabel: handlers.confirmLabel,
confirmAndCloseLabel: handlers.confirmAndCloseLabel,
})
return
}
let panel = this._ensureHumanInteractionPanel(context, {reopenLabel: openLabel})
BrazenViewLayer.updateHumanInteractionPanelContent(panel, {
title: handlers.confirmTitle ?? defaultTitle,
message: handlers.confirmMessage,
confirmLabel: handlers.confirmLabel,
reopenLabel: openLabel,
})
let reopenButton = panel.querySelector('.bv-human-interaction-reopen')
if (reopenButton) {
reopenButton.hidden = false
}
this._showHumanInteractionPanelInteractive(context, panel, {forceShow: true})
}
/**
* Label for the Open-challenge control. Ignores legacy consumer copy
* ("Reopen verification tab") so GreasyFork apps without a local override still rename.
* @param {object} [handlers]
* @return {string}
* @private
*/
_resolveHumanInteractionOpenLabel(handlers = {})
{
let label = handlers.openChallengeLabel ?? handlers.reopenLabel
if (!label || label === 'Reopen verification tab') {
return 'Open Cloudflare Challenge'
}
return label
}
/**
* @param {BrazenHumanInteractionLaneId} context
* @return {string}
* @private
*/
_humanInteractionDefaultTitle(context)
{
return context === 'download' ? 'Download verification required' : 'Resolution verification required'
}
/**
* @param {BrazenHumanInteractionLaneId} context
* @param {{reopenLabel?: string}} [options]
* @return {HTMLElement}
* @private
*/
_ensureHumanInteractionPanel(context, options = {})
{
let existing = this._humanInteractionPanels[context]
if (existing) {
// Recreate when left in an inconsistent offscreen/visible state (dead clicks).
let stuckOffscreen = existing.classList.contains('bv-dock-panel-offscreen')
&& !existing.classList.contains('bv-dock-panel-hidden')
&& existing.style.display === 'flex'
if (stuckOffscreen) {
this._framework._hideDockSlidePanel(existing, false)
existing.remove()
this._humanInteractionPanels[context] = null
} else {
if (options.reopenLabel) {
let reopenButton = existing.querySelector('.bv-human-interaction-reopen')
if (reopenButton) {
reopenButton.textContent = options.reopenLabel
}
}
return existing
}
}
let panelId = context === 'download'
? 'bv-human-interaction-panel-download'
: 'bv-human-interaction-panel-resolution'
let handlers = this._config.rateLimitHandlers?.[context]?.humanInteraction ?? {}
let panel = BrazenViewLayer.createHumanInteractionPanel({
id: panelId,
onConfirm: () => { void this._confirmHumanInteractionResume(context) },
onReopen: () => { void this._reopenHumanInteractionTab(context) },
reopenLabel: options.reopenLabel,
title: handlers.confirmTitle ?? this._humanInteractionDefaultTitle(context),
})
this._humanInteractionPanels[context] = panel
BrazenViewLayer.appendToDockPanelStack(panel)
return panel
}
/**
* Show an HI panel and clear a stuck `bv-dock-panel-offscreen` (pointer-events: none).
* @param {BrazenHumanInteractionLaneId} context
* @param {HTMLElement} panel
* @private
*/
_showHumanInteractionPanelInteractive(context, panel, options = {})
{
if (!panel) {
return
}
BrazenViewLayer._clearDockSlidePanelHideTimer(panel)
let forceShow = options.forceShow === true
if (!forceShow && BrazenViewLayer.isDockSlidePanelVisible(panel) &&
!panel.classList.contains('bv-dock-panel-offscreen')) {
return
}
this._framework._showDockSlidePanel(panel)
panel.classList.remove('bv-dock-panel-offscreen')
}
/**
* Themed dock pane on a browsed challenge page (not queue-driven). Same chrome as the
* leader verification panel — never a grey modal overlay.
* @param {{title?: string, message?: string, confirmLabel?: string}} [options]
* @private
*/
_showStandaloneCloudflareReloadPanel(options = {})
{
if (document.getElementById('bv-human-interaction-panel')) {
return
}
let panel = BrazenViewLayer.createHumanInteractionPanel({
id: 'bv-human-interaction-panel',
showReopen: false,
title: options.title ?? 'Cloudflare challenge',
message: options.message ??
'Complete the CAPTCHA on this page, then confirm to reload and continue.',
confirmLabel: options.confirmLabel ?? 'Done — reload',
onConfirm: () => location.reload(),
})
BrazenViewLayer.appendToDockPanelStack(panel)
this._framework._showDockSlidePanel(panel)
}
/**
* Phase-1 queue Cloudflare tab: show Done — resume on the challenge media page itself
* (full DM init is halted on mediaCloudflare).
* @param {{
* title?: string,
* message?: string,
* confirmLabel?: string,
* confirmAndCloseLabel?: string,
* }} [options]
* @private
*/
_showQueueChallengeResumePanel(options = {})
{
let context = BrazenDownloadManager._peekHumanInteractionContextFromLocation() ?? 'download'
let panelId = context === 'resolution'
? 'bv-human-interaction-panel-resolution'
: 'bv-human-interaction-panel-download'
let handlers = this._config?.rateLimitHandlers?.[context]?.humanInteraction ?? {}
let title = options.title ?? handlers.confirmTitle ??
(context === 'resolution' ? 'Resolution verification required' : 'Cloudflare challenge')
let message = options.message ?? handlers.challengeConfirmMessage ??
'Complete the Cloudflare check on this page, then resume the download queue.'
// Label defaults live on View Layer createHumanInteractionPanel — omit when unset.
let confirmLabel = options.confirmLabel ?? handlers.confirmLabel
let confirmAndCloseLabel = options.confirmAndCloseLabel ?? handlers.confirmAndCloseLabel
let existing = this._humanInteractionPanels[context]
?? document.getElementById(panelId)
?? document.getElementById('bv-human-interaction-panel')
if (existing?.querySelector?.('.bv-human-interaction-confirm-close')) {
BrazenViewLayer.updateHumanInteractionPanelContent(existing, {
title,
message,
confirmLabel,
confirmAndCloseLabel,
})
existing.querySelector('.bv-human-interaction-reopen')?.remove()
this._humanInteractionPanels[context] = existing
this._showHumanInteractionPanelInteractive(context, existing, {forceShow: true})
return
}
// Leader Open/Done chrome (or a stale pane) — replace with challenge controls.
if (existing) {
this._framework._hideDockSlidePanel(existing)
existing.remove()
if (this._humanInteractionPanels[context] === existing) {
this._humanInteractionPanels[context] = null
}
}
let panel = BrazenViewLayer.createHumanInteractionPanel({
id: panelId,
showReopen: false,
title,
message,
confirmLabel,
confirmAndCloseLabel,
onConfirm: () => { void this._confirmHumanInteractionFromChallengePage(context, panel) },
onConfirmAndClose: () => {
void this._confirmHumanInteractionFromChallengePage(context, panel, {closeTab: true})
},
})
this._humanInteractionPanels[context] = panel
BrazenViewLayer.appendToDockPanelStack(panel)
this._showHumanInteractionPanelInteractive(context, panel, {forceShow: true})
}
_hideHumanInteractionPanel(context = null)
{
if (context === 'resolution' || context === 'download') {
let panel = this._humanInteractionPanels[context]
if (panel) {
this._framework._hideDockSlidePanel(panel)
}
return
}
for (let ctx of /** @type {BrazenHumanInteractionLaneId[]} */ (['resolution', 'download'])) {
let panel = this._humanInteractionPanels[ctx]
if (panel) {
this._framework._hideDockSlidePanel(panel)
}
}
}
/**
* True when this document is a queue-opened Cloudflare / verification tab
* (`brazen_hi` marker). Same-origin opener alone is not enough — normal search/media
* tabs opened from another tab must still show Open Cloudflare Challenge.
* @return {boolean}
* @private
*/
_isQueueVerificationTab()
{
return BrazenDownloadManager.hasBrazenHumanInteractionMarker()
}
/**
* Hide local HI panels and drop ownership so another focused tab can claim them.
* Does not clear lane blocks — only Done — resume unlocks the queue.
* @param {{hideLocal?: boolean}} [options] When false, only clears IDB ownership (minimize / tab blur).
* @return {Promise<void>}
* @private
*/
async _releaseHumanInteractionPanelOwnership(options = {})
{
let stillBlocked = false
await Utilities.releaseDockPanelOwnership({
hideLocal: options.hideLocal,
hidePanel: () => this._hideHumanInteractionPanel(),
documentSuspended: this._documentSuspended,
commitRelease: async () => {
// Panel ownership is coordinator-only IDB; visibility handlers can fire during
// initialize() before the Web Lock is held (even on the sole tab).
if (!this.isCoordinator()) {
return
}
await this._commitCoordinatorDmState((state) => {
this._ensureHumanInteractionMap(state)
for (let ctx of /** @type {BrazenHumanInteractionLaneId[]} */ (['resolution', 'download'])) {
let lane = state.humanInteraction[ctx]
if (lane?.promptTabId === this._tabId) {
lane.promptTabId = null
}
}
stillBlocked = this._anyHumanInteraction(state)
})
},
wakeAfterRelease: () => {
if (stillBlocked) {
this._requestPipelineRun()
}
},
})
}
async _confirmHumanInteractionFromChallengePage(context = null, panel = null, options = {})
{
let prefix = BrazenDownloadManager.storageKey(this._cm?._scriptPrefix, '')
let state = null
let repos = null
try {
if (typeof BrazenStorageRepositories === 'function') {
repos = new BrazenStorageRepositories(prefix)
if (repos.storage.available) {
await repos.storage.open()
state = await repos.downloadManagerState.get()
}
}
} catch (e) {
// ignore — fall through to URL / default context
}
let ctx = context
?? BrazenDownloadManager._peekHumanInteractionContextFromLocation()
?? BrazenDownloadManager._resolveChallengeHumanInteractionContext(state)
?? 'download'
let el = panel
?? document.getElementById(
ctx === 'resolution'
? 'bv-human-interaction-panel-resolution'
: 'bv-human-interaction-panel-download',
)
?? document.getElementById('bv-human-interaction-panel')
if (el) {
this._framework._hideDockSlidePanel(el)
}
try {
if (repos?.storage?.available && state) {
state = await repos.download.clearHumanInteractionLaneFromSnapshot(ctx, state)
if (state) {
this._publishDmStateToAtoms(state)
this._writeHumanInteractionBlockMirror(
BrazenDownloadManager._anyHumanInteractionState(state),
)
}
}
} catch (e) {
// ignore IDB failures on challenge tabs
this._writeHumanInteractionBlockMirror(false)
}
this._signalProcessorsWakeAndRunIfCoordinator()
if (!options.closeTab) {
return
}
try {
window.close()
} catch (e) {
// ignore — browsers often block close for non-script-opened tabs
}
// Resume already succeeded; if close was ignored, surface a short note.
await new Promise((resolve) => setTimeout(resolve, 50))
if (window.closed || !el) {
return
}
BrazenViewLayer.updateHumanInteractionPanelContent(el, {
message: 'Queue resumed. Close this tab manually if it stays open.',
})
el.querySelector('.bv-dock-list-footer')?.replaceChildren()
this._framework._showDockSlidePanel(el)
}
/**
* @param {BrazenHumanInteractionLaneId} context
* @return {Promise<void>}
* @private
*/
async _confirmHumanInteractionResume(context)
{
this._hideHumanInteractionPanel(context)
await this._clearHumanInteractionBlock(context)
this._signalProcessorsWakeAndRunIfCoordinator()
}
/**
* Re-open the verification URL if the user closed that tab by mistake.
* Opens synchronously from the sync cache so the click keeps user activation.
* @param {BrazenHumanInteractionLaneId} context
* @return {Promise<void>}
* @private
*/
async _reopenHumanInteractionTab(context)
{
let openUrl = this._hiLane(this.readDmState(), context)?.openUrl
if (openUrl) {
this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl, context))
return
}
// Cache miss — await may lose transient user activation; best-effort only.
let state = await this._getState()
openUrl = this._hiLane(state, context)?.openUrl
if (openUrl) {
this._openHumanInteractionTab(this._markHumanInteractionOpenUrl(openUrl, context))
return
}
let panel = this._humanInteractionPanels[context]
if (panel) {
BrazenViewLayer.updateHumanInteractionPanelContent(panel, {
message: 'Could not open the challenge tab (missing URL). Try Done — resume, or reload this page.',
})
}
}
async _clearHumanInteractionBlock(context)
{
if (context !== 'resolution' && context !== 'download') {
return
}
this._hideHumanInteractionPanel(context)
this._stampInitiationClock(context, Date.now())
await this.sendCommand({type: 'clear-hi-lane', payload: {context}})
this._writeHumanInteractionBlockMirror(
this._anyHumanInteraction(this.readDmState()),
)
this._refreshAllItemProgress()
await this._syncFromStorage()
this._stampInitiationClock(context, Date.now())
}
async _maybeExpireHumanInteractionBlocks(state = null)
{
let snapshot = state ?? this.readDmState() ?? await this._getState()
let clearedAny = false
for (let ctx of /** @type {BrazenHumanInteractionLaneId[]} */ (['resolution', 'download'])) {
let lane = this._hiLane(snapshot, ctx)
if (!lane) {
continue
}
let expireMs = Number(
this._config.rateLimitHandlers?.[ctx]?.humanInteraction?.softExpireMs,
)
if (!(Number.isFinite(expireMs) && expireMs > 0)) {
expireMs = BrazenDownloadManager.SOFT_EXPIRE_MS
}
if (Date.now() - (Number(lane.at) || 0) < expireMs) {
continue
}
await this._clearHumanInteractionBlock(ctx)
clearedAny = true
snapshot = this.readDmState() ?? snapshot
}
if (clearedAny) {
this._signalProcessorsWakeAndRunIfCoordinator()
}
return clearedAny
}
/**
* Re-show / reclaim HI panels on a visible tab; hide local panels when the lane was cleared remotely.
* @param {object|null|undefined} [state]
* @return {Promise<void>}
* @private
*/
async _restoreHumanInteractionPanelsIfNeeded(state = null)
{
if (this._documentSuspended) {
return
}
await this._maybeExpireHumanInteractionBlocks(state)
let snapshot = this.readDmState() ?? state ?? await this._getState()
for (let ctx of /** @type {BrazenHumanInteractionLaneId[]} */ (['resolution', 'download'])) {
let lane = this._hiLane(snapshot, ctx)
if (!lane) {
this._hideHumanInteractionPanel(ctx)
continue
}
// Orphan lane (item gone from both queues) — clear so Waiting cannot stick forever.
if (lane.itemId != null && this._cm.canPersist()) {
let itemKey = String(lane.itemId)
let [resolution, download] = await Promise.all([
this._repos().downloadResolutionQueue.get(itemKey),
this._repos().downloadQueue.get(itemKey),
])
let resolutionAlive = !!(resolution && !RESOLUTION_TERMINAL.has(resolution.status))
let downloadAlive = !!(download && !DOWNLOAD_TERMINAL.has(download.status))
if (!resolutionAlive && !downloadAlive) {
await this._clearHumanInteractionBlock(ctx)
this._signalProcessorsWakeAndRunIfCoordinator()
snapshot = this.readDmState() ?? snapshot
continue
}
}
if (document.visibilityState !== 'visible') {
continue
}
void this._promptHumanInteractionResume(ctx)
}
}
/**
* Lightweight periodic reclaim so a stale/closed `promptTabId` cannot leave HI blocked forever.
* @private
*/
_startHumanInteractionWatchdog()
{
if (this._humanInteractionWatchdogTimer) {
return
}
this._humanInteractionWatchdogTimer = setInterval(() => {
if (this._documentSuspended) {
return
}
void this._maybeExpireHumanInteractionBlocks()
if (document.visibilityState !== 'visible') {
return
}
let state = this.readDmState()
if (!this._anyHumanInteraction(state)) {
// Still hide any leftover local zombie if lanes are clear.
this._hideHumanInteractionPanel()
} else {
void this._restoreHumanInteractionPanelsIfNeeded(state)
}
let snapshot = this.readDmState() ?? state
if (snapshot?.resolutionBlocked) {
void this.restoreTagDiscoveryPanelIfNeeded()
}
}, BrazenDownloadManager.WATCHDOG_MS)
}
/**
* @private
*/
_stopHumanInteractionWatchdog()
{
if (!this._humanInteractionWatchdogTimer) {
return
}
clearInterval(this._humanInteractionWatchdogTimer)
this._humanInteractionWatchdogTimer = null
}
/**
* Show the navigation-interrupted download decision panel (visible leader only).
* Click marks pending rows done and pauses the queue; timeout requeues and relaunches.
* @param {object[]|null|undefined} rows
* @private
*/
_showDownloadInterruptionPanel(rows)
{
if (!rows?.length || document.visibilityState !== 'visible' || this._documentSuspended) {
return
}
this._downloadInterruptionPending = true
this._downloadInterruptionRows = rows
this._hideDownloadInterruptionPanel({keepPending: true})
let count = rows.length
let panel = BrazenViewLayer.createDownloadInterruptionPanel({
message: count === 1
? 'A download was still in progress when this page loaded. Click below to mark it complete and pause the queue, or wait for the timer to relaunch it.'
: `${count} downloads were still in progress when this page loaded. Click below to mark them complete and pause the queue, or wait for the timer to relaunch them.`,
onComplete: () => { void this._completeDownloadInterruption() },
onTimeout: () => { void this._timeoutDownloadInterruption() },
})
this._downloadInterruptionPanel = panel
BrazenViewLayer.appendToDockPanelStack(panel)
this._framework._showDockSlidePanel(panel)
}
/**
* @param {{keepPending?: boolean}} [options]
* @private
*/
_hideDownloadInterruptionPanel(options = {})
{
let panel = this._downloadInterruptionPanel
?? document.getElementById('bv-download-interruption-panel')
if (panel) {
panel._bvTimedButtonApi?.cancel?.()
this._framework._hideDockSlidePanel(panel)
panel.remove()
}
this._downloadInterruptionPanel = null
if (!options.keepPending) {
this._downloadInterruptionPending = false
this._downloadInterruptionRows = null
}
}
/**
* User clicked the timed button: treat interrupted downloads as complete and pause.
* @return {Promise<void>}
* @private
*/
async _completeDownloadInterruption()
{
let rows = this._downloadInterruptionRows ?? []
this._hideDownloadInterruptionPanel()
if (!this._cm.canPersist()) {
return
}
for (let row of rows) {
let current = await this._repos().downloadQueue.get(row.itemId)
if (!current || current.status !== 'downloading') {
continue
}
current.status = 'done'
current.inProgress = false
current.error = null
await this._repos().download.commitDownload(current)
await this._incrementDownloadProgress()
await this._clearTerminalQueueRows(current.itemId)
}
await this._commitCoordinatorDmState((state) => {
state.paused = true
})
this._refreshDockProgress({refreshAllItems: true})
await this._syncFromStorage()
}
/**
* Timed button expired: requeue interrupted downloads and relaunch (today's default).
* @return {Promise<void>}
* @private
*/
async _timeoutDownloadInterruption()
{
let rows = this._downloadInterruptionRows ?? []
this._hideDownloadInterruptionPanel()
if (!this._cm.canPersist()) {
return
}
for (let row of rows) {
let current = await this._repos().downloadQueue.get(row.itemId)
if (!current || current.status !== 'downloading') {
continue
}
current.status = 'queued'
current.inProgress = false
current.error = null
await this._repos().download.commitDownload(current)
}
this._signalProcessorsWakeAndRunIfCoordinator()
}
/**
* Advance one pipeline's in-tab initiation clock to at least `at`.
* @param {'resolution'|'download'} kind
* @param {number} at
* @private
*/
_stampInitiationClock(kind, at)
{
let ts = at ?? Date.now()
let localKey = kind === 'download' ? '_lastDownloadInitiationAt' : '_lastResolutionInitiationAt'
let stateKey = kind === 'download' ? 'lastDownloadInitiationAt' : 'lastResolutionInitiationAt'
this[localKey] = Math.max(this[localKey] ?? 0, ts)
let dmState = this.readDmState()
if (dmState) {
dmState[stateKey] = Math.max(dmState[stateKey] ?? 0, ts)
this._publishDmStateToAtoms(dmState)
}
}
// -------------------------------------------------------------------------
// Tag discovery
// -------------------------------------------------------------------------
/**
* Write extracted typed tag groups into the tag registry.
* Discovery on → register-on-seen (`media`, lastSeenTypeEntryId only; isDiscovered stays null).
* Discovery off → confirm as-is (`resolution`, sets typeEntryId and isDiscovered).
*
* @param {Record<string, string[]>|null|undefined} tagGroups
* @return {Promise<void>}
* @private
*/
async _registerResolvedTagGroups(tagGroups)
{
if (!this._cm.canPersist() || !tagGroups || typeof tagGroups !== 'object') {
return
}
let normalize = this._getDownloadTagNormalize()
let normalizedGroups = {}
for (let [typeName, names] of Object.entries(tagGroups)) {
if (!Array.isArray(names)) {
continue
}
let list = []
let seen = new Set()
for (let name of names) {
if (!name) {
continue
}
let normalized = normalize(name) || name
if (seen.has(normalized)) {
continue
}
seen.add(normalized)
list.push(normalized)
}
if (list.length) {
normalizedGroups[typeName] = list
}
}
let hasNames = Object.values(normalizedGroups).some((names) => names.some(Boolean))
if (!hasNames) {
return
}
let source = this._isTagDiscoveryActive() ? 'media' : 'resolution'
await this._cm.registerTypedTagGroups(normalizedGroups, source)
}
/**
* @return {boolean}
*/
isTagDiscoveryEnabled()
{
return !!this.readDmState()?.tagDiscoveryEnabled
}
/**
* @return {boolean}
* @private
*/
_isTagDiscoveryActive()
{
return !!this._config.tagDiscovery && this.isTagDiscoveryEnabled()
}
/**
* @param {string} step
* @return {boolean}
* @private
*/
_shouldRunTagDiscovery(step)
{
return this._config.tagDiscovery?.discoverAt?.includes(step) ?? false
}
/**
* After discovery review: mark tags {@link TagEntry.isDiscovered} and promote seen types
* to {@link TagEntry.typeEntryId} when still unset (attributes may have typed earlier).
* @param {Array<{name: string, type: string|null}>|null} tags
* @return {Promise<void>}
* @private
*/
async _confirmDiscoveryPanelTagTypes(tags)
{
if (!tags?.length) {
return
}
let tagRuntime = this._cm.getTagRuntime()
if (!tagRuntime) {
return
}
await this._repos().meta.beginTagsRevisionBatch()
try {
for (let tag of tags) {
if (!tag?.name) {
continue
}
let entry = await tagRuntime.getTag(tag.name)
// Already discovered with a type — nothing left for Confirm.
if (entry?.isDiscovered === true && entry?.typeEntryId != null) {
continue
}
// Prefer existing type / lastSeen from resolution; fall back to the panel row's type.
let typeEntryId = null
let typeName = null
if (entry?.typeEntryId == null) {
typeEntryId = entry?.meta?.lastSeenTypeEntryId ?? null
if (typeEntryId == null && tag.type) {
typeName = tag.type
typeEntryId = await this._repos().tags.resolveCanonicalTypeEntryId(tag.type)
}
} else {
typeEntryId = entry.typeEntryId
}
// discovery-confirm sets isDiscovered even when typeEntryId was set via attributes.
await tagRuntime.ensureTag(tag.name, {
typeEntryId,
typeName,
source: 'discovery-confirm',
})
}
} finally {
await this._repos().meta.endTagsRevisionBatch()
}
}
/**
* @param {{}} tagGroups
* @param {Record<string, number|string>|null|undefined} [tagIncidences]
* @return {Promise<{unknown: Array<{name: string, type: string|null, count: number|null}>, known: Array<{name: string, type: string|null, count: number|null}>}>}
* @private
*/
async _partitionDiscoveryTags(tagGroups, tagIncidences = {})
{
let empty = {unknown: [], known: []}
if (!this._config.tagDiscovery) {
return empty
}
let resolver = this._config.downloadPaths.getPatternResolver()
let patterns = this._getActiveDownloadPatterns()
let relevantTypes = new Set()
for (let chip of resolver.chips) {
if (!resolver.tagTypes.includes(chip.token)) {
continue
}
for (let pattern of patterns) {
if (this.patternIncludesChip(pattern, chip)) {
relevantTypes.add(chip.token)
}
}
}
// Pattern pins always own scan scope. Optional tagDiscovery.tagTypes array is an
// allowlist intersect only — never a replacement that expands beyond pins.
let tagTypes = [...relevantTypes]
let configured = this._config.tagDiscovery.tagTypes
if (Array.isArray(configured) && configured.length) {
let allow = new Set(configured)
tagTypes = tagTypes.filter((type) => allow.has(type))
}
if (!tagTypes.length) {
return empty
}
let candidates = []
let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
for (let type of tagTypes) {
for (let tag of tagGroups[type] ?? []) {
if (!tag) {
continue
}
candidates.push({
name: tag,
type,
count: this._resolveTagIncidence(tag, incidences),
})
}
}
// One TagRuntime ensure for the whole set — avoid per-tag IDB round-trips.
let tagRuntime = this._cm.getTagRuntime?.()
if (tagRuntime && candidates.length) {
try {
await tagRuntime.ensureNames(candidates.map((entry) => entry.name))
} catch (error) {
// Fall through to per-tag lookups.
}
}
let unknown = []
let known = []
for (let entry of candidates) {
if (await this._isTagKnownForDiscovery(entry.name)) {
known.push(entry)
} else {
unknown.push(entry)
}
}
return {unknown, known}
}
/**
* @param {string} tagName
* @param {Record<string, number|string>} incidences
* @return {number|null}
* @private
*/
_resolveTagIncidence(tagName, incidences)
{
if (!incidences || typeof incidences !== 'object') {
return null
}
let raw = incidences[tagName]
if (raw == null) {
let lower = String(tagName).toLowerCase()
for (let [key, value] of Object.entries(incidences)) {
if (String(key).toLowerCase() === lower) {
raw = value
break
}
}
}
if (raw == null || raw === '') {
return null
}
let count = typeof raw === 'number' ? raw : parseInt(String(raw).replace(/[^\d]/g, ''), 10)
return Number.isFinite(count) ? count : null
}
/**
* Whether a tag has completed discovery review ({@link TagEntry.isDiscovered}).
* Type may be set earlier via attribute actions; discovery is gated separately.
* Script {@link tagDiscovery.isTagKnown} overrides; default uses {@link TagRepository.getByName}.
*
* @param {string} tagName
* @return {Promise<boolean>}
* @private
*/
async _isTagKnownForDiscovery(tagName)
{
if (this._config.tagDiscovery?.isTagKnown) {
let result = Utilities.callEventHandler(this._config.tagDiscovery.isTagKnown, [tagName], false)
return !!await result
}
let tagRuntime = this._cm.getTagRuntime?.()
let cached = tagRuntime?.resolveCachedByName?.(tagName)
if (cached) {
return cached.isDiscovered === true
}
if (!this._cm.canPersist()) {
return false
}
let tag = await this._repos().tags.getByName(tagName)
return !!(tag && tag.isDiscovered === true)
}
/**
* @return {string[]}
* @private
*/
_getActiveDownloadPatterns()
{
let paths = this._config.downloadPaths
let patterns = []
if (paths.filenamePatternConfigKey) {
patterns.push(this._framework._getConfig(paths.filenamePatternConfigKey) ?? '')
}
if (paths.subfolderPatternConfigKey) {
patterns.push(this._framework._getConfig(paths.subfolderPatternConfigKey) ?? '')
}
return patterns
}
/**
* @return {boolean}
* @private
*/
_isIgnoredPinReviewEnabled()
{
return this._isTagDiscoveryBehaviorEnabled(
OPTION_REVIEW_IGNORED_FILENAME_PINS,
this._config.tagDiscovery?.ignoredPinReview,
)
}
/**
* @return {boolean}
* @private
*/
_isSkipEmptyFilenamePinsEnabled()
{
return this._isTagDiscoveryBehaviorEnabled(
OPTION_SKIP_EMPTY_FILENAME_PINS,
this._config.tagDiscovery?.skipEmptyFilenamePins,
)
}
/**
* @param {string} optionConst
* @param {{isEnabled?: function}|null|undefined} configHook
* @return {boolean}
* @private
*/
_isTagDiscoveryBehaviorEnabled(optionConst, configHook)
{
if (configHook?.isEnabled) {
return !!Utilities.callEventHandler(configHook.isEnabled, [], false)
}
if (!this._cm.getField(optionConst)) {
return false
}
return !!this._framework._getConfig(optionConst)
}
/**
* @return {boolean}
* @private
*/
_isDeferredTagDiscoveryEnabled()
{
if (!this._cm.getField(OPTION_DEFER_TAG_DISCOVERY)) {
return false
}
return !!this._framework._getConfig(OPTION_DEFER_TAG_DISCOVERY)
}
/**
* Defer unknown-tag and ignored-pin review to the discovery lane (batch only).
* @return {boolean}
* @private
*/
_shouldDeferTagDiscoveryReview()
{
return this._isDeferredTagDiscoveryEnabled() && this._isTagDiscoveryActive()
}
/**
* @return {Promise<number>}
* @private
*/
async _countDiscoveryQueuedItems()
{
return this._repos().downloadResolutionQueue.listByStatus('discoveryQueued').
then((rows) => rows.length)
}
/**
* Clear discovery-lane phase when no deferred rows remain.
* @return {Promise<void>}
* @private
*/
async _maybeEndDiscoveryLanePhase()
{
if (!this._isDeferredTagDiscoveryEnabled()) {
return
}
let pending = await this._countDiscoveryQueuedItems()
if (pending > 0) {
return
}
await this._commitCoordinatorDmState((state) => {
state.discoveryLanePhaseActive = false
})
}
/**
* @param {object|null|undefined} state
* @return {Promise<object|null>}
* @private
*/
async _peekResolutionWork(state)
{
if (state?.resolutionBlocked) {
return null
}
let repo = this._repos().downloadResolutionQueue
if (!this._isDeferredTagDiscoveryEnabled()) {
return repo.peekNextQueued()
}
if (state?.discoveryLanePhaseActive) {
return repo.peekNextDiscoveryQueued()
}
let next = await repo.peekNextQueued()
if (next) {
return next
}
let deferred = await repo.peekNextDiscoveryQueued()
if (!deferred) {
return null
}
await this._commitCoordinatorDmState((nextState) => {
nextState.discoveryLanePhaseActive = true
})
return deferred
}
/**
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _processResolutionWorkItem(item)
{
if (item.pendingTagGroups && this._isDeferredTagDiscoveryEnabled()) {
await this._activateDeferredDiscoveryItem(item)
return
}
await this._processResolutionItem(item)
}
/**
* Open tag review for a deferred discovery-lane item (no re-fetch).
* @param {object} item
* @return {Promise<void>}
* @private
*/
async _activateDeferredDiscoveryItem(item)
{
try {
let pending = item.pendingTagGroups
if (!pending?.mediaUrl) {
item.status = 'failed'
item.error = 'Deferred discovery item has no resolved payload'
if (await this._commitResolutionRow(item)) {
await this._incrementResolutionProgress()
}
return
}
let outcome = await this._applyTagDiscoveryOutcome(item, pending, {
registerTags: false,
forceImmediate: true,
endDiscoveryLaneOnFinish: true,
})
if (outcome !== 'noop') {
return
}
if (!(await this._promoteToDownloadQueue(item, pending))) {
return
}
if (!(await this._removeResolutionRowIfActive(item.itemId))) {
return
}
await this._incrementResolutionProgress()
await this._maybeEndDiscoveryLanePhase()
} finally {
await this._onPipelineTaskComplete(item.itemId)
}
}
/**
* Tag-type tokens present as chips in the active filename/subfolder patterns.
* @return {string[]}
* @private
*/
_getPinnedFilenameTagTypes()
{
if (!this._config.downloadPaths?.getPatternResolver) {
return []
}
let resolver = this._config.downloadPaths.getPatternResolver()
let patterns = this._getActiveDownloadPatterns()
let pinned = []
let seen = new Set()
for (let chip of resolver.chips ?? []) {
if (!resolver.tagTypes.includes(chip.token) || seen.has(chip.token)) {
continue
}
if (!this._activePatternsIncludeTagType(patterns, chip.token, resolver.chips)) {
continue
}
seen.add(chip.token)
pinned.push(chip.token)
}
return pinned
}
/**
* True when any active filename/subfolder tag-type pin has at least one raw tag on the post.
* Filename Tag Ignore List is not applied.
* @param {{}} tagGroups
* @return {boolean}
* @private
*/
_hasRawTagsForAnyFilenamePin(tagGroups)
{
let groups = tagGroups && typeof tagGroups === 'object' ? tagGroups : {}
for (let type of this._getPinnedFilenameTagTypes()) {
if ((groups[type] ?? []).some(Boolean)) {
return true
}
}
return false
}
/**
* Queue-only: drop before promote when enabled and no pinned type has raw tags.
* No-op when patterns contain no tag-type pins.
* @param {{}} tagGroups
* @return {boolean}
* @private
*/
_shouldSkipEmptyFilenamePins(tagGroups)
{
if (!this._isSkipEmptyFilenamePinsEnabled()) {
return false
}
let pinned = this._getPinnedFilenameTagTypes()
if (!pinned.length) {
return false
}
return !this._hasRawTagsForAnyFilenamePin(tagGroups)
}
/**
* Drop a resolved resolution item without promoting to the download queue.
* @param {object} item
* @param {{clearDiscoveryGate?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _dropResolvedWithoutDownload(item, options = {})
{
let itemId = item?.itemId
if (!itemId) {
return
}
let removed = await this._removeResolutionRowIfActive(itemId)
if (!removed) {
removed = await this._repos().download.removeResolutionIfExists(itemId)
}
if (this._selectionUiPending.has(String(itemId))) {
return
}
let element = this._trackedItemElements.get(String(itemId))
this._untrackItemElement(itemId)
if (element) {
this._setItemProgress(element, null)
}
if (options.clearDiscoveryGate) {
await this._commitCoordinatorDmState((next) => {
this._clearDiscoveryReviewState(next)
})
await this._syncFromStorage()
}
if (removed) {
await this._incrementResolutionProgress()
}
}
/**
* True when any active filename/subfolder pattern includes a chip for {@link type}.
* @param {string[]} patterns
* @param {string} type
* @param {{token: string, label: string}[]} chips
* @return {boolean}
* @private
*/
_activePatternsIncludeTagType(patterns, type, chips)
{
for (let chip of chips ?? []) {
if (chip.token !== type) {
continue
}
for (let pattern of patterns) {
if (this.patternIncludesChip(pattern, chip)) {
return true
}
}
}
return false
}
/**
* Resolve `tagGroups` from a resolution payload (nested or flat).
* @param {object|null|undefined} resolved
* @return {{}}
* @private
*/
_tagGroupsFromResolvedPayload(resolved)
{
if (!resolved || typeof resolved !== 'object') {
return {}
}
if (resolved.tagGroups && typeof resolved.tagGroups === 'object' && !Array.isArray(resolved.tagGroups)) {
return resolved.tagGroups
}
// Defensive: older/flat payloads stored groups at the top level.
if (Array.isArray(resolved.author) || Array.isArray(resolved.character) ||
Array.isArray(resolved.copyright) || Array.isArray(resolved.general)) {
return resolved
}
return {}
}
/**
* Whether a post tag is filename-ignored — same source as the Aa ignore button
* ({@link BrazenConfigurationManager.hasTagSoleAttribute} / shared cache row).
* When strip-character-series is on, an ignore on the series-stripped base also counts.
* @param {string} name
* @param {object} resolver
* @param {string|null} [type]
* @return {boolean}
* @private
*/
_isFilenameIgnoredTag(name, resolver, type = null)
{
if (!name) {
return false
}
let normalize = typeof resolver?.normalizeTag === 'function' ?
resolver.normalizeTag :
this._getDownloadTagNormalize()
let normalized = normalize(name)
if (!normalized) {
return false
}
let isIgnoredName = (candidate) => {
if (!candidate) {
return false
}
if (this._cm?.canPersist?.()) {
return !!this._cm.hasTagSoleAttribute('filename-tag-ignore-list', candidate)
}
let ignore = resolver?.ignore
return !!(ignore?.size && (ignore.has(candidate) || ignore.has(normalize(candidate))))
}
if (isIgnoredName(normalized) || (normalized !== name && isIgnoredName(name))) {
return true
}
if (resolver?.stripCharacterSeries && (type === 'character' || type == null)) {
let stripped = normalized.replace(/_\([^)]*\)$/, '')
if (stripped && stripped !== normalized && isIgnoredName(stripped)) {
return true
}
}
return false
}
/**
* Filename-ignored tags for active filename/subfolder tag-type pins that are blocking.
* A pin type is blocking when it has raw tags, the download-path join is empty with ignore
* applied, and a no-ignore join is non-empty (ignore emptied the slot; substitution-only
* empties do not block). Lists filename-ignored tags only for those blocking types.
* `pinJoinEmpty` is true only when `tags.length > 0`.
*
* @param {{}} tagGroups
* @param {Record<string, number|string>|null|undefined} [tagIncidences]
* @return {Promise<{tags: Array<{name: string, type: string|null, count: number|null}>, pinJoinEmpty: boolean}>}
* @private
*/
async _collectIgnoredFilenamePinTags(tagGroups, tagIncidences = {})
{
let empty = {tags: [], pinJoinEmpty: false}
if (!this._isIgnoredPinReviewEnabled() || !this._config.downloadPaths?.getPatternResolver) {
return empty
}
let types = this._getPinnedFilenameTagTypes()
if (!types.length) {
return empty
}
await this._ensureDownloadPathTagsLoaded(tagGroups)
let resolver = this._config.downloadPaths.getPatternResolver()
let normalize = typeof resolver?.normalizeTag === 'function' ?
resolver.normalizeTag :
this._getDownloadTagNormalize()
let incidences = tagIncidences && typeof tagIncidences === 'object' ? tagIncidences : {}
/** @type {string[]} */
let blockingTypes = []
for (let type of types) {
let raw = (tagGroups[type] ?? []).filter(Boolean)
if (!raw.length) {
continue
}
// Product truth: slot empty after ignore/substitution — same join as the download path.
let joined = this._joinTagsForDownloadPath(raw, type, resolver.ignore, resolver)
if (joined) {
continue
}
// Substitution-only empties do not block; ignore-caused empties do.
let joinedNoIgnore = this._joinTagsForDownloadPath(raw, type, resolver.ignore, {
...resolver,
applyFilenameIgnore: false,
})
if (!joinedNoIgnore) {
continue
}
blockingTypes.push(type)
}
if (!blockingTypes.length) {
return empty
}
let collected = []
let seen = new Set()
for (let type of blockingTypes) {
let raw = (tagGroups[type] ?? []).filter(Boolean)
for (let name of raw) {
if (!this._isFilenameIgnoredTag(name, resolver, type)) {
continue
}
let normalized = normalize(name) || name
if (seen.has(normalized)) {
continue
}
seen.add(normalized)
collected.push({
name: normalized,
type,
count: this._resolveTagIncidence(normalized, incidences) ??
this._resolveTagIncidence(name, incidences),
})
}
}
if (!collected.length) {
return empty
}
return {tags: collected, pinJoinEmpty: true}
}
/**
* Open tag discovery on this tab if visible, and wake other tabs so a focused
* non-leader can claim the panel when the processor leader is in the background.
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @return {Promise<void>}
* @private
*/
// -------------------------------------------------------------------------
// Tag discovery panel
// -------------------------------------------------------------------------
/**
* @return {Array<{name: string, type: string|null, count: null}>|null}
*/
_getSimilarDiscoveryCachedTags()
{
return this._similarDiscoveryCachedTags
}
/**
* Claim tag-discovery panel ownership on the visible tab (coordinator writes IDB;
* followers publish claim-tag-discovery-panel Command).
* @return {Promise<boolean>}
* @private
*/
async _claimTagDiscoveryPanelOwnership()
{
if (this._documentSuspended || document.visibilityState !== 'visible') {
return false
}
if (!this._ensureReactor() || !this._kernel) {
return false
}
if (this.isCoordinator()) {
let current = this.readDmState()
if (current?.tagDiscoveryPanelTabId === this._tabId) {
return true
}
if (current && typeof current === 'object') {
let optimistic = structuredClone(current)
optimistic.tagDiscoveryPanelTabId = this._tabId
this._publishDmStateToAtoms(optimistic)
}
void this._commitCoordinatorDmState((state) => {
state.tagDiscoveryPanelTabId = this._tabId
}).catch(() => {})
return true
}
await this.sendCommand({
type: 'claim-tag-discovery-panel',
payload: {tabId: this._tabId},
})
return true
}
/**
* Claim human-interaction prompt ownership on the visible tab.
* @param {BrazenHumanInteractionLaneId} context
* @return {Promise<boolean>}
* @private
*/
async _claimHumanInteractionPromptOwnership(context)
{
if (context !== 'resolution' && context !== 'download') {
return false
}
if (this._documentSuspended || document.visibilityState !== 'visible') {
return false
}
if (!this._ensureReactor() || !this._kernel) {
return false
}
if (this.isCoordinator()) {
await this._commitCoordinatorDmState((next) => {
this._ensureHumanInteractionMap(next)
let lane = next.humanInteraction[context]
if (!lane) {
return
}
lane.promptTabId = this._tabId
})
return true
}
await this.sendCommand({
type: 'claim-hi-prompt',
payload: {context, tabId: this._tabId},
})
return true
}
/**
* Show the tag-discovery panel on the focused tab. Visible tabs may steal
* `tagDiscoveryPanelTabId` so opening another search tab cannot leave review
* locked on a background owner with no UI.
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @return {Promise<void>}
*/
async _showTagDiscoveryPanel(tags, knownTags = null)
{
if (document.visibilityState !== 'visible') {
return
}
let state = this.readDmState()
if (!state?.resolutionBlocked) {
this._hideTagDiscoveryPanel()
return
}
await this._claimTagDiscoveryPanelOwnership()
if (knownTags != null && this.isCoordinator()) {
let currentKnown = state?.discoveryPanelKnownTags
let knownJson = JSON.stringify(knownTags ?? [])
let currentJson = JSON.stringify(currentKnown ?? [])
if (knownJson !== currentJson) {
if (state && typeof state === 'object') {
let optimistic = structuredClone(state)
optimistic.discoveryPanelKnownTags = knownTags
this._publishDmStateToAtoms(optimistic)
}
void this._commitCoordinatorDmState((next) => {
next.discoveryPanelKnownTags = knownTags
}).catch(() => {})
}
}
state = this.readDmState()
let known = state?.discoveryPanelKnownTags ?? knownTags ?? []
let tagRuntime = this._cm.getTagRuntime()
if (tagRuntime) {
let names = []
for (let tag of [...(tags ?? []), ...known]) {
if (tag?.name) {
names.push(tag.name)
}
}
await tagRuntime.ensureNames(names)
}
this._renderTagDiscoveryPanel(tags, known, {forceShow: true})
}
/**
* @private
*/
_hideTagDiscoveryPanel()
{
if (this._tagDiscoveryRefreshTimer != null) {
clearTimeout(this._tagDiscoveryRefreshTimer)
this._tagDiscoveryRefreshTimer = null
}
this._cancelSimilarDiscoveryLoad()
this._similarDiscoveryCachedTags = null
this._similarDiscoveryFingerprint = null
if (!this._tagDiscoveryPanel) {
return
}
this._framework._hideDockSlidePanel(this._tagDiscoveryPanel)
}
/**
* @param {Array} tags
* @param {Array|null|undefined} [knownTags]
* @param {{forceShow?: boolean}} [options]
*/
_renderTagDiscoveryPanel(tags, knownTags = null, options = {})
{
if (!this._config.tagDiscovery) {
return
}
if (!this.readDmState()?.resolutionBlocked) {
this._hideTagDiscoveryPanel()
return
}
if (!this._tagDiscoveryPanel) {
this._tagDiscoveryPanel = BrazenViewLayer.createTagDiscoveryPanel({
onConfirm: () => { void void this.confirmTagDiscoveryMappings() },
onSkip: () => { void void this.skipTagDiscoveryInclusion() },
onOpenMedia: () => { void void this.openTagDiscoveryMedia() },
})
BrazenViewLayer.appendToDockPanelStack(this._tagDiscoveryPanel)
}
let known = knownTags ?? this.readDmState()?.discoveryPanelKnownTags ?? []
let reviewMode = this.readDmState()?.discoveryReviewMode ?? 'unknown'
let panelOptions = {
...(this._config.tagDiscovery?.panel ?? {}),
knownTags: known,
resolveTagNameHref: (tag) => this._resolveTagDiscoveryTagHref(tag),
onTagNameClick: (tag) => this._openTagDiscoverySearch(tag),
resetSimilar: true,
intro: reviewMode === 'ignoredPins'
? 'Filename tag slots are empty after ignore. Review ignored tags or similar matches, then confirm to continue.'
: 'New tags were found. Review actions, then confirm to continue.',
unknownSectionLabel: reviewMode === 'ignoredPins' ? 'Ignored tags' : undefined,
}
if (reviewMode === 'ignoredPins') {
panelOptions.similarOpen = true
panelOptions.similarShowCount = false
}
let fingerprint = this._discoverySimilarFingerprint(tags, known)
let sameFingerprint = fingerprint === this._similarDiscoveryFingerprint
let reuseSimilar = sameFingerprint && Array.isArray(this._similarDiscoveryCachedTags)
// New tag set → soft-reset Similar (keep open + count) instead of collapsing/flashing.
panelOptions.resetSimilar = !sameFingerprint
panelOptions.preserveSimilarOpen = true
panelOptions.clearSimilarCount = false
BrazenViewLayer.renderTagDiscoveryPanelContent(
this._tagDiscoveryPanel,
tags,
(row, tag, actionsElement) => {
this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
},
panelOptions,
)
let forceShow = options.forceShow === true
if (forceShow || !BrazenViewLayer.isDockSlidePanelVisible(this._tagDiscoveryPanel)) {
// Steal/restore after hideLocal blur — local DOM may look open without IDB ownership.
this._framework._showDockSlidePanel(this._tagDiscoveryPanel)
this._tagDiscoveryPanel.classList.remove('bv-dock-panel-offscreen')
BrazenViewLayer.ensureDockSlidePanelInteractive(this._tagDiscoveryPanel)
} else {
// Content-only refresh — keep the open panel still; only re-stack positions.
this._framework._syncDockPanelPosition()
}
if (reuseSimilar) {
// Attribute-only refresh: repaint cached similar rows (action chrome) without IDB scan.
let excluded = this._buildSimilarDiscoveryExcludedNames(tags, known)
let cached = (this._similarDiscoveryCachedTags ?? []).filter((tag) => !excluded.has(tag.name))
this._similarDiscoveryCachedTags = cached
void this._paintSimilarDiscoveryTags(cached, panelOptions)
return
}
if (sameFingerprint && this._similarDiscoveryRaf != null) {
// Prefetch already scheduled for this tag set — do not restart.
return
}
// Same fingerprint with no cache (cancelled mid-load) or a new fingerprint → (re)load.
this._scheduleSimilarDiscoveryLoad(tags, known, fingerprint)
}
/**
* @param {string[]|null|undefined} [onlyTagsOverride]
*/
_refreshTagDiscoveryPanelUi(onlyTagsOverride = undefined)
{
if (this._tagDiscoveryActionInFlight) {
return
}
let onlyTags = onlyTagsOverride
if (onlyTags === undefined) {
onlyTags = this._tagDiscoveryRefreshOnlyTags
this._tagDiscoveryRefreshOnlyTags = undefined
}
let state = this.readDmState()
if (!state?.resolutionBlocked) {
this._hideTagDiscoveryPanel()
return
}
if (state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
if (!this._discoveryPanelHasOpenTags(state)) {
return
}
if (Array.isArray(onlyTags) && onlyTags.length &&
!this._discoveryOpenTagNamesContainsAny(onlyTags, state)) {
return
}
// Keep the open review list on attribute toggles (including ignored-pin un-ignore).
// Re-collect only on Confirm / restore — do not drop rows the moment Aa is cleared.
this._refreshTagDiscoveryActionChrome(onlyTags)
}
/**
* @return {object}
* @private
*/
_getTagDiscoveryActionsConfig()
{
let discovery = this._config.tagDiscovery ?? {}
let actions = discovery.actions ?? {}
return {
className: actions.className ?? discovery.actionButtonClass,
iconClass: actions.iconClass ?? discovery.actionIconClass,
ignoreClassName: actions.ignoreClassName,
normalize: actions.normalize ?? discovery.normalizeTag,
bookmark: actions.bookmark,
ignore: actions.ignore,
substitute: actions.substitute ?? {fieldKey: discovery.substitutionFieldKey ?? 'filename-tag-substitutions'},
blacklist: actions.blacklist,
explore: actions.explore,
getRowState: actions.getRowState ?? discovery.getRowState,
extraMutedClasses: actions.extraMutedClasses ?? discovery.extraMutedClasses,
extraHiddenClasses: actions.extraHiddenClasses ?? discovery.extraHiddenClasses,
}
}
/**
* Row-scoped tag action chrome refresh — does not rebuild discovery panel groups.
* @param {string[]|null|undefined} [onlyTags]
* @private
*/
_refreshTagDiscoveryActionChrome(onlyTags = null)
{
if (!this._tagDiscoveryPanel) {
return
}
let state = this.readDmState()
if (!state?.resolutionBlocked || state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
let config = this._getTagDiscoveryActionsConfig()
let normalize = config.normalize
let tagByName = new Map()
let addTag = (tag) => {
if (!tag?.name) {
return
}
tagByName.set(tag.name, tag)
if (typeof normalize === 'function') {
let normalized = normalize(tag.name)
if (normalized) {
tagByName.set(normalized, tag)
}
}
}
for (let tag of state.discoveryPanelTags ?? []) {
addTag(tag)
}
for (let tag of state.discoveryPanelKnownTags ?? []) {
addTag(tag)
}
for (let tag of this._similarDiscoveryCachedTags ?? []) {
addTag(tag)
}
let scopedSet = null
if (Array.isArray(onlyTags) && onlyTags.length) {
scopedSet = new Set()
for (let name of onlyTags) {
if (!name) {
continue
}
scopedSet.add(name)
if (typeof normalize === 'function') {
let normalized = normalize(name)
if (normalized) {
scopedSet.add(normalized)
}
}
}
}
for (let row of this._tagDiscoveryPanel.querySelectorAll('.bv-dock-list-row')) {
let nameEl = row.querySelector('.bv-dock-list-name[data-tag-name]')
let rawName = nameEl?.dataset?.tagName ?? ''
if (!rawName) {
continue
}
if (scopedSet) {
let normalized = typeof normalize === 'function' ? normalize(rawName) : rawName
if (!scopedSet.has(rawName) && !scopedSet.has(normalized)) {
continue
}
}
let tag = tagByName.get(rawName)
if (!tag && typeof normalize === 'function') {
tag = tagByName.get(normalize(rawName))
}
if (!tag) {
let typeAttr = nameEl?.dataset?.tagType
tag = {
name: rawName,
type: typeAttr && typeAttr !== '' ? typeAttr : null,
}
}
let actionsElement = row.querySelector('.bv-tag-actions')
if (!actionsElement) {
continue
}
this._framework.repaintTagAttributeActionRow(actionsElement, row, tag, config)
}
}
/**
* Build the search-list URL for a discovery-panel tag name (native `<a href>` when possible).
* @param {{name?: string, type?: string|null}} tag
* @return {string}
* @private
*/
_resolveTagDiscoveryTagHref(tag)
{
let discovery = this._config.tagDiscovery ?? {}
let actions = discovery.actions ?? {}
let buildUrl = actions.bookmark?.buildUrl
if (!buildUrl || !tag?.name) {
return ''
}
let normalize = actions.normalize ?? discovery.normalizeTag ?? ((name) => name)
return String(buildUrl(normalize(tag.name)) ?? '').trim()
}
/**
* Open the consumer search-list URL for a discovery-panel tag name.
* @param {{name: string, type?: string|null}} tag
* @private
*/
_openTagDiscoverySearch(tag)
{
let url = this._resolveTagDiscoveryTagHref(tag)
if (url) {
Utilities.openUrlInNewTab(url)
}
}
/**
* @param {Array} tags
* @param {Array} knownTags
* @return {string}
* @private
*/
_discoverySimilarFingerprint(tags, knownTags)
{
let newKey = (Array.isArray(tags) ? tags : [])
.map((tag) => `${tag?.type ?? ''}\0${tag?.name ?? ''}`)
.sort()
.join('\n')
let knownKey = (Array.isArray(knownTags) ? knownTags : [])
.map((tag) => tag?.name ?? '')
.sort()
.join('\n')
return newKey + '\n---\n' + knownKey
}
/**
* Cancel in-flight similar prefetch. Keeps a completed cache so hide/restore
* (e.g. brief visibility loss) can reuse rows without a Loading flash.
* @private
*/
_cancelSimilarDiscoveryLoad()
{
this._similarDiscoveryLoadId++
if (this._similarDiscoveryRaf != null) {
cancelAnimationFrame(this._similarDiscoveryRaf)
this._similarDiscoveryRaf = null
}
if (!Array.isArray(this._similarDiscoveryCachedTags)) {
// Incomplete load — allow the next render to schedule a fresh prefetch.
this._similarDiscoveryFingerprint = null
}
}
/**
* Raw + consumer-normalized tag names currently shown on the open discovery panel.
* @param {object|null|undefined} [state]
* @return {Set<string>}
*/
_buildDiscoveryOpenTagNames(state = null)
{
state = state ?? this.readDmState()
let excluded = this._buildSimilarDiscoveryExcludedNames(
state?.discoveryPanelTags ?? [],
state?.discoveryPanelKnownTags ?? [],
)
for (let tag of this._similarDiscoveryCachedTags ?? []) {
let name = tag?.name
if (name) {
excluded.add(name)
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
if (typeof normalize === 'function') {
let normalized = normalize(name)
if (normalized) {
excluded.add(normalized)
}
}
}
}
return excluded
}
/**
* @param {string|string[]} names Notify/detail tag names (often normalized).
* @param {object|null|undefined} [state]
* @return {boolean}
*/
_discoveryOpenTagNamesContainsAny(names, state = null)
{
let openNames = this._buildDiscoveryOpenTagNames(state)
let list = Array.isArray(names) ? names : (names != null && names !== '' ? [names] : [])
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
return list.some((name) => {
if (!name) {
return false
}
if (openNames.has(name)) {
return true
}
if (typeof normalize === 'function') {
let normalized = normalize(name)
if (normalized && openNames.has(normalized)) {
return true
}
}
return false
})
}
/**
* Names already on the discovery panel (New + Known) — never suggest these as Similar.
* Includes raw and consumer-normalized forms when a normalize hook exists.
*
* @param {Array} tags
* @param {Array} knownTags
* @return {Set<string>}
* @private
*/
_buildSimilarDiscoveryExcludedNames(tags, knownTags)
{
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag ?? null
let excluded = new Set()
let add = (name) => {
if (!name) {
return
}
excluded.add(name)
if (typeof normalize === 'function') {
let normalized = normalize(name)
if (normalized) {
excluded.add(normalized)
}
}
}
for (let tag of Array.isArray(tags) ? tags : []) {
add(tag?.name)
}
for (let tag of Array.isArray(knownTags) ? knownTags : []) {
add(tag?.name)
}
return excluded
}
/**
* Ensure TagRuntime rows for similar names, then paint Similar section actions.
* @param {Array<{name: string, type: string|null, count?: number|null}>} similarTags
* @param {object} panelOptions
* @return {Promise<void>}
* @private
*/
async _paintSimilarDiscoveryTags(similarTags, panelOptions)
{
if (!this._tagDiscoveryPanel) {
return
}
let tags = Array.isArray(similarTags) ? similarTags.filter((tag) => tag?.name) : []
let tagRuntime = this._cm.getTagRuntime()
if (tagRuntime && tags.length) {
await tagRuntime.ensureNames(tags.map((tag) => tag.name))
}
if (!this._tagDiscoveryPanel) {
return
}
BrazenViewLayer.renderTagDiscoverySimilarContent(
this._tagDiscoveryPanel,
tags,
(row, tag, actionsElement) => {
this._appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
},
panelOptions,
)
}
/**
* After paint: prefetch similar registry tags for the current new-tag set.
* @param {Array} tags
* @param {Array} knownTags
* @param {string} fingerprint
* @private
*/
_scheduleSimilarDiscoveryLoad(tags, knownTags, fingerprint)
{
if (this._similarDiscoveryRaf != null) {
cancelAnimationFrame(this._similarDiscoveryRaf)
this._similarDiscoveryRaf = null
}
let loadId = ++this._similarDiscoveryLoadId
this._similarDiscoveryFingerprint = fingerprint
this._similarDiscoveryCachedTags = null
this._similarDiscoveryRaf = requestAnimationFrame(() => {
this._similarDiscoveryRaf = null
void this._loadSimilarDiscoveryTags(loadId, tags, knownTags, fingerprint)
})
}
/**
* Strip bracket segments and split snake_case tokens for similarity scoring.
* @param {string} name
* @return {string[]}
* @private
*/
_normalizeTagTokensForSimilarity(name)
{
let cleaned = String(name ?? '').replace(/\([^)]*\)|\[[^\]]*\]/g, '')
cleaned = cleaned.replace(/_+/g, '_').replace(/^_|_$/g, '')
if (!cleaned) {
return []
}
return cleaned.split('_').filter(Boolean)
}
/**
* Coverage of the longest contiguous shared token run over the shorter joined length.
* @param {string[]} tokensA
* @param {string[]} tokensB
* @return {number}
* @private
*/
_scoreTagTokensSimilarity(tokensA, tokensB)
{
if (!tokensA?.length || !tokensB?.length) {
return 0
}
let bestLen = 0
let bestJoined = 0
for (let i = 0; i < tokensA.length; i++) {
for (let j = 0; j < tokensB.length; j++) {
let k = 0
while (i + k < tokensA.length && j + k < tokensB.length && tokensA[i + k] === tokensB[j + k]) {
k++
}
if (k > bestLen) {
bestLen = k
bestJoined = tokensA.slice(i, i + k).join('_').length
}
}
}
if (!bestLen) {
return 0
}
let shorter = Math.min(tokensA.join('_').length, tokensB.join('_').length)
return shorter > 0 ? bestJoined / shorter : 0
}
/**
* Keep at most {@link SIMILAR_DISCOVERY_LIMIT} unique candidates (by name), ranked by score.
* @param {Array<{name: string, type: string|null, count: null, score: number}>} top
* @param {{name: string, type: string|null, count: null, score: number}} candidate
* @private
*/
_considerSimilarDiscoveryCandidate(top, candidate)
{
let existingIdx = top.findIndex((row) => row.name === candidate.name)
if (existingIdx >= 0) {
if (candidate.score > top[existingIdx].score) {
top[existingIdx] = candidate
top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
}
return
}
if (top.length < SIMILAR_DISCOVERY_LIMIT) {
top.push(candidate)
top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
return
}
let worst = top[top.length - 1]
if (candidate.score < worst.score ||
(candidate.score === worst.score && candidate.name >= worst.name)) {
return
}
top[top.length - 1] = candidate
top.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
}
/**
* @param {number} loadId
* @param {Array} tags
* @param {Array} knownTags
* @param {string} fingerprint
* @return {Promise<void>}
* @private
*/
async _loadSimilarDiscoveryTags(loadId, tags, knownTags, fingerprint)
{
if (!this._tagDiscoveryPanel || loadId !== this._similarDiscoveryLoadId) {
return
}
let newTags = Array.isArray(tags) ? tags.filter((tag) => tag?.name) : []
let reviewMode = this.readDmState()?.discoveryReviewMode ?? 'unknown'
let panelOptions = {
...(this._config.tagDiscovery?.panel ?? {}),
onTagNameClick: (tag) => this._openTagDiscoverySearch(tag),
}
if (reviewMode === 'ignoredPins') {
panelOptions.similarOpen = true
panelOptions.similarShowCount = false
}
let finish = async (similar) => {
if (loadId !== this._similarDiscoveryLoadId) {
return
}
let excluded = this._buildSimilarDiscoveryExcludedNames(tags, knownTags)
let filtered = (Array.isArray(similar) ? similar : []).filter((tag) => tag?.name && !excluded.has(tag.name))
this._similarDiscoveryCachedTags = filtered
this._similarDiscoveryFingerprint = fingerprint
await this._paintSimilarDiscoveryTags(filtered, panelOptions)
if (loadId !== this._similarDiscoveryLoadId) {
return
}
}
if (!newTags.length || !this._cm.canPersist()) {
await finish([])
return
}
// Exclude every name already listed under New tags or Known tags.
let excluded = this._buildSimilarDiscoveryExcludedNames(newTags, knownTags)
/** @type {Array<{name: string, type: string|null, count: null, score: number}>} */
let top = []
/** @type {Map<string, Array<{name: string, tokens: string[]}>>} */
let tagsByType = new Map()
for (let tag of newTags) {
let type = tag.type ?? 'unknown'
if (!tagsByType.has(type)) {
tagsByType.set(type, [])
}
tagsByType.get(type).push({
name: tag.name,
tokens: this._normalizeTagTokensForSimilarity(tag.name),
})
}
let tagRepo = this._repos()?.tags
if (!tagRepo) {
await finish([])
return
}
for (let [type, typeNewTags] of tagsByType) {
if (loadId !== this._similarDiscoveryLoadId) {
return
}
let typeEntryId = type && type !== 'unknown'
? await tagRepo.resolveCanonicalTypeEntryId(type)
: null
if (typeEntryId == null) {
continue
}
let discovery = this._config.tagDiscovery ?? {}
let normalize = discovery.actions?.normalize ?? discovery.normalizeTag
let cursor = null
while (true) {
if (loadId !== this._similarDiscoveryLoadId) {
return
}
let page = await tagRepo.listTags({
typeEntryId,
limit: SIMILAR_DISCOVERY_PAGE_SIZE,
cursor,
})
let entries = page.entries ?? []
let nextCursor = page.nextCursor
for (let entry of entries) {
let candidateName = entry?.name
if (!candidateName || excluded.has(candidateName)) {
continue
}
if (typeof normalize === 'function') {
let normalizedCandidate = normalize(candidateName)
if (normalizedCandidate && excluded.has(normalizedCandidate)) {
continue
}
}
let candidateTokens = this._normalizeTagTokensForSimilarity(candidateName)
let bestScore = 0
for (let newTag of typeNewTags) {
let score = this._scoreTagTokensSimilarity(newTag.tokens, candidateTokens)
if (score > bestScore) {
bestScore = score
}
}
if (bestScore <= 0.5) {
continue
}
this._considerSimilarDiscoveryCandidate(top, {
name: candidateName,
type,
count: null,
score: bestScore,
})
}
if (!nextCursor) {
break
}
cursor = nextCursor
await Utilities.sleep(0)
}
}
await finish(top.map(({name, type, count}) => ({name, type, count})))
}
/**
* Framework owns tag-attribute actions; discovery panel maps them internally.
* @param {HTMLElement} row
* @param {{name: string, type: string|null}} tag
* @param {HTMLElement} actionsElement
* @private
*/
_appendTagDiscoveryFrameworkActions(row, tag, actionsElement)
{
this._framework.repaintTagAttributeActionRow(
actionsElement, row, tag, this._getTagDiscoveryActionsConfig())
}
async _presentTagDiscoveryReview(tags, knownTags = null)
{
await this._showTagDiscoveryPanel(tags, knownTags)
this._signalProcessorsWake()
}
async _releaseTagDiscoveryPanelOwnership(options = {})
{
await Utilities.releaseDockPanelOwnership({
hideLocal: options.hideLocal,
hidePanel: () => this._hideTagDiscoveryPanel(),
documentSuspended: this._documentSuspended,
commitRelease: async () => {
// Panel ownership is coordinator-only IDB; visibility handlers can fire during
// initialize() before the Web Lock is held (even on the sole tab).
if (!this.isCoordinator()) {
return
}
await this._commitCoordinatorDmState((state) => {
if (state.tagDiscoveryPanelTabId === this._tabId) {
state.tagDiscoveryPanelTabId = null
}
})
},
})
}
/**
* Re-collect ignored-pin rows for restore / Confirm (not attribute toggles).
* Attribute toggles keep the open list and only refresh action chrome.
* @return {Promise<void>}
* @private
*/
async _refreshIgnoredPinDiscoveryPanel()
{
if (!this.isCoordinator()) {
return
}
let state = this.readDmState()
if (!state?.resolutionBlocked || state.discoveryReviewMode !== 'ignoredPins' ||
state.tagDiscoveryPanelTabId !== this._tabId) {
return
}
let refreshGen = (this._ignoredPinRefreshGen = (this._ignoredPinRefreshGen ?? 0) + 1)
let itemId = state.resolutionBlockedItemId
if (!itemId) {
await this._reconcileDiscoveryGateFromPending(null, {refreshIgnoredOnly: true, wakeProcessors: true})
return
}
let queueItem = await this._repos().downloadResolutionQueue.get(itemId)
if (refreshGen !== this._ignoredPinRefreshGen) {
return
}
if (!queueItem?.pendingTagGroups) {
await this._reconcileDiscoveryGateFromPending(itemId, {refreshIgnoredOnly: true, wakeProcessors: true})
return
}
await this._reconcileDiscoveryGateFromPending(queueItem, {refreshIgnoredOnly: true})
if (refreshGen !== this._ignoredPinRefreshGen) {
return
}
}
/**
* Re-render tag discovery panel rows (e.g. after bookmark/rule toggles).
* @param {string[]|null} [onlyTagNames] When set, no-op unless the open panel lists one.
*/
refreshTagDiscoveryPanel(onlyTagNames = null)
{
this._requestTagDiscoveryPanelRefresh(onlyTagNames)
}
/**
* @param {string|string[]} tags
* @return {boolean}
*/
openTagDiscoveryContainsAnyTag(tags)
{
let names = Array.isArray(tags) ? tags : (tags != null && tags !== '' ? [tags] : [])
if (!names.length) {
return false
}
let state = this.readDmState()
if (!state?.resolutionBlocked || state.tagDiscoveryPanelTabId !== this._tabId ||
!this._discoveryPanelHasOpenTags(state)) {
return false
}
return this._discoveryOpenTagNamesContainsAny(names, state)
}
/**
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_discoveryPanelHasOpenTags(state)
{
if (!state) {
return false
}
if ((state.discoveryPanelTags?.length ?? 0) > 0) {
return true
}
if ((state.discoveryPanelKnownTags?.length ?? 0) > 0) {
return true
}
return (this._similarDiscoveryCachedTags?.length ?? 0) > 0
}
/**
* Re-open the tag discovery panel after reload / tab focus when review is still pending.
* Safe to call once the dock exists (or on later focus). Visible tabs steal ownership.
* @return {Promise<void>}
*/
async restoreTagDiscoveryPanelIfNeeded()
{
if (!this._config.tagDiscovery) {
return
}
if (this._tagDiscoveryActionInFlight) {
return
}
if (document.visibilityState !== 'visible') {
return
}
let state = await this._getState()
if (!state.resolutionBlocked) {
this._hideTagDiscoveryPanel()
await this._recoverOrphanTagDiscoveryReviewIfNeeded()
return
}
// Followers: read-only restore from coordinator patches (claim ownership + show cached rows).
if (!this.isCoordinator()) {
if (state.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
}
return
}
// Re-collect ignored-pin rows so un-ignore / strip-base ignores match current registry.
if (state.discoveryReviewMode === 'ignoredPins') {
await this._claimTagDiscoveryPanelOwnership()
await this._refreshIgnoredPinDiscoveryPanel()
let after = this.readDmState()
if (!after?.resolutionBlocked) {
this._hideTagDiscoveryPanel()
return
}
if (after.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(after.discoveryPanelTags, [])
return
}
this._hideTagDiscoveryPanel()
let stuckId = after.resolutionBlockedItemId
let stuckItem = stuckId ? await this._repos().downloadResolutionQueue.get(stuckId) : null
if (stuckItem?.pendingTagGroups) {
await this._finishResolutionAfterTagDiscovery(stuckItem)
} else if (this.isCoordinator()) {
await this._commitCoordinatorDmState((next) => {
this._clearDiscoveryReviewState(next)
})
this._signalProcessorsWakeAndRunIfCoordinator()
}
return
}
let queueItem = null
if (state.resolutionBlockedItemId) {
queueItem = await this._repos().downloadResolutionQueue.get(state.resolutionBlockedItemId)
}
if (queueItem?.pendingTagGroups) {
let outcome = await this._reconcileDiscoveryGateFromPending(queueItem, {stealOwnership: true})
if (outcome === 'unknownReview' || outcome === 'ignoredPinReview') {
return
}
if (outcome === 'finished' || outcome === 'cleared') {
this._hideTagDiscoveryPanel()
return
}
}
if (!(queueItem?.pendingTagGroups)) {
this._hideTagDiscoveryPanel()
if (this.isCoordinator()) {
await this._commitDmStateAndWake((next) => {
this._clearDiscoveryReviewState(next)
})
}
return
}
// No tag groups available to re-scope — last resort show cached rows.
if (state.discoveryPanelTags?.length) {
await this._showTagDiscoveryPanel(state.discoveryPanelTags, state.discoveryPanelKnownTags ?? [])
}
}
/**
* Recover orphan `tagReview` rows when IDB gate is clear (reload / cross-tab Confirm).
* @return {Promise<void>}
* @private
*/
async _recoverOrphanTagDiscoveryReviewIfNeeded()
{
if (!this.isCoordinator()) {
return
}
let resolutionRows = await this._repos().downloadResolutionQueue.listByStatus('tagReview')
let stuck = resolutionRows.find((row) => row.pendingTagGroups)
if (!stuck) {
return
}
let outcome = await this._reconcileDiscoveryGateFromPending(stuck, {wakeProcessors: true})
if (outcome !== 'ignoredPinReview') {
return
}
let latest = this.readDmState() ?? await this._getState()
if (this._isDeferredTagDiscoveryEnabled() && !latest?.resolutionBlocked) {
let [deferredRows, queuedRows] = await Promise.all([
this._repos().downloadResolutionQueue.listByStatus('discoveryQueued'),
this._repos().downloadResolutionQueue.listByStatus('queued'),
])
if (deferredRows.length && !queuedRows.length) {
await this._commitDmStateAndWake((next) => {
next.discoveryLanePhaseActive = true
})
}
}
}
// -------------------------------------------------------------------------
// Processors & sync
// -------------------------------------------------------------------------
/**
* Notify other tabs that queue work may be available (Reactor Command bus).
* @private
*/
_signalProcessorsWake()
{
this._requestPipelineRun()
}
/**
* @param {object|null|undefined} state
* @private
*/
_publishDmStateToAtoms(state)
{
let nextJson
try {
nextJson = JSON.stringify(state ?? null)
} catch (_e) {
nextJson = null
}
if (nextJson === this._lastPublishedStateJson) {
return
}
this._lastPublishedStateJson = nextJson
if (this._dmStateAtom?.write) {
this._dmStateAtom.write(state ?? null)
} else {
this._dmStateAtom?.set?.(state ?? null)
}
this._requestDockProgressPaint({refreshAllItems: false})
}
/**
* @param {boolean} value
* @private
*/
_setCurrentMediaQueued(value)
{
this._currentMediaQueued = !!value
this._currentMediaQueuedAtom?.write?.(this._currentMediaQueued) ??
this._currentMediaQueuedAtom?.set?.(this._currentMediaQueued)
}
/**
* @param {number} download
* @param {number} resolution
* @private
*/
_commitPendingCountSignals(download, resolution)
{
if (download === this._lastCommittedPendingDownload &&
resolution === this._lastCommittedPendingResolution) {
return
}
this._lastCommittedPendingDownload = download
this._lastCommittedPendingResolution = resolution
this._pendingDownloadCountAtom?.write?.(download) ??
this._pendingDownloadCountAtom?.set?.(download)
this._pendingResolutionCountAtom?.write?.(resolution) ??
this._pendingResolutionCountAtom?.set?.(resolution)
this._requestDockProgressPaint({refreshAllItems: false})
}
/**
* Wire DM dock progress + tag-discovery UI as signal effects (Reactor Phase 2).
* @return {boolean}
* @private
*/
_ensureDmUiEffects()
{
if (this._dmUiEffectsReady) {
return true
}
let signals = globalThis.BrazenSignals
if (!signals?.atom || !signals?.effect) {
return false
}
this._dmStateAtom = signals.atom('dm.state.snapshot', this.readDmState() ?? null)
void this._getState().then((state) => {
this._dmStateAtom?.write?.(state ?? null)
})
this._pendingDownloadCountAtom = signals.atom('dm.pendingDownloadCount', 0)
this._pendingResolutionCountAtom = signals.atom('dm.pendingResolutionCount', 0)
this._currentMediaQueuedAtom = signals.atom('dm.currentMediaQueued', !!this._currentMediaQueued)
this._dockProgressEpochAtom = signals.atom('dm.dockProgressEpoch', 0)
this._tagDiscoveryRefreshAtom = signals.atom('dm.tagDiscoveryRefresh', {epoch: 0, onlyTags: null})
this._localEditsDirtyAtom = signals.atom('reactor.localEditsDirty', false)
this._dmUiEffectDisposers.push(signals.effect(() => {
void this._dockProgressEpochAtom.read()
}, () => {
this._scheduleCoalescedDockProgressPaint()
return () => {
this._dockProgressPaintCancel?.()
this._dockProgressPaintCancel = null
this._dockProgressPaintRaf = null
this._dockProgressEpochBumpCancel?.()
this._dockProgressEpochBumpCancel = null
this._dockProgressEpochBumpRaf = null
this._tagDiscoveryRefreshBumpCancel?.()
this._tagDiscoveryRefreshBumpCancel = null
this._tagDiscoveryRefreshBumpRaf = null
}
}, 'dmDockProgress'))
this._dmUiEffectDisposers.push(signals.effect(() => {
let req = this._tagDiscoveryRefreshAtom.read()
void req.epoch
}, () => {
globalThis.__brazenReactor?.mark?.('effect', 'dmTagDiscovery', {key: 'tagDiscovery'})
if (this._tagDiscoveryActionInFlight) {
return
}
let req = this._tagDiscoveryRefreshAtom.read()
let timer = setTimeout(() => {
let stop = globalThis.__brazenReactor?.time?.('effect', 'dmTagDiscoveryPaint', {key: 'tagDiscovery'})
this._refreshTagDiscoveryPanelUi(req.onlyTags)
stop?.()
}, 0)
return () => clearTimeout(timer)
}, 'dmTagDiscovery'))
this._dmUiEffectsReady = true
return true
}
/**
* Coalesce dock progress paints to at most one per ~16ms (follower patch bursts).
* Uses timers (not rAF) so paints still run on backgrounded tabs and between lane awaits.
* @private
*/
_scheduleCoalescedDockProgressPaint()
{
if (this._dockProgressPaintCancel) {
return
}
let run = () => {
this._dockProgressPaintCancel = null
this._dockProgressPaintRaf = null
globalThis.__brazenReactor?.mark?.('effect', 'dmDockProgress', {key: 'dockProgress'})
let refreshAllItems = this._dockProgressRefreshAllItems
this._dockProgressRefreshAllItems = false
let stop = globalThis.__brazenReactor?.time?.('effect', 'dmDockPaint', {key: 'dockProgress'})
void this._paintDockProgress({refreshAllItems}).finally(() => {
stop?.()
})
}
this._dockProgressPaintRaf = setTimeout(run, 16)
this._dockProgressPaintCancel = () => clearTimeout(this._dockProgressPaintRaf)
}
/**
* Coalesce dock progress epoch bumps to at most one per animation frame.
* @private
*/
_scheduleCoalescedDockProgressEpochBump()
{
if (this._dockProgressEpochBumpCancel) {
return
}
let run = () => {
this._dockProgressEpochBumpCancel = null
this._dockProgressEpochBumpRaf = null
if (this._dockProgressEpochAtom?.write) {
this._dockProgressEpochAtom.write(this._dockProgressEpochAtom.read() + 1)
return
}
if (this._dockProgressEpochAtom?.set) {
this._dockProgressEpochAtom.set(this._dockProgressEpochAtom.value + 1)
return
}
this._scheduleCoalescedDockProgressPaint()
}
if (typeof requestAnimationFrame === 'function') {
this._dockProgressEpochBumpRaf = requestAnimationFrame(run)
this._dockProgressEpochBumpCancel = () => cancelAnimationFrame(this._dockProgressEpochBumpRaf)
} else {
this._dockProgressEpochBumpRaf = setTimeout(run, 16)
this._dockProgressEpochBumpCancel = () => clearTimeout(this._dockProgressEpochBumpRaf)
}
}
/**
* @param {{refreshAllItems?: boolean}} [options]
* @private
*/
_requestDockProgressPaint(options = {})
{
if (options.refreshAllItems === true) {
this._dockProgressRefreshAllItems = true
}
this._scheduleCoalescedDockProgressPaint()
}
/**
* @param {string[]|null} [onlyTagNames]
* @private
*/
_requestTagDiscoveryPanelRefresh(onlyTagNames = null)
{
if (this._tagDiscoveryActionInFlight) {
return
}
if (!this._tagDiscoveryRefreshAtom) {
return
}
if (onlyTagNames == null) {
this._tagDiscoveryRefreshPendingOnlyTags = null
} else if (Array.isArray(onlyTagNames) && onlyTagNames.length) {
let pending = this._tagDiscoveryRefreshPendingOnlyTags
if (pending === null) {
// Already unrestricted this frame.
} else {
let atomPrev = this._tagDiscoveryRefreshAtom.read?.() ?? this._tagDiscoveryRefreshAtom.value
if (pending === undefined) {
if (atomPrev?.onlyTags == null && (atomPrev?.epoch ?? 0) > 0) {
this._tagDiscoveryRefreshPendingOnlyTags = null
} else if (atomPrev?.onlyTags == null && (atomPrev?.epoch ?? 0) === 0) {
this._tagDiscoveryRefreshPendingOnlyTags = onlyTagNames.slice()
} else if (Array.isArray(atomPrev?.onlyTags)) {
this._tagDiscoveryRefreshPendingOnlyTags = atomPrev.onlyTags.slice()
} else {
this._tagDiscoveryRefreshPendingOnlyTags = onlyTagNames.slice()
}
}
pending = this._tagDiscoveryRefreshPendingOnlyTags
if (Array.isArray(pending)) {
let seen = new Set(pending)
for (let name of onlyTagNames) {
if (name && !seen.has(name)) {
seen.add(name)
pending.push(name)
}
}
}
}
}
this._scheduleCoalescedTagDiscoveryRefreshBump()
}
/**
* Coalesce tag-discovery panel refresh atom bumps to at most one per ~16ms tick.
* Uses setTimeout (not rAF) so refreshes still fire on a backgrounded coordinator tab.
* @private
*/
_scheduleCoalescedTagDiscoveryRefreshBump()
{
if (this._tagDiscoveryRefreshBumpCancel) {
return
}
let run = () => {
this._tagDiscoveryRefreshBumpCancel = null
this._tagDiscoveryRefreshBumpRaf = null
if (!this._tagDiscoveryRefreshAtom) {
this._tagDiscoveryRefreshPendingOnlyTags = undefined
return
}
let prev = this._tagDiscoveryRefreshAtom.read?.() ?? this._tagDiscoveryRefreshAtom.value
let onlyTags = this._tagDiscoveryRefreshPendingOnlyTags
this._tagDiscoveryRefreshPendingOnlyTags = undefined
if (onlyTags === undefined) {
return
}
if (this._tagDiscoveryRefreshAtom?.write) {
this._tagDiscoveryRefreshAtom.write({epoch: (prev?.epoch ?? 0) + 1, onlyTags})
} else {
this._tagDiscoveryRefreshAtom.set({epoch: (prev?.epoch ?? 0) + 1, onlyTags})
}
}
this._tagDiscoveryRefreshBumpRaf = setTimeout(run, 16)
this._tagDiscoveryRefreshBumpCancel = () => clearTimeout(this._tagDiscoveryRefreshBumpRaf)
}
/**
* @param {object|null|undefined} state
* @return {string}
* @private
*/
_followerGateSignature(state)
{
let parts = []
for (let key of DM_STATE_GATE_PATCH_FIELDS) {
parts.push(JSON.stringify(state?.[key] ?? null))
}
return parts.join('|')
}
/**
* Follower: repaint dock chrome, selection marks, and gate-driven panels after patch apply.
* @param {{changed?: boolean, snapshotPatchInBatch?: boolean, queueCountTouched?: boolean, progressFieldTouched?: boolean, gateKeysChanged?: Set<string>, state?: object}} options
* @private
*/
_reconcileFollowerVisibleUiAfterPatches(options = {})
{
if (this._documentSuspended || document.visibilityState !== 'visible') {
return
}
let changed = options.changed === true
let snapshotPatchInBatch = options.snapshotPatchInBatch === true
let queueCountTouched = options.queueCountTouched === true
let progressFieldTouched = options.progressFieldTouched === true
let gateKeysChanged = options.gateKeysChanged
if (changed || snapshotPatchInBatch) {
this._requestDockProgressPaint({refreshAllItems: false})
} else if (progressFieldTouched) {
this._requestDockProgressPaint({refreshAllItems: false})
}
if (queueCountTouched || progressFieldTouched) {
this._scheduleFollowerSelectionMarksRefresh()
} else if (changed || snapshotPatchInBatch) {
this._refreshSelectionMarks()
}
let state = this.readDmState() ?? options.state
if (!state) {
return
}
let sig = this._followerGateSignature(state)
if (sig === this._lastFollowerGateSignature) {
return
}
this._lastFollowerGateSignature = sig
let onlyOwnPanelClaim = gateKeysChanged?.size === 1 &&
gateKeysChanged.has('tagDiscoveryPanelTabId') &&
state.tagDiscoveryPanelTabId === this._tabId
if (!onlyOwnPanelClaim) {
this._scheduleFollowerVisibleDockPanelRestore()
}
}
/**
* Coordinator: publish dm.state.* patches via kernel (no direct bus publish).
* @param {object|null|undefined} state
* @private
*/
_publishDmStateProgressViaKernel(state)
{
if (!this._reactorReady || !this._kernel?.isCoordinator() || !state) {
return
}
let patches = DM_STATE_PATCH_FIELDS
.filter((field) => field in state)
.map((field) => ({
path: `dm.state.${field}`,
value: structuredClone(state[field]),
}))
if (!patches.length) {
return
}
void this._kernel.commitStatePatches(
{type: 'dm-state-progress', payload: {}},
patches,
)
}
/**
* Follower: config revision patches — schedule local config-ui after atom apply.
* @param {object[]|null|undefined} patches
* @private
*/
_maybeScheduleConfigUiFromPatches(patches)
{
if (!patches?.length || this._kernel?.isCoordinator()) {
return
}
let configTouched = patches.some((patch) => {
let path = patch?.path
return typeof path === 'string' && path.startsWith('config.')
})
if (!configTouched) {
return
}
globalThis.__brazenReactor?.mark?.('scheduler', 'config-ui-from-patches')
this.scheduleConfigUiReaction({source: 'all', local: false, detail: null})
}
/**
* Follower: apply coordinator dm.state patches to cached state and refresh dock chrome.
* @param {object[]|null|undefined} patches
* @param {number} [seq]
* @private
*/
_reconcileBusPatches(patches, seq = 0)
{
if (!patches?.length || this._kernel?.isCoordinator()) {
return
}
let state = this.readDmState()
if (!state) {
return
}
if (!this._dmPatchVersions) {
this._dmPatchVersions = new Map()
}
let changed = false
let queueCountTouched = false
let progressFieldTouched = false
let snapshotPatchInBatch = false
/** @type {Set<string>} */
let gateKeysChanged = new Set()
for (let patch of patches) {
let path = patch?.path
if (path === 'dm.state.snapshot') {
snapshotPatchInBatch = true
}
if (path === 'dm.pendingResolutionCount' || path === 'dm.pendingDownloadCount') {
queueCountTouched = true
}
if (!path?.startsWith('dm.state.')) {
continue
}
if (path === 'dm.state.snapshot') {
continue
}
let key = path.slice('dm.state.'.length)
if (!key) {
continue
}
if (DM_STATE_PATCH_FIELDS.includes(key) ||
key === 'lastResolutionInitiationAt' ||
key === 'lastDownloadInitiationAt') {
progressFieldTouched = true
}
let patchVersion = typeof patch.version === 'number' ? patch.version : 0
let lastVersion = this._dmPatchVersions.get(key) ?? 0
if (patchVersion > 0 && patchVersion <= lastVersion) {
continue
}
if (patchVersion > lastVersion) {
this._dmPatchVersions.set(key, patchVersion)
}
state[key] = patch.value
changed = true
if (DM_STATE_GATE_PATCH_FIELDS.includes(key)) {
gateKeysChanged.add(key)
}
}
if (!changed) {
if (snapshotPatchInBatch || queueCountTouched || progressFieldTouched) {
if (Number.isFinite(seq) && seq > this._lastAppliedPatchSeq) {
this._lastAppliedPatchSeq = seq
}
this._reconcileFollowerPendingCommands(seq, patches)
this._reconcileFollowerVisibleUiAfterPatches({
changed: false,
snapshotPatchInBatch,
queueCountTouched,
progressFieldTouched,
gateKeysChanged,
state,
})
}
return
}
if (Number.isFinite(seq) && seq > this._lastAppliedPatchSeq) {
this._lastAppliedPatchSeq = seq
}
this._reconcileFollowerPendingCommands(seq, patches)
if (!snapshotPatchInBatch) {
this._publishDmStateToAtoms(state)
}
this._reconcileFollowerVisibleUiAfterPatches({
changed: true,
snapshotPatchInBatch,
queueCountTouched,
progressFieldTouched,
gateKeysChanged,
state,
})
}
/**
* Follower: coalesce gate-patch panel restore (HI + tag discovery) on the visible tab.
* @private
*/
_scheduleFollowerVisibleDockPanelRestore()
{
if (this._documentSuspended || document.visibilityState !== 'visible') {
return
}
if (this._followerPanelRestoreTimer) {
clearTimeout(this._followerPanelRestoreTimer)
}
this._followerPanelRestoreTimer = setTimeout(() => {
this._followerPanelRestoreTimer = null
void this._restoreVisibleDockPanelsIfNeeded()
}, PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS)
}
/**
* Follower: coalesce selection-mark reconcile when queue membership count patches arrive.
* @private
*/
_scheduleFollowerSelectionMarksRefresh()
{
if (this._documentSuspended || document.visibilityState !== 'visible') {
return
}
if (!this.isDownloadPageRole('selection')) {
return
}
if (this._followerSelectionMarksTimer) {
clearTimeout(this._followerSelectionMarksTimer)
}
this._followerSelectionMarksTimer = setTimeout(() => {
this._followerSelectionMarksTimer = null
this._refreshSelectionMarks()
}, PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS)
}
/**
* Refresh pending queue count signal mirrors from IDB (coalesced dock paint input).
* @return {Promise<void>}
* @private
*/
async _syncPendingCountSignalsFromIdb()
{
let download = await this._getPendingDownloadCount()
let resolution = await this._getPendingResolutionCount()
this._commitPendingCountSignals(download, resolution)
}
/**
* Notify followers that dock progress counters changed (Reactor patch stream).
* @private
*/
_signalProgressUiWake()
{
let state = this.readDmState()
if (this._reactorReady && this._kernel?.isCoordinator()) {
this._publishDmStateProgressViaKernel(state)
}
}
/**
* Schedule reclaim / resume after focus, bfcache restore, or cross-tab wake.
* Debounced so stacked wake events do not run concurrent full syncs.
* @param {{fullUi?: boolean, requeueInterrupted?: boolean}} [options]
* @private
*/
_scheduleResumeFromExternalWake(options = {})
{
let fullUi = options.fullUi !== false
let requeueInterrupted = options.requeueInterrupted === true
let runResume = () => {
this._processorsWakeResumeTail = this._processorsWakeResumeTail
.catch(() => {})
.then(() => this._resumeProcessorsFromExternalWake({fullUi, requeueInterrupted}))
}
if (this._processorsWakeReceiveTimer) {
clearTimeout(this._processorsWakeReceiveTimer)
}
this._processorsWakeReceiveTimer = setTimeout(() => {
this._processorsWakeReceiveTimer = null
runResume()
}, PROCESSORS_WAKE_RECEIVE_DEBOUNCE_MS)
}
/**
* Resume coordinator pump after focus / bfcache restore / cross-tab wake.
* Hidden tabs skip full UI hydrate — only cheap processor resume.
* @param {{fullUi?: boolean, requeueInterrupted?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _resumeProcessorsFromExternalWake(options = {})
{
if (this._documentSuspended) {
return
}
let fullUi = options.fullUi !== false && document.visibilityState === 'visible'
let requeueInterrupted = options.requeueInterrupted === true
if (fullUi) {
await this._syncFromStorage()
} else if (this._cm.canPersist()) {
this._publishDmStateToAtoms(await this._repos().downloadManagerState.get())
if (!this.readDmState()?.resolutionBlocked) {
this._hideTagDiscoveryPanel()
}
}
if (requeueInterrupted && this._cm.canPersist() && this._kernel?.isCoordinator()) {
await this._requeueInterruptedPipelineWork({deferInProgress: fullUi})
}
await this._startReactor()
if (fullUi) {
await this._restoreVisibleDockPanelsIfNeeded()
if (!this._kernel?.isCoordinator()) {
void this._requestFollowerSnapshot()
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
}
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
}
}
this._signalProcessorsWakeAndRunIfCoordinator()
}
/**
* Stop local processor ownership so unload / bfcache cannot leave orphan `resolving`
* rows shielded by stale `_active*ItemId`, and so late awaits cannot recommit.
* @private
*/
_interruptDocumentProcessors()
{
this._activeResolutionItemId = null
this._activeDownloadItemId = null
this._processing = false
this._dispatcherWakeRequested = false
this._resolutionProcessing = false
this._downloadProcessing = false
this._resolutionWakeRequested = false
this._downloadWakeRequested = false
}
/**
* Sync unload path: drop processor RAM ownership, close IDB so bfcache cannot pin
* the database, then release Web Locks coordinator + panel ownership.
* @private
*/
_suspendDocumentForUnload()
{
if (this._documentSuspended) {
return
}
this._documentSuspended = true
this._interruptDocumentProcessors()
if (this._followerPanelRestoreTimer) {
clearTimeout(this._followerPanelRestoreTimer)
this._followerPanelRestoreTimer = null
}
if (this._followerSelectionMarksTimer) {
clearTimeout(this._followerSelectionMarksTimer)
this._followerSelectionMarksTimer = null
}
this._cancelFollowerSnapshotRetry()
this._stopHumanInteractionWatchdog()
for (let dispose of this._dmUiEffectDisposers) {
dispose?.dispose?.()
}
this._dmUiEffectDisposers = []
this._dmUiEffectsReady = false
try {
this._cm.repos?.storage?.close?.()
} catch (e) {
// ignore
}
void this._kernel?.stop('pagehide')
void this._releaseTagDiscoveryPanelOwnership().catch(() => {})
void this._releaseHumanInteractionPanelOwnership().catch(() => {})
}
/**
* bfcache restore: reopen storage, invalidate any thawed processor continuations, then
* resume with forced orphan requeue.
* @private
*/
_resumeDocumentAfterBfcache()
{
this._documentSuspended = false
this._interruptDocumentProcessors()
this._startHumanInteractionWatchdog()
void (async () => {
try {
await this._cm.repos?.storage?.open?.()
} catch (e) {
// ignore — first IDB helper will retry open()
}
this._scheduleResumeFromExternalWake({fullUi: true, requeueInterrupted: true})
})()
}
/**
* Requeue orphan `resolving` / `downloading` rows after unload / coordinator steal.
* When `deferInProgress` is true on a visible page, `downloading` rows with `inProgress`
* are held for the interruption timed panel instead of being flipped to `queued`.
* @param {{deferInProgress?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _requeueInterruptedPipelineWork(options = {})
{
let deferInProgress = options.deferInProgress === true &&
document.visibilityState === 'visible' &&
!this._documentSuspended
let resolutionRows = await this._repos().downloadResolutionQueue.listByStatus('resolving')
for (let row of resolutionRows) {
// Never requeue an item this tab is actively processing.
if (String(this._activeResolutionItemId) === String(row.itemId)) {
continue
}
row.status = 'queued'
row.error = null
await this._repos().download.commitResolution(row)
}
let downloadRows = await this._repos().downloadQueue.listByStatus('downloading')
/** @type {object[]} */
let pending = []
for (let row of downloadRows) {
if (String(this._activeDownloadItemId) === String(row.itemId)) {
continue
}
if (deferInProgress && row.inProgress) {
pending.push(row)
continue
}
row.status = 'queued'
row.error = null
row.inProgress = false
// Keep ledgerClaimed — the slot was already reserved before GM_download.
await this._repos().download.commitDownload(row)
}
if (pending.length) {
this._downloadInterruptionPending = true
this._downloadInterruptionRows = pending
}
}
/**
* @return {boolean}
* @private
*/
_isLinkQueues()
{
return this._schedulerLinkQueues === true
}
/**
* Wire Reactor Core (kernel / bus / scheduler / resolve+download job types). Idempotent.
* @return {boolean}
* @private
*/
_ensureReactor()
{
if (this._reactorReady) {
return true
}
let Kernel = globalThis.BrazenKernel
let Scheduler = globalThis.BrazenScheduler
let Registry = globalThis.BrazenJobRegistry
let EventBus = globalThis.BrazenEventBus
let resolveType = globalThis.JOB_TYPE_RESOLVE
let downloadType = globalThis.JOB_TYPE_DOWNLOAD
if (!Kernel || !Scheduler || !Registry || !EventBus) {
throw new Error('BrazenReactor required')
}
this._jobRegistry = new Registry()
this._registerReactorJobTypes()
this._cm._ensureConfigReactor()
this._reactorBus = this._cm.getConfigBus() ?? EventBus.create(this._cm._scriptPrefix)
this._kernel = new Kernel({
scriptPrefix: this._cm._scriptPrefix,
signals: globalThis.BrazenSignals ?? null,
bus: this._reactorBus,
repos: this._repos(),
scheduler: /** @type {object} */ ({}),
onCoordinatorAcquired: () => {
void this._onReactorCoordinatorAcquired()
},
onCoordinatorLost: (reason) => {
this._onReactorCoordinatorLost(reason)
},
onCommandApplied: (command) => {
if (command?.type === 'custom' &&
command.payload?.name === REACTOR_PIPELINE_PUMP &&
this._kernel?.isCoordinator() &&
!this._documentSuspended) {
void this._runProcessors()
}
},
})
this._repos().bindCoordinatorGuard(() => this._kernel?.isCoordinator() ?? false)
/** @type {BrazenDownloadManager} */
let dm = this
let schedulerKernel = {
get abortSignal() {
return dm._kernel.abortSignal
},
isCoordinator: () => dm._kernel?.isCoordinator() ?? false,
readState: () => dm.readDmState() ?? {},
read: (path) => {
let state = dm.readDmState()
if (state && typeof state === 'object' && path in state) {
return state[path]
}
return undefined
},
repos: dm._repos(),
}
this._scheduler = new Scheduler({
registry: this._jobRegistry,
kernel: schedulerKernel,
linkQueues: this._schedulerLinkQueues,
})
this._kernel._scheduler = this._scheduler
if (this._reactorBusUnsubscribe) {
this._reactorBusUnsubscribe()
}
this._reactorBusUnsubscribe = this._reactorBus.subscribe((message) => {
if (message?.kind === 'patch') {
let profiler = globalThis.__brazenReactor
let paths = message.patches?.map((patch) => patch?.path).filter(Boolean) ?? []
let apply = () => {
let signals = globalThis.BrazenSignals
if (signals?.applyPatches) {
signals.applyPatches(message.patches)
}
this._reconcileBusPatches(message.patches, message.seq)
this._maybeScheduleConfigUiFromPatches(message.patches)
}
if (profiler?.withCause) {
profiler.withCause({
kind: 'patch',
label: String(message.seq ?? 'patch'),
detail: {seq: message.seq, paths: paths.slice(0, 32)},
}, apply)
} else {
apply()
}
}
})
this._reactorReady = true
this._cm.setCommandSender((command) => this.sendCommand(command))
this._cm.setConfigUiReactionDelegate((event) => this.scheduleConfigUiReaction(event))
this._cm.setRuntimePipelineChangeDelegate((source) => this._requestRuntimeUiRefresh(source))
if (this._kernel?.isCoordinator()) {
this._reactorBus.markSnapshotReady?.()
} else {
void this._requestFollowerSnapshot()
}
return true
}
/**
* @return {Promise<void>}
* @private
*/
async _startReactor()
{
if (!this._ensureReactor() || !this._kernel) {
return
}
await this._kernel.start()
if (!this._coordinatorStickyReclaimAttempted &&
this._wasCoordinatorSticky() &&
!this.isCoordinator()) {
this._coordinatorStickyReclaimAttempted = true
void this.requestCoordinatorRole({steal: true})
}
}
/**
* @private
*/
_onReactorCoordinatorLost(_reason)
{
this._activeResolutionItemId = null
this._activeDownloadItemId = null
this._processing = false
this._dispatcherWakeRequested = false
this._resolutionProcessing = false
this._downloadProcessing = false
this._resolutionWakeRequested = false
this._downloadWakeRequested = false
if (!this._documentSuspended) {
this._clearCoordinatorSticky()
}
this._cm.refreshDockButtonStates()
void this._requestFollowerSnapshot()
}
/**
* @return {Promise<void>}
* @private
*/
async _onReactorCoordinatorAcquired()
{
if (this._documentSuspended || !this.isDownloadManagerEnabled()) {
return
}
this._markCoordinatorSticky()
this._cm.refreshDockButtonStates()
this._cancelFollowerSnapshotRetry()
this._reactorBus?.markSnapshotReady?.()
await this._ensureState()
await this._framework._reloadDownloadDuplicateLedgerFromStorage()
await this._pruneAllTerminalQueueRows()
await this._requeueInterruptedPipelineWork({
deferInProgress: document.visibilityState === 'visible',
})
await this._resetProgressCountersIfIdle()
if (this._processorsUiReady) {
await this._restoreVisibleDockPanelsIfNeeded()
}
await this._syncPendingCountSignalsFromIdb()
this._refreshDockProgress({refreshAllItems: false})
this._kickProcessorsIfReady()
}
/**
* Debounced cross-tab pipeline wake via Reactor Command bus (coordinator runs pump).
* @private
*/
_requestPipelineRun()
{
if (this._ensureReactor() && this._kernel?.isCoordinator()) {
this._kickProcessorsIfReady()
return
}
if (this._reactorPumpSignalTimer) {
clearTimeout(this._reactorPumpSignalTimer)
}
this._reactorPumpSignalTimer = setTimeout(() => {
this._reactorPumpSignalTimer = null
void this.sendCommand({
type: 'custom',
payload: {name: REACTOR_PIPELINE_PUMP, data: {}},
})
}, PROCESSORS_WAKE_SIGNAL_DEBOUNCE_MS)
}
/**
* Lane descriptors wrapping peek/claim/process/block for the dispatcher.
* @return {{resolution: object, download: object}}
* @private
*/
_ensureLanes()
{
if (this._lanes) {
return this._lanes
}
this._lanes = {
resolution: {
id: /** @type {BrazenDownloadManagerLaneId} */ ('resolution'),
peek: async () => {
let state = await this._getState()
return this._peekResolutionWork(state)
},
claim: (itemId) => this._claimResolutionWorkItem(itemId),
process: (item) => this._processResolutionWorkItem(item),
ownBlocked: (state) => this._isResolutionPipelineBlocked(state),
getActiveId: () => this._activeResolutionItemId,
setActiveId: (itemId) => { this._activeResolutionItemId = itemId },
processingKey: '_resolutionProcessing',
wakeKey: '_resolutionWakeRequested',
},
download: {
id: /** @type {BrazenDownloadManagerLaneId} */ ('download'),
peek: () => this._repos().downloadQueue.peekNextQueued(),
claim: (itemId) => this._claimDownloadQueueItem(itemId),
process: (item) => this._processDownloadItem(item),
ownBlocked: (state) => this._isDownloadPipelineBlocked(state),
getActiveId: () => this._activeDownloadItemId,
setActiveId: (itemId) => { this._activeDownloadItemId = itemId },
processingKey: '_downloadProcessing',
wakeKey: '_downloadWakeRequested',
},
}
return this._lanes
}
/**
* Scheduling policy from `linkQueues`.
* IndependentPolicy (default / r34xxx): each unblocked lane advances in parallel.
* InterleavedPolicy (linked): single serial selection, shared HI pause, opportunistic
* download during discovery, retractable in-flight jobs.
* @return {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}}
* @private
*/
_getSchedulingPolicy()
{
if (this._isLinkQueues()) {
return {
name: 'interleaved',
isLaneBlocked: (lane, state) => {
if (this._anyHumanInteraction(state)) {
return true
}
if (lane.id === 'download' && state.paused) {
return true
}
if (lane.id === 'resolution' && state.resolutionBlocked) {
return true
}
return false
},
run: () => { void this._runInterleavedDispatcher() },
}
}
/** @type {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}} */
let independent = {
name: 'independent',
isLaneBlocked: (lane, state) => lane.ownBlocked(state),
run: () => {
let lanes = this._ensureLanes()
void this._runLaneProcessor(lanes.resolution, independent)
void this._runLaneProcessor(lanes.download, independent)
},
}
return independent
}
/**
* @param {{name: string, isLaneBlocked: function(object, object): boolean, run: function(): void}} policy
* @private
*/
_runDispatcher(policy)
{
policy.run()
}
/**
* IndependentPolicy lane loop (one concurrent loop per unblocked lane).
* @param {object} lane
* @param {{isLaneBlocked: function(object, object): boolean}} policy
* @return {Promise<void>}
* @private
*/
async _runLaneProcessor(lane, policy)
{
let profiler = globalThis.__brazenReactor
if (this[lane.processingKey]) {
profiler?.noteLane?.(lane.id, 'reentry')
this[lane.wakeKey] = true
return
}
this[lane.processingKey] = true
let loopStop = profiler?.time?.('lane', 'processor', {key: lane.id})
let itemsProcessed = 0
try {
do {
this[lane.wakeKey] = false
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
this[lane.wakeKey] = false
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors() && !this._kernel?.abortSignal?.aborted) {
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
break
}
let state = await this._getState()
await this._maybeExpireHumanInteractionBlocks(state)
state = this.readDmState() ?? state
if (policy.isLaneBlocked(lane, state)) {
break
}
let next = await lane.peek()
if (!next) {
break
}
let claimed = await lane.claim(next.itemId)
if (!claimed) {
break
}
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
break
}
lane.setActiveId(claimed.itemId)
try {
void this._renderItemProgress(claimed.itemId)
let itemStop = profiler?.time?.('lane', 'process-item', {key: lane.id})
profiler?.enterProductiveBusy?.()
try {
await lane.process(claimed)
} finally {
profiler?.exitProductiveBusy?.()
}
itemStop?.()
itemsProcessed += 1
if (!this._documentSuspended && !this._kernel?.abortSignal?.aborted) {
await this._reloadCachedStateQuiet()
}
} finally {
if (!this._documentSuspended) {
lane.setActiveId(null)
}
}
}
} while (this[lane.wakeKey] && !this._documentSuspended && !this._kernel?.abortSignal?.aborted)
} finally {
loopStop?.()
profiler?.mark?.('lane', 'processor-end', {key: lane.id, data: {itemsProcessed}})
if (!this._documentSuspended) {
this[lane.processingKey] = false
}
if (!this._documentSuspended && !this._kernel?.abortSignal?.aborted) {
if (lane.id === 'resolution' && await this._tryRecoverStrandedDownloads()) {
this._downloadWakeRequested = true
let lanes = this._ensureLanes()
void this._runLaneProcessor(lanes.download, policy)
} else if (lane.id === 'download' && await this._tryRecoverStrandedDownloads()) {
this[lane.wakeKey] = true
}
await this._pauseDownloadQueueIfIdle()
await this._resetProgressCountersIfIdle()
this._refreshDockProgress({refreshAllItems: false})
if (this[lane.wakeKey]) {
this[lane.wakeKey] = false
if (profiler?.noteLane?.(lane.id, 'rearm', {itemsProcessed}) !== false) {
void this._runLaneProcessor(lane, policy)
}
}
}
}
}
/**
* InterleavedPolicy: single serial selection across lanes under one shared regime.
* Prefers resolution; when resolution is discovery-gated, opportunistically downloads.
* @return {Promise<void>}
* @private
*/
async _runInterleavedDispatcher()
{
let profiler = globalThis.__brazenReactor
if (this._processing) {
profiler?.noteLane?.('interleaved', 'reentry')
this._dispatcherWakeRequested = true
return
}
this._processing = true
let loopStop = profiler?.time?.('lane', 'interleaved', {key: 'interleaved'})
let itemsProcessed = 0
let policy = this._getSchedulingPolicy()
let lanes = this._ensureLanes()
try {
do {
this._dispatcherWakeRequested = false
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
this._dispatcherWakeRequested = false
break
}
if (!this._shouldRunProcessors()) {
break
}
while (this._shouldRunProcessors() && !this._kernel?.abortSignal?.aborted) {
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
break
}
let state = await this._getState()
await this._maybeExpireHumanInteractionBlocks(state)
state = this.readDmState() ?? state
if (this._anyHumanInteraction(state)) {
break
}
let selected = null
if (!policy.isLaneBlocked(lanes.resolution, state)) {
let nextResolution = await lanes.resolution.peek()
if (nextResolution) {
selected = {lane: lanes.resolution, next: nextResolution}
}
}
if (!selected && !policy.isLaneBlocked(lanes.download, state)) {
let nextDownload = await lanes.download.peek()
if (nextDownload) {
selected = {lane: lanes.download, next: nextDownload}
}
}
if (!selected) {
break
}
let claimed = await selected.lane.claim(selected.next.itemId)
if (!claimed) {
break
}
if (this._documentSuspended || this._kernel?.abortSignal?.aborted) {
break
}
selected.lane.setActiveId(claimed.itemId)
try {
void this._renderItemProgress(claimed.itemId)
let itemStop = profiler?.time?.('lane', 'process-item', {key: selected.lane.id})
profiler?.enterProductiveBusy?.()
try {
await selected.lane.process(claimed)
} finally {
profiler?.exitProductiveBusy?.()
}
itemStop?.()
itemsProcessed += 1
if (!this._documentSuspended && !this._kernel?.abortSignal?.aborted) {
await this._reloadCachedStateQuiet()
}
} finally {
if (!this._documentSuspended) {
await this._retractLaneJobIfGated(selected.lane)
selected.lane.setActiveId(null)
}
}
}
} while (this._dispatcherWakeRequested && !this._documentSuspended && !this._kernel?.abortSignal?.aborted)
} finally {
loopStop?.()
profiler?.mark?.('lane', 'interleaved-end', {key: 'interleaved', data: {itemsProcessed}})
if (!this._documentSuspended) {
this._processing = false
}
if (!this._documentSuspended && !this._kernel?.abortSignal?.aborted) {
if (await this._tryRecoverStrandedDownloads()) {
this._dispatcherWakeRequested = true
}
await this._pauseDownloadQueueIfIdle()
await this._resetProgressCountersIfIdle()
this._refreshDockProgress({refreshAllItems: false})
if (this._dispatcherWakeRequested) {
this._dispatcherWakeRequested = false
if (profiler?.noteLane?.('interleaved', 'rearm', {itemsProcessed}) !== false) {
void this._runInterleavedDispatcher()
}
}
}
}
}
/**
* Cooperative retract of an in-flight interleaved job when pause / any-HI gates the regime.
* @param {object} lane
* @return {Promise<void>}
* @private
*/
async _retractLaneJobIfGated(lane)
{
let itemId = lane.getActiveId()
if (!itemId) {
return
}
let state = this.readDmState() ?? await this._getState()
let gated = this._anyHumanInteraction(state)
|| (lane.id === 'download' && !!state?.paused)
if (!gated) {
return
}
let repo = lane.id === 'resolution'
? this._repos().downloadResolutionQueue
: this._repos().downloadQueue
let expected = lane.id === 'resolution' ? 'resolving' : 'downloading'
let row = await repo.get(itemId)
if (!row || row.status !== expected) {
return
}
lane.setActiveId(null)
row.status = 'queued'
row.error = null
if (lane.id === 'resolution') {
await this._repos().download.commitResolution(row)
} else {
row.inProgress = false
await this._repos().download.commitDownload(row)
}
}
/**
* Wire Reactor job types (config-sync, tag-discovery). Pipeline work uses DM lane dispatcher.
* @private
*/
_registerReactorJobTypes()
{
let registry = this._jobRegistry
this._registerReactorConfigUiJobType(registry)
this._registerReactorTagDiscoveryJobType(registry, 'confirm-tag-discovery', this._executeConfirmTagDiscoveryCoordinator)
this._registerReactorTagDiscoveryJobType(registry, 'skip-tag-discovery', this._executeSkipTagDiscoveryCoordinator)
}
/**
* Schedule the per-tab config->UI reaction (local-scope lane).
* @param {{manager?: object, source?: string, local?: boolean, detail?: {tags?: string[], fieldKeys?: string[]}|null}} event
*/
scheduleConfigUiReaction(event)
{
if (!event || !this._ensureReactor() || !this._kernel) {
return
}
if (this._tagDiscoveryActionInFlight) {
setTimeout(() => this.scheduleConfigUiReaction(event), 32)
return
}
globalThis.__brazenReactor?.mark?.('scheduler', 'config-ui-schedule', {
key: event.source ?? 'all',
data: {
local: event.local !== false,
activeCause: globalThis.__brazenReactor?.activeCauseLabel?.(),
},
})
let safeEvent = this._framework._busSafeConfigurationChangeEvent({
manager: event.manager ?? this._cm,
source: event.source,
local: event.local,
detail: event.detail ?? null,
})
this._kernel.scheduleLocal('config-ui', {event: safeEvent})
}
/**
* @param {object} registry
* @private
*/
_registerReactorConfigUiJobType(registry)
{
registry.register('config-ui', {
scope: globalThis.JOB_SCOPE_LOCAL ?? 'local',
coalesceKey: () => 'config-ui',
merge: (pending, incoming) => {
let pe = pending.payload?.event ?? {}
let ie = incoming.payload?.event ?? {}
let incomingSource = ie.source ?? pe.source ?? 'all'
if (globalThis.__brazenRuntimePipelineConfigSources.has(incomingSource)) {
return pending
}
let source = incomingSource
if (pe.source !== source && source !== 'all') {
source = 'all'
} else if (pe.source === 'all') {
source = 'all'
}
return {
...incoming,
payload: {
event: {
source,
local: ie.local !== false && pe.local !== false,
detail: this._cm.mergeChangeDetails?.(pe.detail, ie.detail) ?? ie.detail ?? pe.detail,
},
},
}
},
concurrency: 1,
run: async (_ctx, payload) => {
let stop = globalThis.__brazenReactor?.time?.('scheduler', 'config-ui-run', {key: 'config-ui'})
try {
if (payload?.event) {
await this._framework._handleConfigurationChange({
manager: this._cm,
...payload.event,
})
}
} finally {
stop?.()
}
},
})
}
/**
* @param {object} registry
* @param {function(): Promise<void>} runFn
* @private
*/
_registerReactorTagDiscoveryJobType(registry, name, runFn)
{
registry.register(name, {
scope: globalThis.JOB_SCOPE_COORDINATOR ?? 'coordinator',
coalesceKey: () => 'tag-discovery',
concurrency: 1,
run: async () => {
await runFn.call(this)
},
})
}
/**
* Kick entry for init, enqueue, Start, tag confirm/skip, HI resume, and visibility.
* Coordinator runs DM lane dispatcher; followers publish pipeline-pump Commands.
* @return {Promise<void>}
* @private
*/
async _runProcessors()
{
globalThis.__brazenReactor?.mark?.('lane', 'runProcessors')
if (!this._shouldRunProcessors()) {
return
}
if (!this._ensureReactor()) {
return
}
if (!this._kernel.isCoordinator()) {
this._dispatcherWakeRequested = false
this._resolutionWakeRequested = false
this._downloadWakeRequested = false
return
}
this._runDispatcher(this._getSchedulingPolicy())
}
/**
* @param {object} options
* @param {object} options.repo
* @param {string} options.itemId
* @param {string[]} options.fromStatuses
* @param {string} options.toStatus
* @param {function(object): Promise<void>} options.commitFn
* @return {Promise<object|null>}
* @private
*/
async _claimQueueItem({repo, itemId, fromStatuses, toStatus, commitFn})
{
let row = await repo.get(itemId)
if (!row || !fromStatuses.includes(row.status)) {
return null
}
row.status = toStatus
row.error = null
await commitFn(row)
return row
}
/**
* @param {object} options
* @param {string} options.itemId
* @param {string} options.activeField
* @param {object} options.repo
* @param {string} options.activeStatus
* @return {Promise<boolean>}
* @private
*/
async _isActiveClaim({itemId, activeField, repo, activeStatus})
{
if (String(this[activeField]) !== String(itemId)) {
return false
}
let row = await repo.get(itemId)
return !!(row && row.status === activeStatus)
}
/**
* @param {object} options
* @param {object} options.item
* @param {string} options.activeField
* @param {object} options.repo
* @param {string} options.expectedStatus
* @param {function(object): Promise<void>} options.commitFn
* @return {Promise<boolean>}
* @private
*/
async _commitOwnedRow({item, activeField, repo, expectedStatus, commitFn})
{
if (String(this[activeField]) !== String(item.itemId)) {
return false
}
let existing = await repo.get(item.itemId)
if (!existing || existing.status !== expectedStatus) {
return false
}
await commitFn(item)
return true
}
/**
* Flip a download row from `queued` → `downloading`. Returns null if another worker already claimed it.
* @param {string} itemId
* @return {Promise<object|null>}
* @private
*/
async _claimDownloadQueueItem(itemId)
{
return this._claimQueueItem({
repo: this._repos().downloadQueue,
itemId,
fromStatuses: ['queued'],
toStatus: 'downloading',
commitFn: (row) => this._repos().download.commitDownload(row),
})
}
/**
* @param {string} itemId
* @return {Promise<object|null>}
* @private
*/
async _claimResolutionWorkItem(itemId)
{
return this._claimQueueItem({
repo: this._repos().downloadResolutionQueue,
itemId,
fromStatuses: ['queued', 'discoveryQueued'],
toStatus: 'resolving',
commitFn: (row) => this._repos().download.commitResolution(row),
})
}
/**
* True while this tab still owns the in-flight resolution claim for `itemId`.
* @param {string} itemId
* @return {Promise<boolean>}
* @private
*/
async _isActiveResolutionClaim(itemId)
{
return this._isActiveClaim({
itemId,
activeField: '_activeResolutionItemId',
repo: this._repos().downloadResolutionQueue,
activeStatus: 'resolving',
})
}
/**
* True while this tab still owns the in-flight download claim for `itemId`.
* @param {string} itemId
* @return {Promise<boolean>}
* @private
*/
async _isActiveDownloadClaim(itemId)
{
return this._isActiveClaim({
itemId,
activeField: '_activeDownloadItemId',
repo: this._repos().downloadQueue,
activeStatus: 'downloading',
})
}
/**
* Commit a resolution row only if this tab still owns the `resolving` claim (or is
* intentionally writing `queued` / `tagReview` / terminal from that claim).
* @param {object} item
* @return {Promise<boolean>}
* @private
*/
async _commitResolutionRow(item)
{
return this._commitOwnedRow({
item,
activeField: '_activeResolutionItemId',
repo: this._repos().downloadResolutionQueue,
expectedStatus: 'resolving',
commitFn: (row) => this._repos().download.commitResolution(row),
})
}
/**
* Commit a download row only if this tab still owns the `downloading` claim.
* @param {object} item
* @return {Promise<boolean>}
* @private
*/
async _commitDownloadRow(item)
{
return this._commitOwnedRow({
item,
activeField: '_activeDownloadItemId',
repo: this._repos().downloadQueue,
expectedStatus: 'downloading',
commitFn: (row) => this._repos().download.commitDownload(row),
})
}
/**
* Remove a resolution row after successful promote when this tab still owns the claim.
* @param {string} itemId
* @return {Promise<boolean>}
* @private
*/
async _removeResolutionRowIfActive(itemId)
{
return this._repos().download.removeResolutionIfClaimed(
itemId,
this._activeResolutionItemId,
['resolving', 'tagReview'],
)
}
/**
* Resolution pipeline gate (scheduler `reassess` / enqueue peek).
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isResolutionPipelineBlocked(state)
{
if (!state) {
return true
}
// Interleaved (`linkQueues`): reassess skips all pipeline jobs when any lane has HI.
if (this._isLinkQueues() && this._anyHumanInteraction(state)) {
return true
}
if (this._hiLane(state, 'resolution')) {
return true
}
return !!state.resolutionBlocked
}
/**
* Download pipeline gate (scheduler `reassess` / enqueue peek).
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isDownloadPipelineBlocked(state)
{
if (!state) {
return true
}
if (state.paused) {
return true
}
if (this._downloadInterruptionPending) {
return true
}
if (this._isLinkQueues() && this._anyHumanInteraction(state)) {
return true
}
return !!this._hiLane(state, 'download')
}
/**
* Whether Start/Pause should surface human-interaction resume instead of toggle.
* Independent: download-lane HI. Interleaved: any lane HI.
* @param {object|null|undefined} state
* @return {boolean}
* @private
*/
_isStartPauseShowingHumanInteraction(state)
{
if (this._isLinkQueues()) {
return this._anyHumanInteraction(state)
}
return !!this._hiLane(state, 'download')
}
/**
* Whether this tab may run the coordinator scheduler pump.
* @return {boolean}
* @private
*/
_shouldRunProcessors()
{
if (this._documentSuspended || !this._cm.canPersist()) {
return false
}
if (!this.isDownloadManagerEnabled()) {
return false
}
if (this._isQueueVerificationTab()) {
return false
}
if (!this._ensureReactor() || !this._kernel) {
return false
}
return this._kernel.isCoordinator()
}
/**
* @param {number} lastAt
* @param {number} gapMs
* @return {Promise<number>}
* @private
*/
async _awaitInitiationGap(lastAt, gapMs)
{
let remaining = gapMs - (Date.now() - (lastAt ?? 0))
if (remaining > 0) {
await Utilities.sleep(remaining)
}
return Date.now()
}
/**
* Seed in-tab initiation clocks from shared state (leadership claim / renew).
* @param {object|null|undefined} state
* @private
*/
_hydrateInitiationClocksFromState(state)
{
this._lastDownloadInitiationAt = Math.max(
this._lastDownloadInitiationAt ?? 0,
state?.lastDownloadInitiationAt ?? 0,
)
this._lastResolutionInitiationAt = Math.max(
this._lastResolutionInitiationAt ?? 0,
state?.lastResolutionInitiationAt ?? 0,
)
}
/**
* Coordinator-only IDB state mutation (single-writer). Followers send Commands instead.
* @param {function(object): (void|Promise<void>)} mutator
* @return {Promise<object>}
* @private
*/
_commitCoordinatorDmState(mutator)
{
if (this._documentSuspended || !this._cm.canPersist()) {
return Promise.resolve(this.readDmState())
}
if (!this.isCoordinator()) {
return Promise.reject(new Error('BrazenDownloadManager: follower cannot write downloadManagerState'))
}
let run = this._coordinatorStateTail.then(async () => {
if (this._documentSuspended) {
return this.readDmState()
}
let before = await this._repos().downloadManagerState.get()
let state = await this._repos().download.mutateState(async (draft) => {
await mutator(draft)
})
this._publishDmStateToAtoms(state)
if (this.isCoordinator()) {
this._publishStateDiffViaKernel(before, state)
}
return state
})
this._coordinatorStateTail = run.then(() => undefined, () => undefined)
return run
}
/**
* Coordinator state commit then optional pipeline wake (common post-mutation pattern).
* @param {function(object): (void|Promise<void>)} mutator
* @param {{wake?: boolean}} [options] `wake: false` skips processor wake
* @return {Promise<object>}
* @private
*/
async _commitDmStateAndWake(mutator, options = {})
{
let state = await this._commitCoordinatorDmState(mutator)
if (options.wake !== false) {
this._signalProcessorsWakeAndRunIfCoordinator()
}
return state
}
/**
* @param {object} before
* @param {object} after
* @private
*/
_publishStateDiffViaKernel(before, after)
{
if (!this._kernel?.isCoordinator()) {
return
}
let patches = []
for (let key of Object.keys(after)) {
if (key === 'id') {
continue
}
let prev = before?.[key]
let next = after[key]
if (JSON.stringify(prev) !== JSON.stringify(next)) {
patches.push({path: `dm.state.${key}`, value: structuredClone(next)})
}
}
if (!patches.length) {
return
}
void this._kernel.commitStatePatches({type: 'dm-state-mutation', payload: {}}, patches)
}
/**
* Pace the next GM_download / resolution fetch initiation.
* Uses an in-tab clock so concurrent heartbeat/progress puts cannot wipe lastAt in IDB.
* @param {'download'|'resolution'} kind
* @return {Promise<void>}
* @private
*/
async _paceInitiation(kind)
{
let gapMs = kind === 'resolution' ? this._resolutionGapMs : this._downloadGapMs
let localKey = kind === 'resolution' ? '_lastResolutionInitiationAt' : '_lastDownloadInitiationAt'
let stateKey = kind === 'resolution' ? 'lastResolutionInitiationAt' : 'lastDownloadInitiationAt'
let lastAt = Math.max(this[localKey] ?? 0, this.readDmState()?.[stateKey] ?? 0)
let nextAt = await this._awaitInitiationGap(lastAt, gapMs)
this[localKey] = nextAt
// Keep the atom mirror in lockstep with the in-tab clock so a later
// `_reloadCachedStateQuiet` of a briefly stale IDB row cannot undercut pacing.
let syncState = this.readDmState()
if (syncState) {
syncState[stateKey] = Math.max(syncState[stateKey] ?? 0, nextAt)
this._publishDmStateToAtoms(syncState)
}
if (!this._cm.canPersist()) {
return
}
await this._commitCoordinatorDmState((state) => {
state[stateKey] = Math.max(state[stateKey] ?? 0, nextAt)
})
}
/**
* @return {Promise<void>}
* @private
*/
async _paceDownloadInitiation()
{
// Serialize download initiations so concurrent unlinked work still honors the gap.
let run = this._downloadInitiateTail.then(() => this._paceInitiation('download'))
this._downloadInitiateTail = run.then(() => undefined, () => undefined)
await run
}
/**
* Pace the next resolution / same-origin HTML fetch.
* Serialized so concurrent callers (e.g. tag-type lookups during resolve) cannot stampede.
* @return {Promise<void>}
*/
async paceResolutionInitiation()
{
return this._paceResolutionInitiation()
}
/**
* @return {Promise<void>}
* @private
*/
async _paceResolutionInitiation()
{
// Serialize resolve-page + site tag-list fetches so parallel workers honor the gap.
let run = this._resolutionInitiateTail.then(() => this._paceInitiation('resolution'))
this._resolutionInitiateTail = run.then(() => undefined, () => undefined)
await run
}
/**
* When pending downloads exist but none are `queued` (orphan `downloading` rows),
* requeue via the steal/bfcache helper and allow one wake. Latch prevents tight-loop.
* @return {Promise<boolean>} true when a wake should re-enter the download processor
* @private
*/
async _tryRecoverStrandedDownloads()
{
let pending = await this._getPendingDownloadCount()
if (pending <= 0) {
this._orphanDownloadRequeueAttempted = false
return false
}
if (await this._repos().downloadQueue.peekNextQueued()) {
this._orphanDownloadRequeueAttempted = false
return false
}
// Interruption panel owns these orphans — do not requeue under it.
if (this._downloadInterruptionPending) {
return false
}
if (this._orphanDownloadRequeueAttempted) {
return false
}
this._orphanDownloadRequeueAttempted = true
let defer = document.visibilityState === 'visible' && !this._documentSuspended
await this._requeueInterruptedPipelineWork({deferInProgress: defer})
if (this._downloadInterruptionPending) {
this._showDownloadInterruptionPanel(this._downloadInterruptionRows)
return false
}
return !!(await this._repos().downloadQueue.peekNextQueued())
}
/**
* Download batch is idle only when both queues are empty (and resolution is not mid-tick).
* Once started, the download side stays in-batch until resolution empties too.
* @param {number|null} [downloadPending] Omit to use cached sync counts.
* @param {number|null} [resolutionPending]
* @return {boolean}
* @private
*/
_isDownloadBatchIdle(downloadPending = null, resolutionPending = null)
{
if (this._processing || this._dispatcherWakeRequested ||
this._resolutionProcessing || this._resolutionWakeRequested ||
this._downloadProcessing || this._downloadWakeRequested) {
return false
}
let download = downloadPending == null ? this.getPendingDownloadCountSync() : downloadPending
let resolution = resolutionPending == null ? this.getPendingResolutionCountSync() : resolutionPending
return download <= 0 && resolution <= 0
}
/**
* Zero each dock progress counter when that pipeline's batch is fully idle.
* Download completed count clears only when both queues are empty.
* @return {Promise<void>}
* @private
*/
async _resetProgressCountersIfIdle()
{
// Coordinator writes only — `initialize()` / `_syncFromStorage()` can run before Reactor
// acquires the Web Lock, so `isCoordinator()` is false even on the sole tab.
if (!this.isCoordinator()) {
return
}
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
let cleared = false
await this._commitCoordinatorDmState((state) => {
if (resolutionPending === 0 && (Number(state.completedResolutionCount) || 0) !== 0) {
state.completedResolutionCount = 0
cleared = true
}
if (this._isDownloadBatchIdle(downloadPending, resolutionPending) &&
(Number(state.completedDownloadCount) || 0) !== 0) {
state.completedDownloadCount = 0
cleared = true
}
})
if (cleared) {
this._signalProgressUiWake()
}
}
/**
* Return to idle (Start required) only when both download and resolution queues are empty
* so a later batch does not auto-run. Does not clear a user Pause.
* @return {Promise<void>}
* @private
*/
async _pauseDownloadQueueIfIdle()
{
let downloadPending = await this._getPendingDownloadCount()
let resolutionPending = await this._getPendingResolutionCount()
if (!this._isDownloadBatchIdle(downloadPending, resolutionPending)) {
return
}
await this._commitCoordinatorDmState((state) => {
if (state.paused) {
return
}
state.paused = true
})
}
/**
* @return {Promise<void>}
* @private
*/
async _incrementResolutionProgress()
{
// Serialize with heartbeats — a raw put of a stale snapshot can wipe the count.
await this._commitCoordinatorDmState((state) => {
state.completedResolutionCount = (Number(state.completedResolutionCount) || 0) + 1
})
this._signalProgressUiWake()
this._refreshDockProgress({refreshAllItems: false})
}
/**
* @return {Promise<void>}
* @private
*/
async _incrementDownloadProgress()
{
await this._commitCoordinatorDmState((state) => {
state.completedDownloadCount = (Number(state.completedDownloadCount) || 0) + 1
})
await this._pauseDownloadQueueIfIdle()
this._signalProgressUiWake()
this._refreshDockProgress({refreshAllItems: false})
}
/**
* Reload cached DM state without UI rebuilds (used between processor tasks).
* @return {Promise<void>}
* @private
*/
async _reloadCachedStateQuiet()
{
if (this._documentSuspended || !this._cm.canPersist()) {
return
}
let run = this._coordinatorStateTail.then(async () => {
if (this._documentSuspended || !this._cm.canPersist()) {
return
}
this._publishDmStateToAtoms(await this._repos().downloadManagerState.get())
})
this._coordinatorStateTail = run.then(() => undefined, () => undefined)
return run
}
/**
* Shared post-task UI reset for resolution and download pipeline jobs.
* @param {string} itemId
* @return {Promise<void>}
* @private
*/
async _onPipelineTaskComplete(itemId)
{
await this._reloadCachedStateQuiet()
await this._renderItemProgress(itemId)
await this._clearTerminalQueueRows(itemId)
if (!(await this.isQueued(itemId))) {
this._forgetTrackedItem(itemId)
}
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
}
/**
* @return {Promise<void>}
* @private
*/
async _ensureState()
{
if (!this._cm.canPersist()) {
return
}
let state = this.isCoordinator()
? await this._repos().downloadManagerState.persistNormalizedIfStale()
: await this._repos().downloadManagerState.get()
if (!state?.id) {
if (this.isCoordinator()) {
await this._repos().download.resetState()
}
return
}
this._publishDmStateToAtoms(state)
this._writeHumanInteractionBlockMirror(this._anyHumanInteraction(state))
}
/**
* @return {Promise<object>}
* @private
*/
async _getState()
{
let state = await this._repos().downloadManagerState.get()
this._publishDmStateToAtoms(state)
return state
}
/**
* @return {Promise<void>}
* @private
*/
async _syncFromStorage()
{
if (!this._cm.canPersist()) {
return
}
this._publishDmStateToAtoms(await this._repos().downloadManagerState.get())
await this._syncPendingCountSignalsFromIdb()
if (!this.readDmState()?.resolutionBlocked) {
this._hideTagDiscoveryPanel()
}
await this._resetProgressCountersIfIdle()
await this._refreshCurrentMediaQueueState({refreshDock: false})
this._framework.refreshDockInterface(undefined, {layout: false})
// Selection marks paint queued tiles; skip `_refreshAllItemProgress` on sync.
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
}
/**
* @private
*/
_setupCrossTabSync()
{
if (this._crossTabVisibilityHandler) {
document.removeEventListener('visibilitychange', this._crossTabVisibilityHandler)
}
this._crossTabVisibilityHandler = () => {
if (document.visibilityState === 'visible') {
this._framework._scheduleVisibilityWake?.()
return
}
// Drop panel ownership while hidden so the focused search tab can claim it.
// Keep local DOM visible on minimize — only unload paths hide panels.
void this._releaseTagDiscoveryPanelOwnership({hideLocal: false})
void this._releaseHumanInteractionPanelOwnership({hideLocal: false})
}
document.addEventListener('visibilitychange', this._crossTabVisibilityHandler)
// bfcache back/forward restores the page without a reliable visibility flip — reclaim here.
if (this._crossTabPageshowHandler) {
window.removeEventListener('pageshow', this._crossTabPageshowHandler)
}
this._crossTabPageshowHandler = (event) => {
let persisted = !!event.persisted
if (persisted) {
// Hard interrupt + reopen IDB + requeue orphans (soft resume is not enough).
this._resumeDocumentAfterBfcache()
return
}
if (document.visibilityState === 'visible') {
this._framework._scheduleVisibilityWake?.()
}
}
window.addEventListener('pageshow', this._crossTabPageshowHandler)
if (this._crossTabPagehideHandler) {
window.removeEventListener('pagehide', this._crossTabPagehideHandler)
window.removeEventListener('beforeunload', this._crossTabPagehideHandler)
}
this._crossTabPagehideHandler = (event) => {
// Sync interrupt: stop mirror rewrites, clear active ids, close IDB for bfcache.
this._suspendDocumentForUnload()
// Keep overlays on bfcache restore so marks do not blank-flash on back/forward.
if (!event?.persisted) {
this._clearAllItemProgress()
}
}
window.addEventListener('pagehide', this._crossTabPagehideHandler)
window.addEventListener('beforeunload', this._crossTabPagehideHandler)
}
// -------------------------------------------------------------------------
// Search item progress
// -------------------------------------------------------------------------
/**
* @return {{stepCount: number, indices: {queued: number, resolving: number, tagReview: number, downloadQueued: number, downloading: number}}}
* @private
*/
_getPipelineStepLayout()
{
if (this._isTagDiscoveryActive()) {
return {
stepCount: 5,
indices: {queued: 0, resolving: 1, tagReview: 2, downloadQueued: 3, downloading: 4},
}
}
return {
stepCount: 4,
indices: {queued: 0, resolving: 1, tagReview: -1, downloadQueued: 2, downloading: 3},
}
}
/**
* @param {string|number} itemId
* @return {HTMLElement|null}
* @private
*/
_findSelectionItemElement(itemId)
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !pageConfig.resolveItem) {
return null
}
let itemKey = String(itemId)
for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [element], null)
if (resolved?.itemId != null && String(resolved.itemId) === itemKey) {
return element
}
}
return null
}
/**
* @param {string|number} itemId
* @param {HTMLElement|null|undefined} element
* @private
*/
_trackItemElement(itemId, element)
{
if (itemId == null || !element) {
return
}
this._trackedItemElements.set(String(itemId), element)
}
/**
* @param {string|number} itemId
* @private
*/
_untrackItemElement(itemId)
{
if (itemId == null) {
return
}
this._trackedItemElements.delete(String(itemId))
}
/**
* Drops DOM + epoch retention for a tile that is no longer in the active pipeline.
* @param {string|number} itemId
* @private
*/
_forgetTrackedItem(itemId)
{
if (itemId == null) {
return
}
let itemKey = String(itemId)
if (this._selectionUiPending.has(itemKey)) {
return
}
let element = this._trackedItemElements.get(itemKey)
this._untrackItemElement(itemId)
this._selectionOpEpoch.delete(itemKey)
this._itemProgressPaintGen.delete(itemKey)
if (element) {
this._setItemProgress(element, null)
}
}
/**
* @private
*/
_refreshAllItemProgress()
{
for (let itemId of this._trackedItemElements.keys()) {
void this._renderItemProgress(itemId)
}
}
/**
* @private
*/
_clearAllItemProgress()
{
for (let element of this._trackedItemElements.values()) {
this._setItemProgress(element, null)
}
this._trackedItemElements.clear()
this._selectionUiHidden.clear()
this._selectionUiPending.clear()
this._selectionOpEpoch.clear()
}
/**
* @param {string|number} itemId
* @return {Promise<void>}
* @private
*/
async _renderItemProgress(itemId)
{
let itemKey = String(itemId)
if (this._selectionUiHidden.has(itemKey)) {
// Local deselect won the UI; ignore late coordinator reconcile/processor paints.
return
}
let paintGen = (this._itemProgressPaintGen.get(itemKey) ?? 0) + 1
this._itemProgressPaintGen.set(itemKey, paintGen)
let element = this._trackedItemElements.get(itemKey)
if (!element) {
element = this._findSelectionItemElement(itemId)
if (element) {
this._trackItemElement(itemId, element)
}
}
if (!element) {
return
}
let progress = await this._resolveItemPipelineView(itemId)
if (this._itemProgressPaintGen.get(itemKey) !== paintGen) {
return
}
if (this._selectionUiHidden.has(itemKey)) {
return
}
// Optimistic select painted before IDB put — don't wipe it with a null reconcile.
if (!progress && this._selectionUiPending.has(itemKey)) {
return
}
// Concurrent _renderItemProgress (enqueue + _refreshSelectionMarks) can interleave:
// one pass confirms the row and deletes pending while another still reads null — never
// null-clear a tracked optimistic Queued overlay from reconcile; explicit deselect/forget owns teardown.
if (!progress &&
this._trackedItemElements.has(itemKey) &&
this._readItemProgressLabelFromElement(element) === 'Queued') {
return
}
if (progress?.label === 'Waiting' && this._readItemProgressLabelFromElement(element) === 'Queued') {
let state = await this._getState()
if (this._itemProgressPaintGen.get(itemKey) !== paintGen) {
return
}
if (!this._isTagReviewBlockedOnOtherItem(state, itemKey)) {
let layout = this._getPipelineStepLayout()
progress = {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
}
this._setItemProgress(element, progress)
if (progress) {
this._selectionUiPending.delete(itemKey)
}
}
/**
* @param {HTMLElement|null|undefined} element
* @return {string|null}
* @private
*/
_readItemProgressLabelFromElement(element)
{
let raw = element?.dataset?.bvDmProgress
if (!raw) {
return null
}
let label = raw.split('|')[0]
return label || null
}
/**
* True when tag-discovery review blocks the resolution queue on a different item.
* @param {object|null|undefined} state
* @param {string|number} itemId
* @return {boolean}
* @private
*/
_isTagReviewBlockedOnOtherItem(state, itemId)
{
if (!state?.resolutionBlocked || state.resolutionBlockedItemId == null) {
return false
}
return String(state.resolutionBlockedItemId) !== String(itemId)
}
/**
* @param {string|number} itemId
* @return {Promise<{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null>}
* @private
*/
async _resolveItemPipelineView(itemId)
{
let layout = this._getPipelineStepLayout()
let state = await this._getState()
let itemKey = String(itemId)
let [resolution, download] = await Promise.all([
this._repos().downloadResolutionQueue.get(itemKey),
this._repos().downloadQueue.get(itemKey),
])
let blocked = !!state?.resolutionBlocked
let blockedItemId = state?.resolutionBlockedItemId != null ? String(state.resolutionBlockedItemId) : null
let hiResolution = this._hiLane(state, 'resolution')
let hiDownload = this._hiLane(state, 'download')
for (let hiEntry of [
{ctx: /** @type {BrazenDownloadManagerLaneId} */ ('download'), lane: hiDownload},
{ctx: /** @type {BrazenDownloadManagerLaneId} */ ('resolution'), lane: hiResolution},
]) {
if (!hiEntry.lane) {
continue
}
let humanItemId = hiEntry.lane.itemId != null ? String(hiEntry.lane.itemId) : null
if (humanItemId === itemKey) {
return {
label: 'Verify',
stepIndex: hiEntry.ctx === 'download' ?
layout.indices.downloading :
layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (humanItemId) {
let showWaiting = this._isLinkQueues()
if (!showWaiting && hiEntry.ctx === 'download') {
showWaiting = !!(download && !DOWNLOAD_TERMINAL.has(download.status))
}
if (!showWaiting && hiEntry.ctx === 'resolution') {
showWaiting = !!(resolution && !RESOLUTION_TERMINAL.has(resolution.status) && !download)
}
if (showWaiting) {
return {
label: 'Waiting',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
}
}
if (download) {
if (download.status === 'downloading') {
return {
label: 'Downloading',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
}
}
if (download.status === 'queued') {
return {
// paused gates starting downloads; queued items stay Ready until Start (not "Paused")
label: 'Ready',
stepIndex: layout.indices.downloadQueued,
stepCount: layout.stepCount,
}
}
if (download.status === 'done') {
return {
label: 'Done',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
complete: true,
}
}
if (download.status === 'duplicate') {
return {
label: 'Duplicate',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
complete: true,
}
}
if (download.status === 'skipped') {
return {
label: 'Skipped',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
complete: true,
}
}
if (download.status === 'failed') {
return {
label: 'Failed',
stepIndex: layout.indices.downloading,
stepCount: layout.stepCount,
failed: true,
}
}
}
if (resolution) {
if (resolution.status === 'failed') {
return {
label: 'Failed',
stepIndex: layout.indices.resolving,
stepCount: layout.stepCount,
failed: true,
}
}
if (resolution.status === 'discoveryQueued') {
return {
label: 'Discovery',
stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (resolution.status === 'tagReview') {
if (blocked && blockedItemId === itemKey) {
return {
label: 'Tags',
stepIndex: layout.indices.tagReview >= 0 ? layout.indices.tagReview : layout.indices.downloadQueued,
stepCount: layout.stepCount,
}
}
if (this._isTagReviewBlockedOnOtherItem(state, itemKey)) {
return {
label: 'Waiting',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
return {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
if (resolution.status === 'resolving') {
return {
label: 'Resolving',
stepIndex: layout.indices.resolving,
stepCount: layout.stepCount,
}
}
if (resolution.status === 'queued') {
if (this._isTagReviewBlockedOnOtherItem(state, itemKey)) {
return {
label: 'Waiting',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
return {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
}
}
}
return null
}
/**
* @param {HTMLElement|null|undefined} element
* @param {{label: string, stepIndex: number, stepCount: number, failed?: boolean, complete?: boolean}|null} progress
* @private
*/
_setItemProgress(element, progress)
{
let pageConfig = this._getActivePageConfig()
if (pageConfig?.setItemProgress) {
Utilities.callEventHandler(pageConfig.setItemProgress, [element, progress], null)
return
}
if (progress) {
BrazenViewLayer.updateDownloadManagerItemProgress(element, progress)
return
}
BrazenViewLayer.clearDownloadManagerItemProgress(element)
}
// -------------------------------------------------------------------------
// Selection mode
// -------------------------------------------------------------------------
/**
* Push filename/subfolder pattern pins into live `tagDiscovery.tagTypes` so discovery
* scan scope tracks the user's active download-path patterns without app handlers.
* @private
*/
_syncDiscoveryTagTypesFromPatterns()
{
if (!this._config.tagDiscovery) {
return
}
this._config.tagDiscovery.tagTypes = this._getPinnedFilenameTagTypes()
}
/**
* While download selection mode is on, block native drag on search thumbs
* so click-to-queue is not stolen by image/link dragging. Applies body class
* `bv-dm-selection` for consumer CSS (pointer-events on thumb tiles).
* @return {void}
*/
registerSelectionDragGuard()
{
let pageConfig = this._getActivePageConfig()
let active = !!(this.isDownloadManagerEnabled() && this._isSelectionModeActive() &&
this.isDownloadPageRole('selection'))
document.body.classList.toggle('bv-dm-selection', active)
if (this._selectionDragStartHandler) {
document.removeEventListener('dragstart', this._selectionDragStartHandler, true)
this._selectionDragStartHandler = null
}
if (!active || !pageConfig?.itemSelector) {
return
}
let itemSelector = pageConfig.itemSelector
this._selectionDragStartHandler = (event) => {
if (event.target instanceof Element && event.target.closest(itemSelector)) {
event.preventDefault()
}
}
document.addEventListener('dragstart', this._selectionDragStartHandler, true)
}
/**
* @return {boolean}
* @private
*/
_isSelectionModeActive()
{
return !!this._selectionModeActive
}
/**
* @private
*/
_bindSelectionHandlers()
{
if (this._selectionClickHandler) {
document.removeEventListener('click', this._selectionClickHandler, true)
this._selectionClickHandler = null
}
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.resolveItem || !pageConfig.itemSelector) {
return
}
if (!this._isSelectionModeActive() || !this.isDownloadManagerEnabled()) {
return
}
let itemSelector = pageConfig.itemSelector
this._selectionClickHandler = (event) => {
if (event.__bvDmSelectionHandled) {
return
}
let target = event.target.closest(itemSelector)
if (!target) {
return
}
event.__bvDmSelectionHandled = true
event.preventDefault()
event.stopPropagation()
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [target], null)
if (!resolved?.itemId) {
return
}
void this._toggleSelectionItem(resolved, target)
}
// Capture so one physical click cannot paint (bubble handler) then deselect (duplicate handler).
document.addEventListener('click', this._selectionClickHandler, true)
}
/**
* @param {object} resolved
* @param {HTMLElement} element
* @return {Promise<void>}
* @private
*/
async _toggleSelectionItem(resolved, element)
{
let itemKey = String(resolved.itemId)
let epoch = (this._selectionOpEpoch.get(itemKey) || 0) + 1
this._selectionOpEpoch.set(itemKey, epoch)
// Sync local heuristic only — never await IDB before painting click feedback.
let hasOverlay = element.dataset.bvDmProgress != null && element.dataset.bvDmProgress !== ''
let locallySelected = this._trackedItemElements.has(itemKey) ||
this._selectionUiPending.has(itemKey) ||
hasOverlay
if (locallySelected) {
// Clear overlay immediately; hide until activeIds reconcile confirms dequeue (blocks late promote/sync repaints).
this._selectionUiPending.delete(itemKey)
this._selectionUiHidden.add(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
await this.dequeueDownload(resolved.itemId)
return
}
// Queued rows with no painted overlay are synced via _enqueueSelectionItem (!added path),
// not treated as deselect — otherwise a silent IDB row toggles off with no visible UI.
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
return
}
await this._enqueueSelectionItem(resolved, element, epoch)
}
/**
* Optimistic select + enqueue for one search tile (shared by click and Select All).
* @param {object} resolved
* @param {HTMLElement} element
* @param {number|null|undefined} opEpoch when set (click toggle), reuse without re-bumping epoch
* @return {Promise<void>}
* @private
*/
async _enqueueSelectionItem(resolved, element, opEpoch = null)
{
let itemKey = String(resolved.itemId)
let epoch = opEpoch
if (epoch == null) {
epoch = (this._selectionOpEpoch.get(itemKey) || 0) + 1
this._selectionOpEpoch.set(itemKey, epoch)
}
// Optimistic paint before any IDB (isQueued / enqueue) so click→blur is not gated on storage.
this._selectionUiHidden.delete(itemKey)
this._selectionUiPending.add(itemKey)
let layout = this._getPipelineStepLayout()
this._trackItemElement(resolved.itemId, element)
this._setItemProgress(element, {
label: 'Queued',
stepIndex: layout.indices.queued,
stepCount: layout.stepCount,
})
try {
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
return
}
let added = await this.enqueueDownload(resolved)
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
// A later click cancelled this select — keep UI hidden and drop the row if written.
this._selectionUiPending.delete(itemKey)
this._selectionUiHidden.add(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
if (added) {
await this.dequeueDownload(resolved.itemId)
}
return
}
if (!added) {
// Another tab may have enqueued first — keep overlay if still in the pipeline.
if (await this.isQueued(resolved.itemId)) {
await this._renderItemProgress(resolved.itemId)
return
}
// Follower/command in flight — local IDB row not visible yet; keep optimistic paint.
if (this._selectionUiPending.has(itemKey)) {
return
}
this._selectionUiPending.delete(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
return
}
// Keep _selectionUiPending until _renderItemProgress confirms the IDB row (Reactor follower lag).
await this._renderItemProgress(resolved.itemId)
} catch (e) {
// IDB wedged / closed mid-click — drop optimistic paint so UI matches persistence.
if (this._selectionOpEpoch.get(itemKey) !== epoch) {
return
}
this._selectionUiPending.delete(itemKey)
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
console.warn('[BrazenDM] selection enqueue failed:', e)
}
}
/**
* @private
*/
_refreshSelectionMarks()
{
let pageConfig = this._getActivePageConfig()
if (!pageConfig?.itemSelector || !this.isDownloadPageRole('selection')) {
return
}
void (async () => {
let selectionActive = this._isSelectionModeActive()
let [resolutionIds, downloadIds] = await Promise.all([
this._repos().downloadResolutionQueue.listActiveItemIds(),
this._repos().downloadQueue.listActiveItemIds(),
])
let activeIds = new Set([...resolutionIds, ...downloadIds].map(String))
/** @type {Promise<void>[]} */
let progressPaints = []
for (let element of document.querySelectorAll(pageConfig.itemSelector)) {
let resolved = Utilities.callEventHandler(pageConfig.resolveItem, [element], null)
if (!resolved?.itemId) {
continue
}
let itemKey = String(resolved.itemId)
let queued = activeIds.has(itemKey)
if (this._selectionUiHidden.has(itemKey)) {
// Sticky until re-select (_enqueueSelectionItem) or selection-mode exit (_clearAllItemProgress).
continue
}
let selected = selectionActive && queued
let hasOverlay = element.dataset.bvDmProgress != null && element.dataset.bvDmProgress !== ''
// Keep DOM↔item mapping warm so processor claim/complete refreshes find the tile.
if (selected) {
this._trackItemElement(resolved.itemId, element)
progressPaints.push(this._renderItemProgress(resolved.itemId))
continue
}
if (this._selectionUiPending.has(itemKey)) {
// Optimistic select — IDB row not visible yet; leave the painted overlay alone.
continue
}
if (this._trackedItemElements.has(itemKey) || hasOverlay) {
if (selectionActive) {
// While selection mode is on, reconcile paints only — never tear down overlays from a
// stale activeIds snapshot taken before enqueue write-through completes.
progressPaints.push(this._renderItemProgress(resolved.itemId))
continue
}
progressPaints.push((async () => {
if (this._selectionUiPending.has(itemKey)) {
return
}
await this._renderItemProgress(resolved.itemId)
if (this._selectionUiPending.has(itemKey)) {
return
}
if (!queued) {
if (await this.isQueued(resolved.itemId)) {
await this._renderItemProgress(resolved.itemId)
return
}
let progress = await this._resolveItemPipelineView(resolved.itemId)
if (!progress) {
this._untrackItemElement(resolved.itemId)
this._setItemProgress(element, null)
}
}
})())
}
}
if (progressPaints.length) {
await Promise.all(progressPaints)
}
})()
}
// -------------------------------------------------------------------------
// Dock UI
// -------------------------------------------------------------------------
/**
* @private
*/
_setupDock()
{
if (this._config.tagDiscovery) {
this._cm.addActionField(DOCK_TAG_DISCOVERY_MODE).
setTitle('Tag Discovery Mode').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_TAG_DISCOVERY_MODE).
setAction(() => { void this.toggleTagDiscoveryMode() }).
setDockButton({
icon: 'discovery',
getState: () => this.readDmState()?.tagDiscoveryEnabled ? 'bv-dock-btn-active' : '',
tooltip: () => this.readDmState()?.tagDiscoveryEnabled ?
'Tag discovery: on — unknown tags pause downloads until you confirm them' :
'Tag discovery: off — click to pause on unknown tags and review mappings',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('tagDiscoveryToggle')
},
})
}
this._cm.addActionField(DOCK_SELECTION_MODE).
setTitle('Selection Mode').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_SELECTION_MODE).
setAction(() => this.toggleSelectionMode()).
setDockButton({
icon: 'select',
getState: () => this._isSelectionModeActive() ? 'bv-dock-btn-active' : '',
tooltip: () => this._isSelectionModeActive() ?
'Selection mode: on — click items to queue; hover for Select All' :
'Selection mode: off — click to select items for download queue',
slideOutWhen: () => this._isSelectionModeActive(),
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('selection')
},
}).
setDockSlideOut([DOCK_SELECTION_SELECT_ALL])
this._cm.addActionField(DOCK_SELECTION_SELECT_ALL).
setTitle('Select All').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_SELECTION_SELECT_ALL).
setAction(() => { void this.selectAllVisibleItems() }).
setDockButton({
icon: 'select-all',
tooltip: 'Select all visible posts for the download queue',
})
this._cm.addActionField(DOCK_ADD_TO_QUEUE).
setTitle('Add to Download Queue').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_ADD_TO_QUEUE).
setAction(() => { void this.toggleCurrentMediaQueued() }).
setDockButton({
icon: () => this._currentMediaQueued ? 'queue-remove' : 'queue-add',
getState: () => this._currentMediaQueued ? 'bv-dock-btn-active' : '',
tooltip: () => this._currentMediaQueued
? 'In download queue — click to remove this post'
: 'Add this post to the download queue',
include: function() {
return this.isDownloadManagerEnabled() && this.isDownloadPageRole('enqueueMedia')
},
})
this._cm.addActionField(DOCK_CLEAR_DOWNLOAD_QUEUE).
setTitle('Clear Download Queue').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_CLEAR_DOWNLOAD_QUEUE).
setAction(() => {
if (confirm('Clear pending downloads? Resolution work and selection mode are not cleared.')) {
void this.clearDownloadQueue()
}
}).
setDockButton({
icon: 'clear',
tooltip: () => this.getPendingDownloadCountSync() <= 0
? 'Download queue empty'
: 'Clear download queue',
isDisabled: () => this.getPendingDownloadCountSync() <= 0,
include: function() {
return this.isDownloadManagerEnabled()
},
})
this._cm.addActionField(DOCK_DOWNLOAD_START_PAUSE).
setTitle('Download Queue Start/Pause').
setHelp(DOWNLOAD_MANAGER_FIELD_DETAILED_HELP.DOCK_DOWNLOAD_START_PAUSE).
setAction(() => { void this.toggleDownloadManagerPaused() }).
setDockButton({
icon: () => {
let state = this.readDmState()
if (this._isStartPauseShowingHumanInteraction(state)) {
return 'play'
}
// Idle Play only when both queues empty — not when download drained mid-batch.
if (this._isDownloadBatchIdle()) {
return 'play'
}
return state?.paused ? 'play' : 'pause'
},
getState: () => {
let state = this.readDmState()
if (this._isStartPauseShowingHumanInteraction(state)) {
return ''
}
if (this._isDownloadBatchIdle()) {
return ''
}
return state?.paused ? '' : 'bv-dock-btn-active'
},
isDisabled: () => {
let state = this.readDmState()
if (this._isStartPauseShowingHumanInteraction(state)) {
return false
}
return this._isDownloadBatchIdle()
},
tooltip: () => {
let state = this.readDmState()
if (this._isStartPauseShowingHumanInteraction(state)) {
return 'Verification required — click to confirm and resume'
}
if (this._isDownloadBatchIdle()) {
return 'Queues idle — click Start when the next batch has Ready downloads'
}
if (this.getPendingDownloadCountSync() <= 0 && this.getPendingResolutionCountSync() > 0) {
if (state?.paused) {
return 'Batch still resolving — downloads paused; click to start when Ready'
}
return 'Batch still resolving — downloads run as items become Ready; click to pause'
}
if (state?.paused) {
return 'Download queue paused — click to start downloads'
}
return 'Download queue running — click to pause downloads'
},
slideOutWhen: () => this._isQueueDockSlideOutOpen(),
slideOutPinnedWhen: () => this._isQueueDockSlideOutPinned(),
include: function() {
return this.isDownloadManagerEnabled()
},
}).
setDockSlideOut(this._getQueueDockSlideOutChildKeys()).
setDockSlideOutNodes(() => this._getQueueDockSlideOutNodes())
// Skip Duplicate ships its dock template from Framework; DM only attaches Hide as slide-out.
let skipDuplicateKey = this._framework._downloadDuplicateLedgerConfig?.enableConfigKey ??
(typeof OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER !== 'undefined' ? OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER : null)
let skipDuplicateField = skipDuplicateKey ? this._cm.getField(skipDuplicateKey) : null
if (skipDuplicateField) {
let hideDownloadedKey = typeof OPTION_HIDE_DOWNLOADED_MEDIA !== 'undefined' ?
OPTION_HIDE_DOWNLOADED_MEDIA :
'hide-downloaded-media'
let hideField = this._cm.getField(hideDownloadedKey)
if (hideField && !hideField.dock) {
hideField.applyDockTemplate('hideDownloaded')
}
skipDuplicateField.setDockSlideOut([hideDownloadedKey])
}
}
/**
* @return {boolean}
* @private
*/
_isQueueDockSlideOutOpen()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.slideOutWhen) {
return !!Utilities.callEventHandler(slideOut.slideOutWhen, [this], null)
}
return this.getPendingResolutionCountSync() > 0 || this.getPendingDownloadCountSync() > 0
}
/**
* @return {boolean}
* @private
*/
_isQueueDockSlideOutPinned()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.slideOutPinnedWhen) {
return !!Utilities.callEventHandler(slideOut.slideOutPinnedWhen, [this], null)
}
return this._isQueueDockSlideOutOpen()
}
/**
* @return {HTMLElement[]}
* @private
*/
_getQueueDockSlideOutNodes()
{
let slideOut = this._config.queueDockSlideOut
if (slideOut?.getSlideOutNodes) {
let nodes = Utilities.callEventHandler(slideOut.getSlideOutNodes, [this, this._cm], null)
return Array.isArray(nodes) ? nodes.filter((node) => node != null) : []
}
return [this.getOrCreateProgressSlot()]
}
/**
* @return {string[]}
* @private
*/
_getQueueDockSlideOutChildKeys()
{
let slideOut = this._config.queueDockSlideOut
if (Array.isArray(slideOut?.childFields)) {
return slideOut.childFields
}
return [DOCK_CLEAR_DOWNLOAD_QUEUE]
}
/**
* Coalesced dock progress paint via signal effect (`dockProgressPaint`).
* @param {{refreshAllItems?: boolean}} [options]
* @private
*/
_refreshDockProgress(options = {})
{
this._requestDockProgressPaint(options)
}
/**
* Read-only DM UI refresh after runtime pipeline store writes (replaces incidental config-ui:all).
* @param {string} [_source]
* @private
*/
_requestRuntimeUiRefresh(_source)
{
if (this._runtimeUiRefreshHandle != null) {
clearTimeout(this._runtimeUiRefreshHandle)
}
this._runtimeUiRefreshHandle = setTimeout(() => {
this._runtimeUiRefreshHandle = null
this._runRuntimeUiRefresh()
}, RUNTIME_UI_REFRESH_DEBOUNCE_MS)
}
/**
* Former config-ui:all storm body — paint/atom only, no IDB store writes.
* @private
*/
_runRuntimeUiRefresh()
{
if (this._documentSuspended || !this._processorsUiReady) {
return
}
globalThis.__brazenReactor?.mark?.('effect', 'runtimeUiRefresh', {
key: 'pipeline',
})
this._requestTagDiscoveryPanelRefresh(null)
this._refreshDockProgress({refreshAllItems: false})
this._refreshSelectionMarks()
this._syncDiscoveryTagTypesFromPatterns()
}
/**
* @private
*/
_cancelFollowerSnapshotRetry()
{
if (this._followerSnapshotRetryTimer != null) {
clearTimeout(this._followerSnapshotRetryTimer)
this._followerSnapshotRetryTimer = null
}
}
/**
* @param {Record<string, *>|null|undefined} snapshot
* @param {number} version
* @return {object[]}
* @private
*/
_snapshotRecordToPatches(snapshot, version)
{
if (!snapshot || typeof snapshot !== 'object') {
return []
}
let seq = Number(version) || 0
let patches = []
for (let [path, value] of Object.entries(snapshot)) {
if (!path) {
continue
}
patches.push({path, value: structuredClone(value), version: seq})
}
return patches
}
/**
* Hydrate follower atoms + cached dm.state from a coordinator snapshot response.
* @param {{snapshot?: Record<string, *>, snapshotSeq?: number, catchUpPatches?: object[]}} response
* @private
*/
_applyFollowerSnapshotResponse(response)
{
if (!response || this._kernel?.isCoordinator()) {
return
}
let seq = Number(response.snapshotSeq) || 0
let patches = this._snapshotRecordToPatches(response.snapshot, seq)
if (response.catchUpPatches?.length) {
for (let patch of response.catchUpPatches) {
if (!patch?.path) {
continue
}
patches.push({
path: patch.path,
value: structuredClone(patch.value),
version: typeof patch.version === 'number' ? patch.version : seq,
})
}
}
if (!patches.length) {
return
}
globalThis.BrazenSignals?.applyPatches?.(patches)
this._reconcileBusPatches(patches, seq)
}
/**
* @param {number} [delayMs]
* @private
*/
_scheduleFollowerSnapshotRetry(delayMs = FOLLOWER_SNAPSHOT_RETRY_MS)
{
this._cancelFollowerSnapshotRetry()
if (this._documentSuspended || this._kernel?.isCoordinator()) {
return
}
this._followerSnapshotRetryTimer = setTimeout(() => {
this._followerSnapshotRetryTimer = null
void this._requestFollowerSnapshot()
}, delayMs)
}
/**
* Follower cold start: open the EventBus patch gate via coordinator snapshot handshake.
* @return {Promise<void>}
* @private
*/
async _requestFollowerSnapshot()
{
if (this._documentSuspended || !this._reactorBus?.requestSnapshot) {
return
}
if (this._kernel?.isCoordinator()) {
this._reactorBus.markSnapshotReady?.()
this._cancelFollowerSnapshotRetry()
return
}
if (this._followerSnapshotInFlight) {
return
}
this._followerSnapshotInFlight = true
try {
let sinceSeq = this._lastAppliedPatchSeq > 0 ? this._lastAppliedPatchSeq : 0
let response = await this._reactorBus.requestSnapshot(sinceSeq)
this._applyFollowerSnapshotResponse(response)
this._cancelFollowerSnapshotRetry()
} catch (_e) {
if (!this._documentSuspended && !this._kernel?.isCoordinator()) {
this._scheduleFollowerSnapshotRetry()
}
} finally {
this._followerSnapshotInFlight = false
}
}
/**
* Update dock progress counters/bars. Remount slide-out only when pending work
* appears or clears; refresh Start/Pause chrome before painting the live node.
* @param {{refreshAllItems?: boolean}} [options]
* @return {Promise<void>}
* @private
*/
async _paintDockProgress(options = {})
{
// Early DM init runs before `_buildDock` — painting then fills a detached slot and
// leaves the live rail empty until a later wake. Skip until the dock exists.
if (!document.querySelector('.bv-dock .bv-dock-rail-body')) {
return
}
let refreshAllItems = options.refreshAllItems === true
let downloadCount = this.getPendingDownloadCountSync()
let resolutionCount = this.getPendingResolutionCountSync()
let prevDownload = this._lastPaintedDownloadPending ?? downloadCount
let prevResolution = this._lastPaintedResolutionPending ?? resolutionCount
let slideOutBefore = prevDownload > 0 || prevResolution > 0
let slideOutAfter = downloadCount > 0 || resolutionCount > 0
// Chrome first so `_refreshDockRootSlot` cannot detach the node after we paint it.
if (slideOutBefore !== slideOutAfter) {
globalThis.__brazenReactor?.mark?.('effect', 'dock-refresh-interface', {
key: 'slideOut',
data: {cause: globalThis.__brazenReactor?.activeCauseLabel?.()},
})
this._framework.refreshDockInterface(undefined, {layout: false})
} else {
globalThis.__brazenReactor?.mark?.('effect', 'dock-refresh-buttons', {
data: {cause: globalThis.__brazenReactor?.activeCauseLabel?.()},
})
this._cm.refreshDockButtonStates()
}
let showProgress = (resolutionCount > 0 || downloadCount > 0) && this.isDownloadManagerEnabled()
let progressElement = this.getOrCreateProgressSlot()
if (showProgress && !this._isProgressSlotConnected(progressElement)) {
this._remountDownloadStartPauseSlot()
progressElement = this.getOrCreateProgressSlot()
}
BrazenViewLayer.setDownloadManagerProgressSlotVisible(progressElement, showProgress)
if (showProgress) {
let state = this.readDmState()
if (!state) {
state = await this._repos().downloadManagerState.get()
}
let completedResolution = Number(state.completedResolutionCount) || 0
let completedDownload = Number(state.completedDownloadCount) || 0
BrazenViewLayer.updateDownloadManagerProgressSlot(progressElement, {
resolution: {
current: completedResolution,
total: completedResolution + resolutionCount,
},
download: {
current: completedDownload,
total: completedDownload + downloadCount,
},
})
}
this._lastPaintedDownloadPending = downloadCount
this._lastPaintedResolutionPending = resolutionCount
if (refreshAllItems) {
this._refreshAllItemProgress()
}
}
// -------------------------------------------------------------------------
// Page helpers
// -------------------------------------------------------------------------
/**
* @return {string|null}
* @private
*/
_getActivePageName()
{
for (let pageName of Object.keys(this._config.pages ?? {})) {
if (this._framework.isPage(pageName)) {
return pageName
}
}
return null
}
/**
* @return {object|null}
* @private
*/
_getActivePageConfig()
{
let pageName = this._getActivePageName()
return pageName ? this._config.pages[pageName] : null
}
/**
* @return {BrazenStorageRepositories}
* @private
*/
_repos()
{
return this._cm.getRepos()
}
}
/** @type {Readonly<Record<string, string>>} */
BrazenDownloadManager.FIELD_DETAILED_HELP = DOWNLOAD_MANAGER_FIELD_DETAILED_HELP
/** @type {Readonly<typeof BRAZEN_DOWNLOAD_PATH_FIELD_KEYS>} */
BrazenDownloadManager.BRAZEN_DOWNLOAD_PATH_FIELD_KEYS = BRAZEN_DOWNLOAD_PATH_FIELD_KEYS