brazenvoid / Rule34.xxx - Search, Download & UI Enhancements

// ==UserScript==
// @name         Rule34.xxx - Search, Download & UI Enhancements
// @namespace    brazenvoid
// @version      4.1.0
// @author       brazenvoid
// @license      GPL-3.0-only
// @description  Hoarder friendly bookmarks, filters, single/multi downloads with ledger, tag ignore/substitutions/blacklists
// @match        https://rule34.xxx/*
// @require      https://update.greasyfork.org/scripts/375557/1882642/Brazen%20Framework%20-%20Utilities.js
// @require      https://update.greasyfork.org/scripts/416104/1884336/Brazen%20Framework%20-%20View%20Layer.js
// @require      https://update.greasyfork.org/scripts/587030/1884360/Brazen%20Framework%20-%20IndexedDB%20Storage.js
// @require      https://update.greasyfork.org/scripts/418665/1884358/Brazen%20Framework%20-%20Configuration%20Manager.js
// @require      https://update.greasyfork.org/scripts/583965/1880215/Brazen%20Framework%20-%20Tag%20Query%20Engine.js
// @require      https://update.greasyfork.org/scripts/587126/1884339/Brazen%20Framework%20-%20Download%20Manager.js
// @require      https://update.greasyfork.org/scripts/416105/1884337/Brazen%20Framework%20-%20Framework.js
// @grant        GM_addStyle
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_deleteValue
// @run-at       document-end
// ==/UserScript==

GM_addStyle(
  `:root { --bv-theme-color: #2a352a; --bv-dock-bg: var(--bv-theme-color); --bv-dock-btn-bg: #4a5f4a; --bv-dock-btn-active-bg: #778c77; --bv-dock-slideout-bg: rgba(0, 0, 0, 0.28); } #bv-ui.bv-dock-panel-left, #bv-compliance-rules.bv-dock-panel-left, #bv-tag-discovery-panel.bv-dock-panel-left, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-left, #bv-download-interruption-panel.bv-dock-panel-left { border-bottom-left-radius: 0; border-left: 0; border-radius: 0 8px 8px 0; border-right: 1px solid #1a221a; box-shadow: 2px 0 12px rgba(0, 0, 0, 0.35); border-top-left-radius: 0; } #bv-ui.bv-dock-panel-right, #bv-compliance-rules.bv-dock-panel-right, #bv-tag-discovery-panel.bv-dock-panel-right, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-right, #bv-download-interruption-panel.bv-dock-panel-right { border-bottom-right-radius: 0; border-radius: 8px 0 0 8px; border-right: 0; box-shadow: -2px 0 12px rgba(0, 0, 0, 0.35); border-top-right-radius: 0; } #bv-ui.bv-dock-panel-bottom, #bv-compliance-rules.bv-dock-panel-bottom, #bv-tag-discovery-panel.bv-dock-panel-bottom, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-bottom, #bv-download-interruption-panel.bv-dock-panel-bottom { border-bottom-left-radius: 0; border-bottom-right-radius: 0; border-radius: 8px 8px 0 0; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4); max-height: 80vh; top: auto !important; width: min(400px, 96vw); } #bv-ui, #bv-compliance-rules, #bv-tag-discovery-panel, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), #bv-download-interruption-panel { box-sizing: border-box; display: flex; flex-direction: column; height: auto; max-height: 90vh; min-width: 400px; position: fixed; width: 400px; z-index: 1001; overflow-x: hidden; overflow-y: auto; font-size: 0.8125rem; font-weight: 400; line-height: 1.3; backdrop-filter: none !important; background: var(--bv-theme-color) !important; opacity: 1 !important; border: 1px solid #1a221a; border-right: 0; border-radius: 8px 0 0 8px; padding: 0.5rem 0.7rem 0.6rem !important; } #bv-compliance-rules, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), #bv-download-interruption-panel { min-width: 300px; padding: 0.5rem 0.7rem 0.6rem !important; width: min(340px, 92vw); } #bv-ui:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), #bv-compliance-rules:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), #bv-tag-discovery-panel:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download):not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom), #bv-download-interruption-panel:not(.bv-dock-panel-left):not(.bv-dock-panel-right):not(.bv-dock-panel-bottom) { bottom: auto; left: auto; right: 0; top: 5vh; } #bv-ui *, #bv-compliance-rules *, #bv-tag-discovery-panel *, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) *, #bv-download-interruption-panel * { box-sizing: border-box; } #bv-ui:hover { opacity: 1 !important; } #bv-ui.bv-bg-colour { background-color: var(--bv-theme-color) !important; } #bv-ui > div + div { margin-top: 0.4rem !important; } #bv-resizer { align-items: center; background-color: #4a5f4a; border: 1px solid #1a221a; cursor: ew-resize; display: flex; height: 14px; justify-content: center; position: absolute; top: 0.45rem; width: 20px; z-index: 6; } #bv-ui.bv-dock-panel-left #bv-resizer, #bv-ui.bv-dock-panel-bottom #bv-resizer { left: auto; right: 0.4rem; } #bv-ui.bv-dock-panel-right #bv-resizer { left: 0.4rem; right: auto; } #bv-resizer:hover { background-color: #5a6f5a; } #bv-resizer-icon { color: #e8efe8; font-size: 0.7rem; line-height: 1; pointer-events: none; } #bv-ui .bv-tabs-nav { display: flex; flex-wrap: wrap; gap: 2px; margin-bottom: 0.15rem; } #bv-ui .bv-tab-button { color: #c8d4c8; background-color: #1e271e; font-size: 0.75rem; font-weight: 500; margin: 0; padding: 0.22rem 0.42rem; line-height: 1.2; width: auto; } #bv-ui .bv-tab-button.bv-active, #bv-ui .bv-tab-button:hover { background-color: #778c77 !important; color: #fff !important; } #bv-ui .bv-tabs-section { max-width: 100%; min-width: 0; } #bv-ui .bv-tab-panel { background-color: var(--bv-theme-color); border-color: #1a221a !important; box-sizing: border-box; display: none; flex-direction: column; max-width: 100%; min-width: 0; overflow: hidden; padding: 0.5rem 0.65rem !important; gap: 0.3rem; } #bv-ui .bv-tab-panel.bv-active { display: flex; } #bv-ui .bv-group { align-items: center; gap: 0.3rem; max-width: 100%; min-height: 0; min-width: 0; margin: 0; } #bv-ui .bv-group + .bv-group { margin-top: 0.3rem !important; } #bv-ui .bv-group.bv-group-disabled { opacity: 0.45; } #bv-ui .bv-group:has(.bv-input.bv-checkbox-radio), #bv-ui .bv-group.bv-stat-group { align-items: center; column-gap: 0.5rem; display: grid; grid-template-columns: minmax(0, 1fr) auto; } #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group):not(.bv-textarea-group) { align-items: center; column-gap: 0.5rem; display: grid; grid-template-columns: 6.75rem minmax(8rem, 1fr); max-width: 100%; min-width: 0; } #bv-ui .bv-group.bv-range-group { align-items: center; column-gap: 0.35rem; display: grid; grid-template-columns: 4.5rem minmax(8rem, 1fr); max-width: 100%; min-width: 0; } #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group):not(.bv-textarea-group) .bv-label.bv-text, #bv-ui .bv-group.bv-range-group .bv-label.bv-text { justify-self: start; min-width: 0; text-align: left; white-space: nowrap; } #bv-ui .bv-group.bv-pattern-group { display: flex; flex-direction: column; align-items: stretch; gap: 0.2rem; } #bv-ui .bv-group.bv-pattern-group > .bv-label.bv-text { display: block; text-align: left; width: 100%; } #bv-ui .bv-group.bv-range-group .bv-label.bv-text { grid-column: 1; grid-row: 1; } #bv-ui .bv-group.bv-range-group .bv-range-inputs { display: flex; gap: 0.35rem; grid-column: 2; grid-row: 1; min-width: 0; width: 100%; } #bv-ui .bv-group.bv-range-group .bv-range-inputs > .bv-input { flex: 1 1 50%; max-width: none; min-width: 3.25rem; width: auto; } #bv-ui .bv-group.bv-textarea-group { display: flex; flex-direction: column; align-items: stretch; gap: 0.2rem; padding: 0 0.05rem; } #bv-ui .bv-group.bv-textarea-group > textarea.bv-input { margin-top: 0; } #bv-ui .bv-substitution-composer { gap: 0.25rem; margin-top: 0; } #bv-ui .bv-substitution-arrow { color: #9eb09e; } #bv-ui .bv-substitution-add { background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; color: #fff; height: 1.65rem; min-width: 1.65rem; width: 1.65rem; } #bv-ui .bv-substitution-add:hover { background-color: #5a6f5a; } #bv-ui .bv-substitution-add-icon { height: 0.95rem; width: 0.95rem; } #bv-ui .bv-group.bv-textarea-group > textarea.bv-input[readonly] { background-color: #a8b5a8; cursor: default; } #bv-ui .bv-label { color: #e8efe8; font-weight: 400; line-height: 1.25; margin: 0; } #bv-ui .bv-group:has(.bv-label.bv-text):not(.bv-pattern-group):not(.bv-range-group) .bv-input.bv-text { justify-self: stretch; max-width: 100%; width: 100%; } #bv-ui .bv-input.bv-checkbox-radio { justify-self: end; margin: 0; scale: 1; } #bv-ui .bv-title { color: #e8efe8; font-size: 0.75rem; font-weight: 500; height: auto; margin: 0 0 0.2rem; min-height: 0; } #bv-ui .bv-stat-label { color: #e8efe8; font-weight: 500; justify-self: end; text-align: right; width: 1.35rem; } #bv-ui .bv-input { background-color: #c5d0c5; color: #1a221a; border: 1px solid #8a9a8a; border-radius: 2px; box-sizing: border-box; font-size: 0.8125rem; font-weight: 400; max-width: 100%; min-width: 0; padding: 0.18rem 0.32rem; } #bv-ui .bv-group.bv-range-group .bv-input { min-height: 1.5rem; min-width: 0; } #bv-ui textarea.bv-input, #bv-ui .bv-group.bv-textarea-group > textarea.bv-input { display: block; max-width: 100%; min-height: 0; padding: 0.28rem 0.35rem; resize: vertical; width: 100%; } #bv-ui .bv-group.bv-pattern-group > .bv-input.bv-text { max-width: 100%; width: 100%; } #bv-ui .bv-input.bv-pattern-active { outline: 1px solid #9eb09e; outline-offset: 1px; } #bv-ui .bv-pattern-builder.bv-pattern-builder-shared { margin-top: 0.15rem; } #bv-ui .bv-button { background-color: #4a5f4a; box-sizing: border-box; color: #fff; border: 1px solid #3a4f3a; border-radius: 2px; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; margin: 0; padding: 0.28rem 0.45rem; } #bv-ui a.bv-button.bv-donate-patreon { align-items: center; display: inline-flex; gap: 0.4rem; justify-content: center; text-decoration: none; width: 100%; } #bv-ui a.bv-button.bv-donate-patreon:hover { background-color: #5a6f5a; color: #fff; } #bv-ui .bv-donate-intro { color: #c8d4c8; font-size: 0.78rem; margin: 0 0 0.45rem; } #bv-ui .bv-donate-patreon-icon { color: #ff424d; height: 1.1rem; width: 1.1rem; } #bv-ui .bv-button:hover { background-color: #5a6f5a; } #bv-ui .bv-section button + button { margin-left: 0 !important; } #bv-ui .bv-actions { align-items: stretch; display: flex; gap: 0.25rem; padding: 0; width: 100%; } #bv-ui .bv-actions:not(.bv-flex-column) { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.3rem; } #bv-ui .bv-actions:not(.bv-flex-column) .bv-button { flex: none; min-width: 0; width: 100%; } #bv-ui .bv-actions.bv-flex-column { flex-direction: column; gap: 0.22rem; } #bv-ui .bv-actions.bv-flex-column .bv-button { width: 100%; } #bv-ui .bv-bottom-section { align-items: stretch; display: flex; flex-direction: column; gap: 0.3rem; margin-top: 0.4rem !important; max-width: 100%; min-width: 0; padding: 0 0.1rem; } #bv-ui .bv-bottom-section .bv-group.bv-stat-group { grid-template-columns: minmax(0, 1fr) auto; } #bv-ui hr { border: 0; border-top: 1px solid rgba(0, 0, 0, 0.35); margin: 0.3rem 0 !important; } #bv-ui .bv-pattern-builder { display: flex; flex-direction: column; gap: 0.2rem; grid-column: 1 / -1; margin-top: 0.15rem; } #bv-ui .bv-token-row { align-items: start; display: grid; gap: 0.18rem 0.3rem; grid-template-columns: 2.65rem minmax(0, 1fr); } #bv-ui .bv-token-row-label { color: #9eb09e; font-size: 0.68rem; grid-column: 1; line-height: 1.45; padding-top: 0.14rem; } #bv-ui .bv-token-bar { display: flex; flex-wrap: wrap; gap: 0.18rem; grid-column: 2; min-width: 0; } #bv-ui .bv-token-btn { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; box-sizing: border-box; color: #e8efe8; cursor: pointer; font-size: 0.68rem; font-weight: 500; line-height: 1.2; margin: 0; padding: 0.12rem 0.32rem; width: auto; } #bv-ui .bv-token-btn:hover { background: #4f634f; } #bv-ui .bv-token-btn:active, #bv-ui .bv-button:active, #bv-ui .bv-tab-button:active { filter: brightness(1.2); transform: scale(0.98); } #bv-ui .bv-token-btn.bv-token-flash { background: #6a8f6a !important; border-color: #9eb09e !important; } #bv-ui .bv-input.bv-pattern-insert-flash { outline: 2px solid #9eb09e; outline-offset: 1px; } #bv-ui .bv-token-btn.bv-token-sep { background: #334433; min-width: 1.35rem; padding: 0.12rem 0.28rem; text-align: center; } #bv-ui .bv-bookmark-toolbar { margin-top: 0.25rem; } #bv-ui .bv-bookmark-sort { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; font-size: 0.8rem; padding: 0; } #bv-ui .bv-bookmark-sort:hover { background: #4f634f; } #bv-ui .bv-bookmark-list { margin-top: 0.25rem; } #bv-ui .bv-bookmark-link { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; font-size: 0.75rem; } #bv-ui .bv-bookmark-link:hover { background: #4f634f; } #bv-ui .bv-bookmark-remove { background: #4a3d3d; border: 1px solid #6f5a5a; border-radius: 2px; color: #e8efe8; font-size: 0.95rem; padding: 0; } #bv-ui .bv-bookmark-remove:hover { background: #5a4f4f; } #bv-ui .bv-bookmark-action { background: #3d4f3d; border: 1px solid #5a6f5a; border-radius: 2px; color: #e8efe8; } #bv-ui .bv-bookmark-action:hover { background: #4f634f; } #bv-ui .bv-bookmark-action .r34-tag-action-icon { height: 1rem; width: 1rem; } #bv-ui .bv-bookmark-empty { color: #9eb09e; font-size: 0.72rem; } #bv-ui .bv-bookmark-link:active, #bv-ui .bv-bookmark-remove:active, #bv-ui .bv-bookmark-action:active { filter: brightness(1.2); transform: scale(0.98); } .tag-search h5.r34-bookmark-heading { align-items: center; display: flex; justify-content: space-between; gap: 0.25rem; line-height: 1.5; min-height: 1.5em; } .tag-search .r34-bookmark-star { align-items: center; background: none; border: 0; box-sizing: border-box; color: var(--bv-theme-color); cursor: pointer; display: inline-flex; flex-shrink: 0; height: 1.5em; justify-content: center; line-height: 0; margin: 0; padding: 0; vertical-align: middle; width: 1.5em; } .tag-search .r34-bookmark-star-icon { display: block; height: 100%; width: 100%; } .tag-search .r34-bookmark-star:hover { filter: brightness(1.15); } .tag-search .r34-bookmark-star:active { transform: scale(0.98); } .tag-search input[name="tags"]:not(.bv-tags-ready) { opacity: 0; } #bv-ui, #bv-tag-discovery-panel, #bv-compliance-rules, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download), #bv-download-interruption-panel, #bv-ui :is(button, input, select, textarea, .bv-button, .bv-tab-button, .bv-label, .bv-title, .bv-token-btn, .bv-bookmark-link, .bv-bookmark-sort, .bv-bookmark-remove, .bv-bookmark-action), #bv-tag-discovery-panel :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), #bv-compliance-rules :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span), #bv-download-interruption-panel :is(button, input, select, textarea, .bv-button, .bv-label, .bv-title, h2, h3, p, span) { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; } .bv-modal-dialog, .bv-modal-dialog :is(button, input, select, textarea, h2, h3, p, span) { font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important; } #tag-sidebar .bv-tag-actions-slot { display: inline-flex; align-items: center; line-height: 1; min-width: 2.5em; justify-content: flex-end; vertical-align: middle; } #tag-sidebar .bv-tag-actions-slot .tag-count { line-height: 1; vertical-align: middle; } #tag-sidebar .bv-tag-actions-slot .tag-count[hidden] { display: none; } #tag-sidebar .bv-tag-actions { align-items: center; display: inline-flex; gap: 0.1rem; line-height: 1; vertical-align: middle; } #tag-sidebar .bv-tag-actions[hidden] { display: none; } #tag-sidebar li.r34-tag-muted a { color: #8a8a8a !important; } #tag-sidebar li.r34-tag-hidden a { color: #865050 !important; } #tag-sidebar .r34-tag-action-btn { align-items: center; background: none; border: 0; color: var(--bv-theme-color); cursor: pointer; display: inline-flex; flex-shrink: 0; height: 1em; justify-content: center; line-height: 1; margin: 0; padding: 0; vertical-align: middle; width: 1em; } #tag-sidebar .r34-tag-action-btn:hover { filter: brightness(1.15); } #tag-sidebar .r34-tag-action-btn:active { transform: scale(0.95); } #tag-sidebar .r34-tag-action-icon { display: block; height: 100%; width: 100%; } #tag-sidebar .r34-tag-action-icon-letters { text-rendering: geometricPrecision; shape-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; } #bv-tag-discovery-panel.bv-bg-colour, #bv-compliance-rules.bv-bg-colour, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-bg-colour, #bv-download-interruption-panel.bv-bg-colour { background-color: var(--bv-theme-color) !important; } #bv-tag-discovery-panel .bv-dock-slide-panel-title, #bv-compliance-rules .bv-dock-slide-panel-title, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-slide-panel-title, #bv-download-interruption-panel .bv-dock-slide-panel-title { color: #e8efe8; font-size: 0.75rem; font-weight: 500; } #bv-compliance-rules .bv-dock-slide-panel-close { background-color: #4a5f4a; border: 1px solid #3a4f3a; color: #fff; font-size: 0.8125rem; line-height: 1.1; padding: 0.18rem 0.38rem; } #bv-compliance-rules .bv-dock-slide-panel-close:hover { background-color: #5a6f5a; } #bv-tag-discovery-panel .bv-dock-list-intro, #bv-compliance-rules .bv-dock-list-intro, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-intro, #bv-download-interruption-panel .bv-dock-list-intro { color: #c8d4c8; font-size: 0.78rem; } :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) { overflow-y: visible; } :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-slide-panel-body { flex: 0 1 auto; min-height: 0; overflow: visible; } #bv-download-interruption-panel .bv-download-interruption-tip { color: #9eb09e; font-size: 0.72rem; line-height: 1.35; margin: 0.55rem 0 0; } #bv-tag-discovery-panel .bv-dock-list-group h3, #bv-compliance-rules .bv-dock-list-group h3 { color: #9eb09e; font-size: 0.68rem; font-weight: 500; letter-spacing: 0.05em; } #bv-compliance-rules .bv-dock-list-meta { color: #9eb09e; } #bv-tag-discovery-panel .bv-tag-actions, #bv-compliance-rules .bv-tag-actions { align-items: center; display: inline-flex; gap: 0.12rem; line-height: 1; } #bv-compliance-rules .bv-dock-list-action-btn { background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; color: #fff; font-size: 0.72rem; line-height: 1.2; padding: 0.15rem 0.35rem; width: auto; } #bv-compliance-rules .bv-dock-list-action-btn:hover { background-color: #5a6f5a; } #bv-tag-discovery-panel .r34-tag-action-btn { align-items: center; background: none; border: 0; color: #c8d4c8; cursor: pointer; display: inline-flex; flex-shrink: 0; height: 1.1em; justify-content: center; line-height: 1; margin: 0; padding: 0; vertical-align: middle; width: 1.1em; } #bv-tag-discovery-panel .r34-tag-action-btn:hover { color: #fff; filter: brightness(1.1); } #bv-tag-discovery-panel .r34-tag-action-btn:active { transform: scale(0.95); } #bv-tag-discovery-panel .r34-tag-action-icon { display: block; height: 100%; width: 100%; } #bv-tag-discovery-panel .r34-tag-action-icon-letters { text-rendering: geometricPrecision; shape-rendering: geometricPrecision; -webkit-font-smoothing: antialiased; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-button, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-button, #bv-download-interruption-panel .bv-dock-list-footer .bv-button { background-color: #4a5f4a; border: 1px solid #3a4f3a; border-radius: 2px; box-sizing: border-box; color: #fff; font-size: 0.8125rem; font-weight: 500; line-height: 1.25; margin: 0; padding: 0.28rem 0.45rem; width: 100%; } #bv-tag-discovery-panel .bv-dock-list-footer.bv-dock-list-footer-row .bv-button { flex: 1 1 0; min-width: 0; width: auto; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-button:hover, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-button:hover, #bv-download-interruption-panel .bv-dock-list-footer .bv-button:hover { background-color: #5a6f5a; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-dock-list-footer-secondary, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-dock-list-footer-secondary { background-color: transparent; border: 1px solid #5a6f5a; color: #c8d4c8; } #bv-tag-discovery-panel .bv-dock-list-footer .bv-dock-list-footer-secondary:hover, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download) .bv-dock-list-footer .bv-dock-list-footer-secondary:hover { background-color: #3d4f3d; color: #fff; } #bv-tag-discovery-panel .bv-dock-list-section-title { color: #e8efe8; font-size: 0.78rem; font-weight: 600; } #bv-tag-discovery-panel .bv-dock-list-meta { color: #9eb09e; flex: 0 0 auto; font-size: 0.72rem; line-height: 1.3; margin-left: 0.15rem; margin-right: 0.15rem; white-space: nowrap; } .bv-timed-button-container { display: block; position: relative; width: 100%; } .bv-timed-button-container .bv-timed-button { display: block; width: 100%; } .bv-timed-button-bar { background: rgba(0, 0, 0, 0.35); border-radius: 0 0 2px 2px; bottom: 0; height: 2px; left: 0; overflow: hidden; pointer-events: none; position: absolute; right: 0; width: 100%; } .bv-timed-button-bar-fill { background: #9eb09e; height: 100%; width: 100%; } #bv-ui.bv-dock-panel-bottom-half-left, #bv-compliance-rules.bv-dock-panel-bottom-half-left, #bv-tag-discovery-panel.bv-dock-panel-bottom-half-left, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-bottom-half-left, #bv-download-interruption-panel.bv-dock-panel-bottom-half-left, #bv-ui.bv-dock-panel-bottom-half-right, #bv-compliance-rules.bv-dock-panel-bottom-half-right, #bv-tag-discovery-panel.bv-dock-panel-bottom-half-right, :is(#bv-human-interaction-panel, #bv-human-interaction-panel-resolution, #bv-human-interaction-panel-download).bv-dock-panel-bottom-half-right, #bv-download-interruption-panel.bv-dock-panel-bottom-half-right { min-width: 0 !important; width: auto !important; max-width: 50vw; } body.r34-dm-selection span.thumb, body.r34-dm-selection span.thumb * { -webkit-user-drag: none !important; }`
)

// ---------------------------------------------------------------------------
// Shared URL helpers
// ---------------------------------------------------------------------------

const QUERY_PARAMS = new URLSearchParams(location.search)

const RATE_LIMIT_REFRESH_DELAY_MS = 5000
const RESOLUTION_INITIATION_GAP_MS = 2000
const DOWNLOAD_INITIATION_GAP_MS = 1000

/** Debug: keep dock migrating chrome after init. Leave false in normal use. */
const FORCE_DOCK_MIGRATION_STATUS = false

// ---------------------------------------------------------------------------
// DOM selectors (see rule34xxx.spec.md for the full analysis)
// ---------------------------------------------------------------------------

const SELECTOR_ITEM_LIST = '.image-list'
const SELECTOR_ITEM = 'span.thumb'
const SELECTOR_ITEM_LINK = 'a'
const SELECTOR_ITEM_THUMBNAIL = 'img.preview'
const SELECTOR_MEDIA_PICTURE = '#image'
const SELECTOR_MEDIA_VIDEO_SOURCE = '#gelcomVideoPlayer source'
const SELECTOR_MEDIA_VIDEO_CONTAINER = '#gelcomVideoContainer'
const SELECTOR_STATS = '#stats'
const SELECTOR_SCORE = '#stats span[id^="psc"]'
const SELECTOR_TAG_SIDEBAR = '#tag-sidebar'
const SELECTOR_TAG_SIDEBAR_ROW = '#tag-sidebar li[class*="tag-type-"]'
const SELECTOR_SEARCH_INPUT = '.tag-search input[name="tags"]'
const SELECTOR_SEARCH_HEADING = '.tag-search h5'
const SELECTOR_PAGINATOR_NEXT = '#paginator a[alt="next"]'
const SELECTOR_PAGINATOR_BACK = '#paginator a[alt="back"]'
const BOOKMARK_STAR_ICON_CLASS = 'r34-bookmark-star-icon'

// ---------------------------------------------------------------------------
// Item attributes
// ---------------------------------------------------------------------------

const ITEM_TAGS = 'tags'
const ITEM_POST_ID = 'postId'

// ---------------------------------------------------------------------------
// Configuration keys
// ---------------------------------------------------------------------------

const OPTION_ENABLE_DEFAULT_TAGS = 'enable-default-tags'
const DEFAULTS_TAGS = 'default-tags'
const OPTION_ENABLE_RESOLUTION = 'enable-resolution-filter'
const DEFAULTS_WIDTH = 'width'
const DEFAULTS_HEIGHT = 'height'

const OPTION_ENABLE_EXPLORED_TAGS_TRACKER = 'enable-explored-tags-tracker'
const FILTER_EXPLORED_TAGS = 'explored-tags-tracker'

const OPTION_HIDE_OLDER_POSTS = 'hide-older-posts'
const LAST_ID = 'last-id'

const MEDIA_AUTO_DOWNLOAD = 'auto-download-media'
const MEDIA_REMOVE_ON_SUCCESS = 'remove-media-on-download-success'
const DOWNLOAD_FOLDER = 'download-folder'
const DOWNLOAD_FILENAME_PATTERN = 'filename-pattern'
const DOWNLOAD_SUBFOLDER_PATTERN = 'subfolder-pattern'

const DEFAULT_DOWNLOAD_FOLDER = 'brazen-rule34xxx'
const BOOKMARKS = 'bookmarks'
const DOWNLOAD_TAG_IGNORE = 'filename-tag-ignore-list'
const DOWNLOAD_TAG_SUBSTITUTIONS = 'filename-tag-substitutions'
const DOWNLOAD_STRIP_CHARACTER_SERIES = 'strip-series-from-character-tags'

/** Joins multiple tags of the same type inside a filename/subfolder token. */
const DOWNLOAD_MULTI_TAG_SEPARATOR = ', '

/** Fallback when Artists / Series / Characters tokens are used but the post has none. */
const DOWNLOAD_TAG_TOKEN_UNKNOWN_DEFAULT = 'unknown'
const DOWNLOAD_TAG_TOKEN_UNKNOWN_TYPES = new Set(['author', 'copyright', 'character'])

const SEARCH_AUTO_NEXT = 'auto-next-page'

const DOCK_BOOKMARK_SEARCH = 'bookmark-search'
const DOCK_DOWNLOAD_MEDIA = 'download-media'
const DOCK_SET_LATEST_ID = 'set-latest-id'
const DOCK_SET_POST_ID = 'set-post-id'

/** Download URL captured when the live picture is suppressed (never assigned to a DOM node). */
let _capturedMediaUrl = ''

/** Stub element for metadata after suppression — src is read-only and does not fetch. */
let _capturedMediaElement = null

/** Parent node #image was removed from — used to restore after a failed download. */
let _mediaPictureHost = null

/** Watches for site JS re-inserting #image after suppression. */
let _mediaResurrectionObserver = null

/** Timeout id for the bounded media-resurrection watcher lifetime. */
let _mediaResurrectionTimeoutId = null

/**
 * @private
 */
function _clearMediaResurrectionTimeout()
{
  if (_mediaResurrectionTimeoutId != null) {
    clearTimeout(_mediaResurrectionTimeoutId)
    _mediaResurrectionTimeoutId = null
  }
}

/**
 * @private
 */
function _stopMediaPictureResurrectionWatcher()
{
  _clearMediaResurrectionTimeout()
  _mediaResurrectionObserver?.disconnect()
  _mediaResurrectionObserver = null
}

/**
 * Stops the resurrection watcher and drops capture refs without restoring #image.
 * Used after a successful remove-on-download suppress (or page unload).
 *
 * @private
 */
function _releaseSuppressedMediaCapture()
{
  _stopMediaPictureResurrectionWatcher()
  _capturedMediaUrl = ''
  _capturedMediaElement = null
  _mediaPictureHost = null
}

/**
 * Puts #image back after a failed download when suppression ran for a new save attempt.
 *
 * @private
 */
function _restoreMediaPicture()
{
  _stopMediaPictureResurrectionWatcher()
  let url = _capturedMediaUrl
  let host = _mediaPictureHost
  _capturedMediaUrl = ''
  _capturedMediaElement = null
  _mediaPictureHost = null
  if (!url || !host || document.querySelector(SELECTOR_MEDIA_PICTURE)) {
    return
  }
  let img = document.createElement('img')
  img.id = 'image'
  img.src = url
  host.appendChild(img)
}

/**
 * @param {string} candidate
 * @param {string} current
 * @return {boolean}
 */
function _isPreferredMediaUrl(candidate, current)
{
  if (!current) {
    return true
  }
  let candidateIsSample = /sample/i.test(candidate)
  let currentIsSample = /sample/i.test(current)
  if (candidateIsSample !== currentIsSample) {
    return !candidateIsSample
  }
  return candidate.length >= current.length
}

/**
 * Full-resolution media URL from Options → "Original image" (works for pictures and videos).
 * Always present on media pages; `#image` / video `source` are resized renditions and are not used.
 *
 * @param {ParentNode|Document|null|undefined} doc
 * @return {string}
 */
function _getOriginalMediaUrlFromDocument(doc)
{
  if (!doc?.querySelectorAll) {
    return ''
  }
  for (let anchor of doc.querySelectorAll('.link-list a')) {
    if (!/original\s*image/i.test(anchor.textContent ?? '')) {
      continue
    }
    let href = (anchor.getAttribute('href') ?? '').trim()
    if (!href || href === '#' || /^javascript:/i.test(href)) {
      continue
    }
    return href
  }
  return ''
}

/**
 * @return {HTMLImageElement|null}
 */
function _getCapturedMediaElement()
{
  if (!_capturedMediaUrl) {
    return null
  }
  if (!_capturedMediaElement) {
    _capturedMediaElement = document.createElement('img')
    Object.defineProperty(_capturedMediaElement, 'src', {
      get: () => _capturedMediaUrl,
      set: () => {},
      configurable: true,
    })
  }
  return _capturedMediaElement
}

/**
 * Clears src/srcset and removes a live picture node.
 *
 * @param {HTMLImageElement} img
 */
function _suppressLiveMediaPicture(img)
{
  if (!(img instanceof HTMLImageElement)) {
    return
  }
  if (!_mediaPictureHost && img.parentElement) {
    _mediaPictureHost = img.parentElement
  }
  img.removeAttribute('srcset')
  img.removeAttribute('sizes')
  img.src = ''
  img.remove()
}

/**
 * Suppresses a live #image and records its URL without triggering a fetch.
 *
 * @param {HTMLImageElement} img
 */
function _captureAndSuppressMediaPicture(img)
{
  if (!(img instanceof HTMLImageElement)) {
    return
  }
  let url = img.src ?? ''
  _suppressLiveMediaPicture(img)
  if (!url) {
    return
  }
  if (_isPreferredMediaUrl(url, _capturedMediaUrl)) {
    _capturedMediaUrl = url
  }
}

/**
 * Keeps suppressing #image if the site re-adds it after removal.
 * Observes the picture host (not the whole document) to limit mutation cost.
 *
 * @private
 */
function _watchForMediaPictureResurrection()
{
  if (_mediaResurrectionObserver) {
    return
  }
  let root = _mediaPictureHost || document.documentElement
  _mediaResurrectionObserver = new MutationObserver((mutations) => {
    for (let mutation of mutations) {
      if (mutation.type === 'attributes' && mutation.target instanceof HTMLImageElement && mutation.target.id === 'image') {
        _captureAndSuppressMediaPicture(mutation.target)
        continue
      }
      for (let node of mutation.addedNodes) {
        if (!(node instanceof Element)) {
          continue
        }
        let candidate = node.id === 'image' ? node : node.querySelector('#image')
        if (candidate instanceof HTMLImageElement) {
          _captureAndSuppressMediaPicture(candidate)
        }
      }
    }
  })
  _mediaResurrectionObserver.observe(root, {childList: true, subtree: true, attributes: true, attributeFilter: ['src', 'srcset']})
}

/**
 * @param {HTMLImageElement} img
 */
function _suppressMediaForDownload(img)
{
  _captureAndSuppressMediaPicture(img)
  _watchForMediaPictureResurrection()
  // Tampermonkey often never fires GM_download onload — drop the watcher after the
  // site's typical re-insert window so overnight media tabs do not keep a MutationObserver.
  _clearMediaResurrectionTimeout()
  _mediaResurrectionTimeoutId = setTimeout(() => {
    _mediaResurrectionTimeoutId = null
    if (_mediaResurrectionObserver) {
      _releaseSuppressedMediaCapture()
    }
  }, 30_000)
}

/**
 * Stops in-page video playback and removes the player container so the browser does not
 * keep buffering alongside `GM_download`.
 *
 * @param {HTMLSourceElement} source
 */
function _suppressVideoForDownload(source)
{
  if (!(source instanceof HTMLSourceElement)) {
    return
  }
  let url = source.src ?? ''
  if (url && _isPreferredMediaUrl(url, _capturedMediaUrl)) {
    _capturedMediaUrl = url
  }
  let video = document.querySelector('#gelcomVideoPlayer')
  if (video instanceof HTMLVideoElement) {
    try {
      video.pause()
    } catch {
    }
    video.removeAttribute('src')
  }
  source.removeAttribute('src')
  document.querySelector(SELECTOR_MEDIA_VIDEO_CONTAINER)?.remove()
}
// ---------------------------------------------------------------------------
// Tag types
// ---------------------------------------------------------------------------

// Maps the rule34 sidebar CSS class suffix to the canonical type used in rules and filename tokens.
const SIDEBAR_CLASS_TO_TYPE = {
  artist: 'author',
  copyright: 'copyright',
  character: 'character',
  metadata: 'meta',
  general: 'general',
}

const TYPE_TO_SIDEBAR_CLASS = {
  author: 'tag-type-artist',
  copyright: 'tag-type-copyright',
  character: 'tag-type-character',
  meta: 'tag-type-metadata',
  general: 'tag-type-general',
}

/** Per-type discovery panel presentation (label, row class, name color) — consumer-owned. */
const TAG_DISCOVERY_TYPES = {
  author: {label: 'Artists', rowClass: TYPE_TO_SIDEBAR_CLASS.author, color: '#A00'},
  copyright: {label: 'Series', rowClass: TYPE_TO_SIDEBAR_CLASS.copyright, color: '#A0A'},
  character: {label: 'Characters', rowClass: TYPE_TO_SIDEBAR_CLASS.character, color: '#0A0'},
  meta: {label: 'Meta', rowClass: TYPE_TO_SIDEBAR_CLASS.meta, color: '#F80'},
  general: {label: 'General', rowClass: TYPE_TO_SIDEBAR_CLASS.general, color: '#009'},
  unknown: {label: 'Other', rowClass: TYPE_TO_SIDEBAR_CLASS.general, color: '#009'},
}

/** Seeded tagTypes entryId → canonical type name (includes artist alias → author). */
const TYPE_ENTRY_ID_TO_TYPE = {
  2: 'author',
  3: 'copyright',
  4: 'character',
  5: 'meta',
  6: 'general',
  7: 'author',
}

const TAG_TYPES = ['author', 'copyright', 'character', 'meta', 'general']

// Chip label text is what users see and what gets inserted into patterns; token keys drive resolution.
const DOWNLOAD_PATTERN_SECTIONS = [
  {
    label: 'Post',
    chips: [
      {token: 'id', label: 'ID', title: 'Numeric id of the post'},
      {token: 'md5', label: 'File hash', title: 'MD5 hash from the media file name'},
      {token: 'width', label: 'Width', title: 'Width in pixels'},
      {token: 'height', label: 'Height', title: 'Height in pixels'},
      {token: 'score', label: 'Score', title: 'Post score'},
      {token: 'rating', label: 'Rating', title: 'Content rating'},
    ],
  },
  {
    label: 'Tags',
    chips: [
      {token: 'author', label: 'Artists', title: 'Artist tags, comma-separated and alphabetically sorted'},
      {token: 'copyright', label: 'Series', title: 'Copyright / series tags, comma-separated and alphabetically sorted'},
      {token: 'character', label: 'Characters', title: 'Character tags, comma-separated and alphabetically sorted'},
      {token: 'meta', label: 'Meta', title: 'Metadata tags, comma-separated and alphabetically sorted'},
      {token: 'general', label: 'General', title: 'General tags, comma-separated and alphabetically sorted'},
    ],
  },
]

const DOWNLOAD_PATTERN_SEPARATORS = [
  {insert: ' ', label: 'Space', title: 'Insert a space between parts'},
  {insert: ' - ', label: '–', title: 'Insert a dash between parts'},
  {insert: '_', label: '_', title: 'Insert an underscore'},
  {insert: '/', label: '/', title: 'Start a new sub-folder level'},
]

/** Gelbooru-family search read/write (rule34 `tags=` param). */
const SEARCH_ADAPTER = new GelbooruFamilySearchAdapter()

class Rule34xxxSearchEnhancer extends BrazenFramework
{
  /**
   * Set when a default-tags redirect is about to navigate away.
   * @type {boolean}
   * @private
   */
  _redirecting = false

  /**
   * @type {boolean}
   * @private
   */
  _searchInputHooked = false

  /**
   * Memoized default search tokens (invalidated when the settings fingerprint changes).
   * @type {string[]|null}
   * @private
   */
  _defaultTokensCache = null

  /**
   * @type {string|null}
   * @private
   */
  _defaultTokensFingerprint = null

  /**
   * Debounce timer for Hide Downloaded focus/visibility compliance.
   * @type {ReturnType<typeof setTimeout>|null}
   * @private
   */
  _hideDownloadedResyncTimer = null

  /**
   * One-shot cache so async extractTagGroups + extractTagIncidences share one parse.
   * @type {{tagGroups: {}, tagIncidences: Record<string, number>}|null}
   * @private
   */
  _pendingMediaTagExtract = null

  /**
   * Live `tagDiscovery` config object shared with Download Manager (mutable `tagTypes`).
   * @type {object|null}
   * @private
   */
  _tagDiscoveryConfig = null

  /**
   * @type {((event: DragEvent) => void)|null}
   * @private
   */
  _selectionDragStartHandler = null

  /**
   * @type {((event: MouseEvent) => void)|null}
   * @private
   */
  _panelResizerMousedownHandler = null

  /**
   * Generation token so overlapping `_setupTagSidebarActions` runs discard stale paints.
   * @type {number}
   * @private
   */
  _tagSidebarActionsGeneration = 0

  constructor()
  {
    super({
      downloadDuplicateLedger: {
        primaryField: 'postIds',
        isValidId: (value) => /^\d+$/.test(String(value).trim()),
        getDownloadId: (mediaElement) => {
          let id = QUERY_PARAMS.get('id') ?? ''
          return id.trim() || null
        },
      },
      isUserLoggedIn: false,
      itemDeepAnalysisSelector: SELECTOR_TAG_SIDEBAR,
      itemLinkSelector: SELECTOR_ITEM_LINK,
      itemListSelectors: SELECTOR_ITEM_LIST,
      itemNameSelector: '',
      itemSelectors: SELECTOR_ITEM,
      itemSelectionMethod: 'children',
      requestDelay: 0,
      scriptPrefix: 'brazen-r34xxx-',
      legacyScriptPrefix: 'rule34xxx-',
      tagSelectorGenerator: (tag) => SELECTOR_TAG_SIDEBAR + ' li.tag:has(a[href$="search=' + tag.trim().replaceAll(' ', '_') + '"])',
      trackComplianceRules: true,
    })

    this.definePages({
      home: () => !!document.getElementById('static-index')
          || (!QUERY_PARAMS.get('page')
              && /^\/(?:index\.php)?$/i.test(location.pathname)),
      search: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'list',
      media: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'view'
          && !this._isMediaRateLimited()
          && !this._isCloudflareChallengeDoc(document),
      mediaRateLimited: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'view' && this._isMediaRateLimited(),
      mediaCloudflare: () => QUERY_PARAMS.get('page') === 'post' && QUERY_PARAMS.get('s') === 'view'
          && !this._isMediaRateLimited()
          && this._isCloudflareChallengeDoc(document),
    })

    this.addPageOperation('mediaRateLimited', () => {
      setTimeout(() => location.reload(), RATE_LIMIT_REFRESH_DELAY_MS)
      return true
    })

    this.addPageOperation('mediaCloudflare', () => {
      void this.handleMediaCloudflarePage()
      return true
    })

    this.setCompliancePages(['search'])

    this._configurationManager.setScriptSetup(rule34xxxSetupStorage)

    this._setupFeatures()
    this._setupFilters()
    this._setupDock()
    this._onBeforeFullInit.push(() => {
      this._setupUI()
      this._setupEvents()
    })
  }

  /**
   * @return {boolean}
   * @private
   */
  _isMediaRateLimited()
  {
    return this._is429RateLimitDoc(document)
  }

  /**
   * @param {Document} doc
   * @return {boolean}
   * @private
   */
  _hasResolvableMediaDoc(doc)
  {
    return !!(doc.querySelector('#image') || doc.querySelector('#gelcomVideoPlayer'))
  }

  /**
   * Site 429 HTML swap (auto-retry after {@link RATE_LIMIT_REFRESH_DELAY_MS}).
   * @param {Document} doc
   * @return {boolean}
   * @private
   */
  _is429RateLimitDoc(doc)
  {
    if (this._hasResolvableMediaDoc(doc)) {
      return false
    }
    let styleText = doc.querySelector?.('.style1')?.textContent ?? ''
    return /429\s+Rate limiting/i.test(doc.title || '')
        || styleText.includes('429 Rate limiting')
        || doc.body?.textContent?.includes('429 Rate limiting')
  }

  /**
   * Cloudflare managed challenge / Turnstile interstitial (human verification required).
   * Matches {@link site-html/cloudflare-429-media.html} (site CAPTCHA skin + `_cf_chl_opt` /
   * challenge-platform) as well as generic Cloudflare interstitials. Must not match a normal
   * media page that only embeds comment Turnstile / bot mgmt scripts while `#image` is present.
   * @param {Document} doc
   * @return {boolean}
   * @private
   */
  _isCloudflareChallengeDoc(doc)
  {
    if (this._hasResolvableMediaDoc(doc)) {
      return false
    }
    if (this._is429RateLimitDoc(doc)) {
      return false
    }
    let html = doc.documentElement?.innerHTML ?? ''
    let text = doc.body?.textContent ?? ''
    let title = doc.title || ''
    return /cdn-cgi\/challenge-platform/i.test(html)
        || /_cf_chl_opt/i.test(html)
        || /Please enter the CAPTCHA/i.test(text)
        || /\bCAPTCHA\b/i.test(title)
        || !!doc.querySelector?.('.captcha-box, #challenge-error-text, .cf-turnstile, .cf-challenge, #challenge-form, #cf-challenge-running')
        || /challenges\.cloudflare\.com/i.test(html)
        || /__CF\$cv\$params/i.test(html)
        || /Just a moment/i.test(title)
        || /Checking your browser/i.test(text)
        || /Enable JavaScript and cookies/i.test(text)
  }

  /**
   * @return {boolean}
   * @private
   */
  _isVideoMedia()
  {
    return this.isPage('media') && document.getElementById('gelcomVideoPlayer') != null
  }

  /**
   * @return {boolean}
   * @private
   */
  _isPictureMedia()
  {
    return this.isPage('media') && !this._isVideoMedia()
  }

  // -------------------------------------------------------------------------
  // Sidebar tag typing (download filename tokens)
  // -------------------------------------------------------------------------

  /**
   * @param {HTMLElement} element
   * @return {string|null}
   * @private
   */
  _sidebarRowType(element)
  {
    // Prefer getAttribute — className can be non-string on some parsed nodes.
    let className = element?.getAttribute?.('class') ??
        (typeof element?.className === 'string' ? element.className : element?.className?.baseVal) ??
        ''
    let match = String(className).match(/tag-type-(\w+)/i)
    if (!match) {
      return null
    }
    return SIDEBAR_CLASS_TO_TYPE[match[1].toLowerCase()] ?? 'general'
  }

  /**
   * Decode a single query-param value from an href (handles `&amp;` and %XX).
   * @param {string|null|undefined} href
   * @param {string} param
   * @return {string|null}
   * @private
   */
  _tagNameFromHrefParam(href, param)
  {
    if (!href) {
      return null
    }
    let normalizedHref = String(href).replace(/&amp;/gi, '&')
    let match = normalizedHref.match(new RegExp('[?&]' + param + '=([^&#]*)', 'i'))
    if (!match) {
      return null
    }
    let raw = match[1]
    try {
      raw = decodeURIComponent(raw.replace(/\+/g, ' '))
    } catch (error) {
      /* keep raw */
    }
    let decoded = this._decodeHtmlEntities(raw).trim()
    return decoded || null
  }

  /**
   * Reads the canonical underscored tag name from a sidebar row.
   * Prefers wiki `search=`, then post-list `tags=`, then visible link text
   * (DOMParser / partial pages sometimes omit the wiki `?` link).
   *
   * @param {HTMLElement} row
   * @return {string|null}
   * @private
   */
  _sidebarRowTagName(row)
  {
    let el = row
    if (!el || typeof el.querySelector !== 'function') {
      return null
    }
    let wiki = el.querySelector('a[href*="page=wiki"][href*="search="], a[href*="page=wiki"]')
    let fromWiki = this._tagNameFromHrefParam(wiki?.getAttribute('href'), 'search')
    if (fromWiki) {
      return fromWiki
    }
    let listLink = el.querySelector('a[href*="tags="]')
    let fromTags = this._tagNameFromHrefParam(listLink?.getAttribute('href'), 'tags')
    if (fromTags) {
      // List links can be multi-tag; take the first token.
      let first = fromTags.split(/\s+/).filter(Boolean)[0]
      if (first) {
        return first
      }
    }
    let textLink = el.querySelector('a[href*="tags="]') ||
        [...el.querySelectorAll('a')].find((anchor) => {
          let text = (anchor.textContent ?? '').trim()
          return text && text !== '?'
        })
    let text = (textLink?.textContent ?? '').trim()
    if (text && text !== '?') {
      return text.replace(/\s+/g, '_')
    }
    return null
  }

  /**
   * Returns bare tag names from a search thumbnail's alt text for blacklist matching.
   *
   * @param {HTMLElement} item
   * @return {string[]}
   * @private
   */
  _extractItemTags(item)
  {
    let alt = item.querySelector(SELECTOR_ITEM_THUMBNAIL)?.getAttribute('alt')
    if (!alt) {
      return []
    }
    let tags = []
    for (let rawName of alt.trim().split(/\s+/)) {
      let name = this._normalizeSimpleTagLine(this._decodeHtmlEntities(rawName))
      if (name) {
        tags.push(name)
      }
    }
    return tags
  }

  // -------------------------------------------------------------------------
  // Default tags & resolution -> metatags
  // -------------------------------------------------------------------------

  /**
   * Collects the enabled default tokens from the default-tags ruleset and the
   * resolution range filter.
   *
   * @return {string[]}
   * @private
   */
  _getDefaultTokens()
  {
    let fingerprint = JSON.stringify([
      this._getConfig(OPTION_ENABLE_DEFAULT_TAGS),
      this._getConfig(DEFAULTS_TAGS),
      this._getConfig(OPTION_ENABLE_RESOLUTION),
      this._getConfig(DEFAULTS_WIDTH),
      this._getConfig(DEFAULTS_HEIGHT),
    ])
    if (this._defaultTokensCache && this._defaultTokensFingerprint === fingerprint) {
      return this._defaultTokensCache
    }
    let tokens = []

    if (this._getConfig(OPTION_ENABLE_DEFAULT_TAGS)) {
      for (let line of this._getConfig(DEFAULTS_TAGS)) {
        tokens.push(...SEARCH_ADAPTER.engine.tokenize(line))
      }
    }

    if (this._getConfig(OPTION_ENABLE_RESOLUTION)) {
      this._appendRangeTokens(tokens, 'width', this._getConfig(DEFAULTS_WIDTH))
      this._appendRangeTokens(tokens, 'height', this._getConfig(DEFAULTS_HEIGHT))
    }
    this._defaultTokensCache = tokens
    this._defaultTokensFingerprint = fingerprint
    return tokens
  }

  /**
   * @param {string[]} tokens
   * @param {string} key
   * @param {{minimum: *, maximum: *}} range
   * @private
   */
  _appendRangeTokens(tokens, key, range)
  {
    let minimum = Number.parseInt(range.minimum) || 0
    let maximum = Number.parseInt(range.maximum) || 0
    if (minimum > 0) {
      tokens.push(key + ':>=' + minimum)
    }
    if (maximum > 0) {
      tokens.push(key + ':<=' + maximum)
    }
  }

  /**
   * @param {string|null} tags
   * @return {string}
   * @private
   */
  _normalizeSearchTagsParam(tags)
  {
    return SEARCH_ADAPTER.normalizeQueryParam(tags)
  }

  /**
   * @param {string} tags
   * @return {string}
   * @private
   */
  _buildSearchListUrl(tags)
  {
    return SEARCH_ADAPTER.buildListUrl(tags)
  }

  /**
   * On search pages, merges default tokens into the URL `tags` param and
   * redirects once if the query changed. The merge is idempotent, which is the
   * loop guard.
   *
   * @private
   */
  _handleDefaultInjection()
  {
    if (!this.isPage('search')) {
      return
    }
    let defaults = this._getDefaultTokens()
    if (!defaults.length) {
      return
    }

    let params = new URLSearchParams(location.search)
    let current = this._normalizeSearchTagsParam(params.get('tags') ?? '')
    let normalizedCurrent = SEARCH_ADAPTER.engine.tokenize(current).join(' ')
    let merged = SEARCH_ADAPTER.engine.merge(current, defaults).join(' ')

    if (merged !== normalizedCurrent) {
      this._redirecting = true
      location.href = this._buildSearchListUrl(merged)
    }
  }

  /**
   * @private
   */
  _refreshSearchInputDisplay()
  {
    let input = document.querySelector(SELECTOR_SEARCH_INPUT)
    if (!input) {
      return
    }

    let tags = input.value
    if (!tags.trim()) {
      let params = new URLSearchParams(location.search)
      tags = this._normalizeSearchTagsParam(params.get('tags') ?? '')
    } else {
      tags = this._normalizeSearchTagsParam(tags)
    }

    input.value = SEARCH_ADAPTER.engine.subtract(SEARCH_ADAPTER.engine.tokenize(tags), this._getDefaultTokens()).join(' ')
    input.classList.add('bv-tags-ready')
  }

  /**
   * Hooks the real search input so the user only sees their own tags while the
   * defaults are merged back in on submit. Operates in place to keep the site's
   * awesomplete autocomplete intact.
   *
   * @private
   */
  _setupSearchInputHook()
  {
    if (this._redirecting) {
      return
    }
    let input = document.querySelector(SELECTOR_SEARCH_INPUT)
    if (!input) {
      return
    }

    this._refreshSearchInputDisplay()

    if (!this._searchInputHooked) {
      let form = input.closest('form')
      if (form) {
        form.addEventListener('submit', (event) => {
          event.preventDefault()
          let merged = SEARCH_ADAPTER.engine.merge(input.value, this._getDefaultTokens()).join(' ')
          location.href = this._buildSearchListUrl(merged)
        }, true)
      }
      this._searchInputHooked = true
    }
  }

  // -------------------------------------------------------------------------
  // Latest only mode
  // -------------------------------------------------------------------------

  /**
   * @param {HTMLElement} item
   * @return {number|null}
   * @private
   */
  _getItemPostId(item)
  {
    let thumbId = item.getAttribute('id')
    if (thumbId && thumbId.startsWith('s')) {
      let id = Number.parseInt(thumbId.slice(1), 10)
      if (!Number.isNaN(id)) {
        return id
      }
    }
    let link = item.querySelector(SELECTOR_ITEM_LINK)
    if (!link) {
      return null
    }
    let linkId = link.getAttribute('id')
    if (linkId && linkId.startsWith('p')) {
      let id = Number.parseInt(linkId.slice(1), 10)
      if (!Number.isNaN(id)) {
        return id
      }
    }
    let href = link.getAttribute('href') ?? ''
    let match = href.match(/[?&]id=(\d+)/)
    if (match) {
      let id = Number.parseInt(match[1], 10)
      if (!Number.isNaN(id)) {
        return id
      }
    }
    return null
  }

  /**
   * @param {number} value
   * @private
   */
  _setLatestMarker(value)
  {
    let field = this._configurationManager.getField(LAST_ID)
    field.value = value
    if (field.element) {
      field.updateUserInterface()
    }
    this._configurationManager.save()
  }

  /**
   * @private
   */
  _handleSetMarkerFromSearch()
  {
    let maximum = 0
    for (let element of document.querySelectorAll(SELECTOR_ITEM)) {
      let id = Number.parseInt((element.getAttribute('id') ?? '').slice(1)) || 0
      if (id > maximum) {
        maximum = id
      }
    }
    if (maximum > 0) {
      this._setLatestMarker(maximum)
    }
  }

  /**
   * @private
   */
  _handleSetMarkerFromMedia()
  {
    let id = Number.parseInt(QUERY_PARAMS.get('id')) || 0
    if (id > 0) {
      this._setLatestMarker(id)
    }
  }

  // -------------------------------------------------------------------------
  // Media downloads
  // -------------------------------------------------------------------------

  /**
   * @return {HTMLElement|null}
   * @private
   */
  _getMediaElement()
  {
    let captured = _getCapturedMediaElement()
    if (captured) {
      return captured
    }
    return document.querySelector(this._isVideoMedia() ? SELECTOR_MEDIA_VIDEO_SOURCE : SELECTOR_MEDIA_PICTURE)
  }

  /**
   * Extracts download metadata from the media page DOM and URL (no page globals).
   *
   * @param {HTMLElement} media
   * @return {{}}
   * @private
   */
  _getMediaData(media)
  {
    void media
    let url = _getOriginalMediaUrlFromDocument(document)
    let fileName = url.split('?')[0].split('/').pop()
    let extension = fileName.includes('.') ? fileName.split('.').pop() : ''
    let md5 = (extension ? fileName.slice(0, -(extension.length + 1)) : fileName).replace(/^sample_/, '')

    let data = {
      id: QUERY_PARAMS.get('id') ?? '',
      md5: md5,
      ext: extension,
      width: '',
      height: '',
      score: this._decodeHtmlEntities(document.querySelector(SELECTOR_SCORE)?.textContent?.trim() ?? ''),
      rating: '',
      source: '',
      sourcedomain: '',
    }

    for (let element of document.querySelectorAll(`${SELECTOR_STATS} li`)) {
      let text = this._decodeHtmlEntities(element.textContent.trim())
      let size = text.match(/Size:\s*(\d+)\s*x\s*(\d+)/i)
      if (size) {
        data.width = size[1]
        data.height = size[2]
      } else if (/^Rating:/i.test(text)) {
        data.rating = text.replace(/^Rating:\s*/i, '').trim()
      } else if (/^Source:/i.test(text)) {
        let link = element.querySelector('a')
        let source = link ? (link.getAttribute('href') ?? link.textContent.trim()) : text.replace(/^Source:\s*/i, '').trim()
        data.source = this._decodeHtmlEntities(source)
        if (data.source) {
          try {
            data.sourcedomain = new URL(data.source).hostname
          } catch {
            data.sourcedomain = ''
          }
        }
      }
    }
    return data
  }

  /**
   * One extract pipeline for queue + immediate: typed sidebar rows from the media
   * document only (the resolve fetch). No extra same-origin tag-list requests.
   *
   * @return {Promise<{}}
   * @private
   */
  async _getMediaTagGroupsAsync()
  {
    this._pendingMediaTagExtract = await this._getMediaTagGroupsAndIncidencesAsync(document)
    return this._pendingMediaTagExtract.tagGroups
  }

  /**
   * @return {Promise<Record<string, number>>}
   * @private
   */
  async _getMediaTagIncidencesAsync()
  {
    let result = this._pendingMediaTagExtract ??
        await this._getMediaTagGroupsAndIncidencesAsync(document)
    this._pendingMediaTagExtract = null
    return result.tagIncidences
  }

  /**
   * Parse typed tags + incidences from `#tag-sidebar` on the media document.
   * @param {Document|ParentNode} root
   * @return {{tagGroups: {}, tagIncidences: Record<string, number>}}
   * @private
   */
  _getMediaTagGroupsAndIncidencesFromRoot(root)
  {
    let tagGroups = {author: [], copyright: [], character: [], meta: [], general: []}
    let tagIncidences = {}
    let scope = root?.querySelectorAll ? root : document
    let rows = scope.querySelectorAll(SELECTOR_TAG_SIDEBAR_ROW)
    rows.forEach((element) => {
      let type = this._sidebarRowType(element)
      // Native DOM — avoid jQuery find() on DOMParser documents from queue resolution.
      let name = this._normalizeTagAtom(this._sidebarRowTagName(element) ?? '')
      if (!name || !type || !tagGroups[type]) {
        return
      }
      tagGroups[type].push(name)
      let countEl = element.querySelector('span.tag-count')
      let raw = countEl?.textContent ?? ''
      if (raw) {
        let count = parseInt(String(raw).replace(/[^\d]/g, ''), 10)
        if (Number.isFinite(count)) {
          tagIncidences[name] = count
        }
      }
    })
    return {tagGroups, tagIncidences}
  }

  /**
   * Full extract from the already-fetched media document (sidebar only).
   *
   * @param {Document|ParentNode} root
   * @return {Promise<{tagGroups: {}, tagIncidences: Record<string, number>}>}
   * @private
   */
  async _getMediaTagGroupsAndIncidencesAsync(root)
  {
    return this._getMediaTagGroupsAndIncidencesFromRoot(root)
  }

  /**
   * @return {{token: string, label: string}[]}
   * @private
   */
  _getDownloadPatternTokenChips()
  {
    return DOWNLOAD_PATTERN_SECTIONS.flatMap((section) => section.chips)
  }

  /**
   * True when {@link pattern} references a chip via label, {label}, or {token}.
   * Delegates to Download Manager (single source of truth).
   * @param {string} pattern
   * @param {{token?: string, label?: string}} chip
   * @return {boolean}
   * @private
   */
  _patternIncludesDownloadChip(pattern, chip)
  {
    return this.getDownloadManager()?._patternIncludesChip(pattern, chip) ?? false
  }

  /**
   * Tag-type tokens present as chips in the active filename/subfolder patterns.
   * @return {string[]}
   * @private
   */
  _getPatternPinnedTagTypes()
  {
    let patterns = [
      this._getConfig(DOWNLOAD_FILENAME_PATTERN) ?? '',
      this._getConfig(DOWNLOAD_SUBFOLDER_PATTERN) ?? '',
    ]
    let pinned = []
    let seen = new Set()
    for (let chip of this._getDownloadPatternTokenChips()) {
      if (!TAG_TYPES.includes(chip.token) || seen.has(chip.token)) {
        continue
      }
      for (let pattern of patterns) {
        if (this._patternIncludesDownloadChip(pattern, chip)) {
          seen.add(chip.token)
          pinned.push(chip.token)
          break
        }
      }
    }
    return pinned
  }

  /**
   * Push pattern pins into live `tagDiscovery.tagTypes` so GreasyFork DM (array wins)
   * and local DM (pins ∩ allowlist) both scan only pinned types.
   * @private
   */
  _syncDiscoveryTagTypesFromPatterns()
  {
    let pinned = this._getPatternPinnedTagTypes()
    if (this._tagDiscoveryConfig) {
      this._tagDiscoveryConfig.tagTypes = pinned
    }
    let discovery = this.getDownloadManager()?._config?.tagDiscovery
    if (discovery) {
      discovery.tagTypes = pinned
    }
  }

  /**
   * @param {string} tag
   * @return {string}
   * @private
   */
  _normalizeTagAtom(tag)
  {
    return SEARCH_ADAPTER.normalizeAtom(tag)
  }

  /**
   * Normalizes one line in a blacklist/explored ruleset field.
   *
   * @param {string} line
   * @return {string}
   * @private
   */
  _normalizeBlacklistRuleLine(line)
  {
    let comment = ''
    let commentIdx = line.indexOf(' // ')
    if (commentIdx >= 0) {
      comment = line.slice(commentIdx)
      line = line.slice(0, commentIdx)
    }

    return line.split(/\s*&\s*/).
        map((segment) => segment.split(/\s*\|\s*/).
            map((alternative) => this._normalizeSimpleTagLine(alternative)).
            join(' | ')).
        join(' & ') + comment
  }

  /**
   * @param {string} line
   * @return {string}
   * @private
   */
  _normalizeSimpleTagLine(line)
  {
    return this._normalizeTagAtom(line)
  }

  /**
   * @param {string} line
   * @param {string} normalizedName
   * @return {boolean}
   * @private
   */
  _tagBlacklistLineMatchesTag(line, normalizedName)
  {
    let rulePart = line
    let commentIdx = line.indexOf(' // ')
    if (commentIdx >= 0) {
      rulePart = line.slice(0, commentIdx)
    }
    rulePart = rulePart.trim()
    if (!rulePart || rulePart.includes('&') || rulePart.includes('|')) {
      return false
    }
    return this._normalizeBlacklistRuleLine(rulePart) === normalizedName
  }

  /**
   * @private
   */
  _setupRulesetFieldMatchers()
  {
    let tagBlacklistOnMatchRule = (storedRule, target) => this._tagBlacklistLineMatchesTag(
        storedRule, this._normalizeSimpleTagLine(target))

    let blacklistField = this._configurationManager.getField(FILTER_TAG_BLACKLIST)
    if (blacklistField) {
      blacklistField.onMatchRule = tagBlacklistOnMatchRule
    }

    let exploredField = this._configurationManager.getField(FILTER_EXPLORED_TAGS)
    if (exploredField) {
      exploredField.onMatchRule = tagBlacklistOnMatchRule
    }

    let substitutionField = this._configurationManager.getField(DOWNLOAD_TAG_SUBSTITUTIONS)
    if (substitutionField) {
      substitutionField.onMatchRule = (storedRule, target) =>
          this._normalizeTagAtom(storedRule.subject) === this._normalizeTagAtom(target)
    }

    let ignoreField = this._configurationManager.getField(DOWNLOAD_TAG_IGNORE)
    if (ignoreField) {
      ignoreField.onMatchRule = (storedRule, target) =>
          this._normalizeSimpleTagLine(storedRule) === this._normalizeSimpleTagLine(target)
    }
  }

  /**
   * @param {string} line
   * @return {string}
   * @private
   */
  _normalizeDefaultTagLine(line)
  {
    return SEARCH_ADAPTER.engine.tokenize(line).map((token) => this._normalizeTagAtom(token)).join(' ')
  }

  /**
   * @return {{subject: string, replacement: string}[]}
   * @private
   */
  _getDownloadTagSubstitutions()
  {
    let field = this._configurationManager.getField(DOWNLOAD_TAG_SUBSTITUTIONS)
    if (!field) {
      return []
    }
    return field.optimized ?? field.value ?? []
  }

  /**
   * @param {string[]|{subject: string, replacement: string}[]} rules
   * @return {{subject: string, replacement: string}[]}
   * @private
   */
  _coerceDownloadTagSubstitutionRules(rules)
  {
    if (!Array.isArray(rules) || !rules.length) {
      return []
    }
    if (typeof rules[0] === 'object' && rules[0]?.subject && rules[0]?.replacement) {
      return rules
    }
    let manager = this.getDownloadManager()
    return manager ? manager.parseDownloadTagSubstitutionLines(rules, (tag) => this._normalizeTagAtom(tag)) : []
  }

  /**
   * @param {{subject: string, replacement: string}[]} rules
   * @return {Map<string, string>}
   * @private
   */
  _buildDownloadTagSubstitutionMap(rules)
  {
    return this.getDownloadManager()?.buildDownloadTagSubstitutionMap(rules) ?? new Map()
  }

  /**
   * @return {{chips: {token: string, label: string}[], tagTypes: string[], unknownTypes: Set<string>, unknownDefault: string, ignore: Set<string>, substitutions: Map<string, string>, stripCharacterSeries: boolean, multiTagSeparator: string}}
   * @private
   */
  _getDownloadPatternResolver()
  {
    return this._configurationManager.applyTagRegistryDownloadResolver({
      chips: this._getDownloadPatternTokenChips(),
      tagTypes: TAG_TYPES,
      unknownTypes: DOWNLOAD_TAG_TOKEN_UNKNOWN_TYPES,
      unknownDefault: DOWNLOAD_TAG_TOKEN_UNKNOWN_DEFAULT,
      ignore: new Set(this._getConfig(DOWNLOAD_TAG_IGNORE)),
      substitutions: this._buildDownloadTagSubstitutionMap(
          this._coerceDownloadTagSubstitutionRules(this._getDownloadTagSubstitutions())),
      stripCharacterSeries: this._getConfig(DOWNLOAD_STRIP_CHARACTER_SERIES),
      multiTagSeparator: DOWNLOAD_MULTI_TAG_SEPARATOR,
      normalizeTag: (value) => this._normalizeTagAtom(value),
    })
  }

  /**
   * @param {string[]} lines
   * @return {{subject: string, replacement: string}[]}
   * @private
   */
  _parseDownloadTagSubstitutionLines(lines)
  {
    let manager = this.getDownloadManager()
    if (!manager) {
      return []
    }
    return manager.parseDownloadTagSubstitutionLines(lines, (tag) => this._normalizeTagAtom(tag))
  }

  /**
   * @param {string} name
   * @return {boolean}
   * @private
   */
  _isTagSubstitutionSubject(name)
  {
    return this._configurationManager.hasTagSubstitutionSubject(this._normalizeTagAtom(name))
  }

  /**
   * @private
   */
  _handleDownloadMedia()
  {
    if (this._isVideoMedia()) {
      this._resolveVideoSourceForDownload((source) => {
        void this.getDownloadManager()?.downloadImmediate({
          mediaElement: source,
          removeMediaOnSuccess: this._getConfig(MEDIA_REMOVE_ON_SUCCESS),
        })
      })
      return
    }
    let media = this._getMediaElement()
    if (media === null) {
      return
    }
    let removeMediaOnSuccess = this._getConfig(MEDIA_REMOVE_ON_SUCCESS) && this._isPictureMedia()
    void this.getDownloadManager()?.downloadImmediate({mediaElement: media, removeMediaOnSuccess})
  }

  /**
   * Fires `callback` once `#gelcomVideoPlayer source` has a non-empty `src`.
   *
   * @param {function(HTMLSourceElement): void} callback
   * @private
   */
  _resolveVideoSourceForDownload(callback)
  {
    this._waitForDomElement(
        SELECTOR_MEDIA_VIDEO_SOURCE,
        (element) => element instanceof HTMLSourceElement && !!element.src,
        (source) => callback(source),
    )
  }

  /**
   * @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
   * @protected
   */
  _handleDuplicateDownloadSkipped(download)
  {
    // Always tear down capture/watcher on terminal paths (flag may be absent on races).
    if (download.restorePictureOnFailure || _mediaResurrectionObserver || _capturedMediaUrl) {
      _restoreMediaPicture()
    }
  }

  /**
   * @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
   * @protected
   */
  _handleDownloadSucceeded(download)
  {
    if (download.restorePictureOnFailure || _mediaResurrectionObserver || _capturedMediaUrl) {
      _releaseSuppressedMediaCapture()
    }
  }

  /**
   * @param {{name?: string|null, restorePictureOnFailure?: boolean}} download
   * @param {*} error
   * @protected
   */
  _handleDownloadFailed(download, error)
  {
    if (download.restorePictureOnFailure || _mediaResurrectionObserver || _capturedMediaUrl) {
      _restoreMediaPicture()
    }
    super._handleDownloadFailed(download, error)
  }

  /**
   * @param {Document} doc
   * @param {object} ctx
   * @return {Promise<{mediaUrl: string, tagGroups: {}, tagIncidences: Record<string, number>, metadata: {}, downloadId: string|null, data: {}}>}
   * @private
   */
  async _resolvePostFromDocument(doc, ctx)
  {
    let mediaUrl = _getOriginalMediaUrlFromDocument(doc)
    let url = new URL(ctx.sourceUrl ?? location.href)
    let data = {
      id: url.searchParams.get('id') ?? '',
      md5: '',
      ext: '',
      width: '',
      height: '',
      score: '',
      rating: '',
      source: '',
      sourcedomain: '',
    }
    if (mediaUrl) {
      let fileName = mediaUrl.split('?')[0].split('/').pop()
      data.ext = fileName.includes('.') ? fileName.split('.').pop() : ''
      let base = data.ext ? fileName.slice(0, -(data.ext.length + 1)) : fileName
      data.md5 = base.replace(/^sample_/, '')
    }
    doc.querySelectorAll(SELECTOR_STATS + ' li').forEach((element) => {
      let text = this._decodeHtmlEntities(element.textContent?.trim() ?? '')
      let size = text.match(/Size:\s*(\d+)\s*x\s*(\d+)/i)
      if (size) {
        data.width = size[1]
        data.height = size[2]
      }
    })
    let {tagGroups, tagIncidences} = await this._getMediaTagGroupsAndIncidencesAsync(doc)
    return {
      mediaUrl,
      tagGroups,
      tagIncidences,
      metadata: {sourceUrl: ctx.sourceUrl},
      downloadId: data.id || null,
      data,
    }
  }

  /**
   * Tag discovery treats a tag as known only when {@link TagEntry.isDiscovered} is true.
   * Attribute actions may set type for colors without marking discovered.
   *
   * @param {string} tagName
   * @return {Promise<boolean>}
   * @private
   */
  async _isTagKnownForDiscovery(tagName)
  {
    let normalized = this._normalizeTagAtom(tagName)
    if (!normalized) {
      return false
    }
    let tagRuntime = this._configurationManager.getTagRuntime()
    let cached = tagRuntime?.resolveCachedByName?.(normalized)
    if (cached) {
      return cached.isDiscovered === true
    }
    if (!this._configurationManager.canPersist()) {
      return false
    }
    let tag = await this._configurationManager.getRepos().tags.getByName(normalized)
    return !!(tag && tag.isDiscovered === true)
  }

  /**
   * @private
   */
  _setupDownloadManager()
  {
    this.configureDownloadManager({
      enabled: true,
      linkQueues: false,
      resolutionInitiationGapMs: RESOLUTION_INITIATION_GAP_MS,
      downloadInitiationGapMs: DOWNLOAD_INITIATION_GAP_MS,
      downloadPaths: {
        folderConfigKey: DOWNLOAD_FOLDER,
        filenamePatternConfigKey: DOWNLOAD_FILENAME_PATTERN,
        subfolderPatternConfigKey: DOWNLOAD_SUBFOLDER_PATTERN,
        defaultFolder: DEFAULT_DOWNLOAD_FOLDER,
        getPatternResolver: () => this._getDownloadPatternResolver(),
        extractMediaData: (mediaElement) => this._getMediaData(mediaElement),
        extractTagGroups: () => this._getMediaTagGroupsAsync(),
        extractTagIncidences: () => this._getMediaTagIncidencesAsync(),
        appendExtension: true,
        nameFallback: (data) => data.md5 || data.id || 'media',
      },
      pages: {
        home: {
          roles: ['dashboard'],
        },
        search: {
          roles: ['selection', 'tagDiscoveryToggle'],
          defaultDownloadType: 'post',
          itemSelector: SELECTOR_ITEM,
          resolveItem: (item) => {
            let postId = this._getItemPostId(item)
            if (!postId) {
              return null
            }
            let href = item.querySelector(SELECTOR_ITEM_LINK)?.getAttribute('href') ?? ''
            return {
              itemId: String(postId),
              sourceUrl: new URL(href, location.origin).href,
              downloadType: 'post',
            }
          },
        },
        media: {
          roles: ['enqueueMedia', 'immediateDownload', 'tagDiscoveryToggle'],
          defaultDownloadType: 'post',
        },
      },
      downloadTypes: {
        post: {
          resolveFromSearch: (ctx) => ({
            nextUrl: ctx.sourceUrl,
            downloadType: 'post',
          }),
          resolveFromMedia: (doc, ctx) => this._resolvePostFromDocument(doc, ctx),
        },
      },
      rateLimitHandlers: {
        resolution: {
          timedReload: {
            detect: (doc) => this._is429RateLimitDoc(doc),
            delayMs: () => RATE_LIMIT_REFRESH_DELAY_MS,
          },
          humanInteraction: {
            detect: (doc) => this._isCloudflareChallengeDoc(doc),
            openUrl: (ctx) => ctx.resolvingUrl ?? ctx.sourceUrl ?? location.href,
            confirmTitle: 'Cloudflare challenge',
            challengeConfirmMessage:
                'Complete the Cloudflare check on this page, then resume the download queue.',
          },
        },
        download: {
          humanInteraction: {
            detect: (docOrSignal) => {
              if (docOrSignal?.status === 429) {
                return true
              }
              return this._isCloudflareChallengeDoc(docOrSignal?.doc ?? docOrSignal)
            },
            openUrl: (ctx) => ctx.resolvingUrl ?? ctx.sourceUrl ?? location.href,
            confirmTitle: 'Rate limit — verification required',
            challengeConfirmMessage:
                'Complete verification on this page, then resume the download queue.',
          },
        },
      },
      tagDiscovery: this._tagDiscoveryConfig = {
        discoverAt: ['mediaPost'],
        // Synced from filename/subfolder pins — array form so GreasyFork DM cannot scan all types.
        tagTypes: [],
        isTagKnown: async (tagName) => this._isTagKnownForDiscovery(tagName),
        actions: this._getTagAttributeActionsConfig(),
        panel: {
          tagTypes: TAG_DISCOVERY_TYPES,
          groupOrder: [...TAG_TYPES, 'unknown'],
          getGroupKey: (tag) => tag.type ?? 'unknown',
        },
      },
      onBeforeDownload: ({mediaElement, removeMediaOnSuccess}) => {
        let downloadUrl = _getOriginalMediaUrlFromDocument(document)
        if (downloadUrl && _isPreferredMediaUrl(downloadUrl, _capturedMediaUrl)) {
          _capturedMediaUrl = downloadUrl
        }
        let suppressedForDownload = false
        if (removeMediaOnSuccess && mediaElement instanceof HTMLImageElement) {
          _suppressMediaForDownload(mediaElement)
          suppressedForDownload = true
          let captured = _getCapturedMediaElement()
          if (captured) {
            mediaElement = captured
          }
        } else if (removeMediaOnSuccess && mediaElement instanceof HTMLSourceElement) {
          _suppressVideoForDownload(mediaElement)
          suppressedForDownload = true
        }
        return {
          downloadUrl,
          mediaElement,
          restoreOnFailure: suppressedForDownload,
        }
      },
      getQueueItemId: (context) => {
        if (context.itemId) {
          return String(context.itemId)
        }
        let sourceUrl = context.sourceUrl ?? location.href
        let match = String(sourceUrl).match(/[?&]id=(\d+)/)
        return match ? match[1] : (QUERY_PARAMS.get('id') ?? null)
      },
    })
    this._syncDiscoveryTagTypesFromPatterns()

    let dm = this.getDownloadManager()
    if (dm) {
      let toggleSelectionMode = dm.toggleSelectionMode.bind(dm)
      dm.toggleSelectionMode = () => {
        toggleSelectionMode()
        this._syncSelectionItemDragDisabled()
      }
    }
    this._onAfterInitialization.push(() => this._syncSelectionItemDragDisabled())
  }

  /**
   * While download selection mode is on, block native drag on search thumbs
   * so click-to-queue is not stolen by image/link dragging.
   * @private
   */
  _syncSelectionItemDragDisabled()
  {
    let dm = this.getDownloadManager()
    let active = !!(dm?.isDownloadManagerEnabled?.() && dm._isSelectionModeActive?.())
    document.body.classList.toggle('r34-dm-selection', active)
    if (this._selectionDragStartHandler) {
      document.removeEventListener('dragstart', this._selectionDragStartHandler, true)
      this._selectionDragStartHandler = null
    }
    if (!active || !this.isPage('search')) {
      return
    }
    this._selectionDragStartHandler = (event) => {
      if (event.target instanceof Element && event.target.closest(SELECTOR_ITEM)) {
        event.preventDefault()
      }
    }
    document.addEventListener('dragstart', this._selectionDragStartHandler, true)
  }

  /**
   * Navigates to the next search page when no compliant items remain.
   *
   * @private
   */
  _handleSearchAutoNext()
  {
    if (this._getConfig(SEARCH_AUTO_NEXT) && document.querySelectorAll('.' + CLASS_COMPLIANT_ITEM).length === 0) {
      document.querySelector(SELECTOR_PAGINATOR_NEXT)?.click()
    }
  }

  /**
   * Left / right arrow keys navigate to the previous / next search page.
   *
   * @param {KeyboardEvent} event
   * @private
   */
  _handleSearchKeyboardNavigation(event)
  {
    if (event.defaultPrevented || event.ctrlKey || event.altKey || event.metaKey) {
      return
    }
    let target = event.target
    if (target instanceof HTMLElement &&
        (target.isContentEditable || /^(input|textarea|select)$/i.test(target.tagName))) {
      return
    }
    let selector = event.key === 'ArrowLeft' ? SELECTOR_PAGINATOR_BACK
      : event.key === 'ArrowRight' ? SELECTOR_PAGINATOR_NEXT
        : null
    if (!selector) {
      return
    }
    let link = document.querySelector(selector)
    if (link) {
      event.preventDefault()
      link.click()
    }
  }

  // -------------------------------------------------------------------------
  // Bookmarks
  // -------------------------------------------------------------------------

  /**
   * @return {*|null}
   * @private
   */
  _getBookmarksField()
  {
    return this._configurationManager.getField(BOOKMARKS)
  }

  /**
   * @return {boolean}
   * @private
   */
  _bookmarksUseRepository()
  {
    return this._configurationManager.isStorageReady()
  }

  /**
   * @return {Promise<Array>}
   * @private
   */
  async _listBookmarks()
  {
    if (this._bookmarksUseRepository()) {
      return this._configurationManager.getRepos().bookmarks.listAll()
    }
    let field = this._getBookmarksField()
    return field?.value ?? []
  }

  /**
   * @param {Array} bookmarks
   * @return {Promise<void>}
   * @private
   */
  async _renderBookmarks(bookmarks)
  {
    let field = this._getBookmarksField()
    if (!field?.widget) {
      return
    }
    field.widget.render(bookmarks)
    field.widget.checkPageMatch?.()
  }

  /**
   * @private
   */
  async _handleSortBookmarks()
  {
    let field = this._getBookmarksField()
    if (!field) {
      return
    }

    if (this._bookmarksUseRepository()) {
      let repos = this._configurationManager.getRepos().bookmarks
      let bookmarks = await repos.listAll()
      bookmarks.sort((left, right) => left.label.localeCompare(right.label, undefined, {sensitivity: 'base'}))
      for (let index = 0; index < bookmarks.length; index++) {
        bookmarks[index].sortOrder = index
        await repos.update(bookmarks[index])
      }
      return
    }

    field.value.sort((left, right) => left.label.localeCompare(right.label, undefined, {sensitivity: 'base'}))
    await field.save()
  }

  /**
   * @param {number|string} entryId
   * @private
   */
  async _handleRemoveBookmark(entryId)
  {
    let field = this._getBookmarksField()
    if (!field) {
      return
    }

    if (this._bookmarksUseRepository()) {
      let removeId = Number.isFinite(Number(entryId)) ? Number(entryId) : entryId
      await this._configurationManager.getRepos().bookmarks.remove(removeId)
    } else {
      let before = field.value.length
      field.value = field.value.filter((bookmark) => bookmark.id !== entryId &&
          bookmark.entryId !== entryId)
      if (field.value.length === before) {
        return
      }
      await field.save()
    }
    await this._refreshBookmarkPanel()
    this._refreshDockButtonStates()
  }

  /**
   * @private
   */
  async _refreshBookmarkPanel()
  {
    let field = this._getBookmarksField()
    if (!field) {
      return
    }
    await field.reload()
    await this._renderBookmarks(await this._listBookmarks())
  }

  /**
   * @param {string} tags
   * @return {string}
   * @private
   */
  _formatBookmarkLabel(tags)
  {
    return tags.replaceAll('_', ' ')
  }

  /**
   * Returns user-entered search tags only, excluding script default tags and
   * resolution metatags merged into live searches.
   *
   * @return {string}
   * @private
   */
  _getCurrentBookmarkTags()
  {
    let tags = ''
    let input = document.querySelector(SELECTOR_SEARCH_INPUT)
    if (input?.value.trim()) {
      tags = input.value
    } else {
      tags = QUERY_PARAMS.get('tags') ?? ''
    }
    if (!tags.trim()) {
      return ''
    }
    return SEARCH_ADAPTER.engine.subtract(SEARCH_ADAPTER.engine.tokenize(tags), this._getDefaultTokens()).join(' ')
  }

  /**
   * @param {string} tags
   * @return {string}
   * @private
   */
  _buildBookmarkSearchUrl(tags)
  {
    return this._buildSearchListUrl(tags)
  }

  /**
   * Canonical bookmark-list URL used for page-match comparisons.
   *
   * @param {string} url
   * @return {string}
   * @private
   */
  _normalizeBookmarkUrl(url)
  {
    try {
      let parsed = new URL(url, location.origin)
      if (parsed.searchParams.get('page') !== 'post' || parsed.searchParams.get('s') !== 'list') {
        return ''
      }
      let tags = parsed.searchParams.get('tags') ?? ''
      if (!tags.trim()) {
        return ''
      }
      let userTags = SEARCH_ADAPTER.engine.subtract(SEARCH_ADAPTER.engine.tokenize(tags), this._getDefaultTokens()).join(' ')
      if (!userTags.trim()) {
        return ''
      }
      return this._buildBookmarkSearchUrl(userTags)
    } catch (error) {
      return ''
    }
  }

  /**
   * Site incidence options for framework-owned tag attribute actions.
   * Persist toggles notify `tags` with detail — chrome refreshes from onConfigurationChange.
   * Bookmark star still needs a local after hook (bookmarks notify, not tags).
   * @return {object}
   * @private
   */
  _getTagAttributeActionsConfig()
  {
    return {
      className: 'r34-tag-action-btn',
      iconClass: 'r34-tag-action-icon',
      ignoreClassName: 'r34-tag-action-btn r34-tag-action-btn--ignore',
      normalize: (value) => this._normalizeTagAtom(value),
      bookmark: {
        fieldKey: BOOKMARKS,
        buildUrl: (name) => this._buildTagBookmarkUrl(name),
        formatLabel: (tags) => this._formatBookmarkLabel(tags),
        normalizeUrl: (url) => this._normalizeBookmarkUrl(url),
        iconClass: BOOKMARK_STAR_ICON_CLASS,
        onAfterToggle: () => this._refreshTagSidebarActions(),
      },
      ignore: {fieldKey: DOWNLOAD_TAG_IGNORE},
      substitute: {fieldKey: DOWNLOAD_TAG_SUBSTITUTIONS},
      blacklist: {
        fieldKey: FILTER_TAG_BLACKLIST,
        ensureOptionKey: OPTION_ENABLE_TAG_BLACKLIST,
      },
      explore: {
        fieldKey: FILTER_EXPLORED_TAGS,
        ensureOptionKey: OPTION_ENABLE_EXPLORED_TAGS_TRACKER,
      },
      getRowState: (tag) => ({
        muted: this._isTagInIgnoreList(tag.name) || this._isTagSubstitutionSubject(tag.name),
        hidden: this.hasTagSoleAttributeActive(tag.name, {
          fieldKey: FILTER_TAG_BLACKLIST,
          normalize: (value) => this._normalizeTagAtom(value),
        }) || this.hasTagSoleAttributeActive(tag.name, {
          fieldKey: FILTER_EXPLORED_TAGS,
          normalize: (value) => this._normalizeTagAtom(value),
        }),
      }),
      extraMutedClasses: ['r34-tag-muted'],
      extraHiddenClasses: ['r34-tag-hidden'],
    }
  }

  /**
   * @param {boolean} filled
   * @return {SVGSVGElement}
   * @private
   */
  _createBookmarkStarIcon(filled)
  {
    return BrazenViewLayer.createTagBookmarkStarIcon(filled, BOOKMARK_STAR_ICON_CLASS)
  }

  // -------------------------------------------------------------------------
  // Settings panel patches
  // -------------------------------------------------------------------------

  /**
   * @param {string} configKey
   * @return {HTMLElement}
   * @private
   */
  _createPatternGroup(configKey)
  {
    let element = this._configurationManager.createElement(configKey)
    element.classList.add('bv-pattern-group')
    return element
  }

  /**
   * @param {Array} stored
   * @return {{entryId?: number, id?: string, label: string, tags: string, url: string}[]}
   * @private
   */
  _normalizeBookmarksFromStorage(stored)
  {
    if (!Array.isArray(stored)) {
      return []
    }
    return stored.
        map((bookmark) => {
          if (!bookmark || typeof bookmark !== 'object') {
            return null
          }
          let tags = typeof bookmark.tags === 'string' ? bookmark.tags.trim() : ''
          if (!tags.length) {
            return null
          }
          let entryId = bookmark.entryId ?? null
          let id = bookmark.id !== null && bookmark.id !== undefined ? String(bookmark.id).trim() : ''
          let url = typeof bookmark.url === 'string' ? bookmark.url.trim() : ''
          if (!url.length) {
            url = this._buildBookmarkSearchUrl(tags)
          }
          return {
            entryId,
            id: id || undefined,
            label: bookmark.label || this._formatBookmarkLabel(tags),
            tags,
            url,
          }
        }).
        filter(Boolean)
  }

  /**
   * @return {{ok: boolean, reason?: string, filename?: boolean, subfolder?: boolean, builder?: boolean}}
   * @private
   */
  _patchPatternTokenBuilder()
  {
    let filenameEl = this._configurationManager.getField(DOWNLOAD_FILENAME_PATTERN)?.element
    let subfolderEl = this._configurationManager.getField(DOWNLOAD_SUBFOLDER_PATTERN)?.element
    let builder = document.querySelector('#bv-ui .bv-pattern-builder-shared')
    return this._uiGen.patchPatternTokenBuilder(filenameEl, subfolderEl, builder, 'r34pattern')
  }

  /**
   * @return {{ok: boolean, reason?: string}}
   * @private
   */
  _patchPanelResizer()
  {
    let resizer = document.getElementById('bv-resizer')
    let panel = document.getElementById('bv-ui')
    if (!resizer || !panel) {
      return {ok: false, reason: 'panel-missing'}
    }

    if (this._panelResizerMousedownHandler) {
      resizer.removeEventListener('mousedown', this._panelResizerMousedownHandler)
    }

    this._panelResizerMousedownHandler = (event) => {
      event.preventDefault()
      let resizeHorizontal = (moveEvent) => {
        let rect = panel.getBoundingClientRect()
        let resizeFromLeft = panel.classList.contains('bv-dock-panel-right')
        panel.style.width = resizeFromLeft ?
            `${Math.max(150, moveEvent.clientX - rect.left)}px` :
            `${Math.max(150, rect.right - moveEvent.clientX)}px`
      }
      let stopResize = () => {
        removeEventListener('mousemove', resizeHorizontal)
        removeEventListener('mouseup', stopResize)
      }
      addEventListener('mousemove', resizeHorizontal)
      addEventListener('mouseup', stopResize)
    }
    resizer.addEventListener('mousedown', this._panelResizerMousedownHandler)
    return {ok: true}
  }

  /**
   * @param {string} tagName
   * @return {string}
   * @private
   */
  _buildTagBookmarkUrl(tagName)
  {
    return this._buildSearchListUrl(this._normalizeTagAtom(tagName))
  }

  /**
   * @param {HTMLElement} row
   * @return {{name: string, type: string|null}}
   * @private
   */
  _extractSidebarTag(row)
  {
    let name = this._normalizeTagAtom(this._sidebarRowTagName(row) ?? '')
    return {
      name: name ?? '',
      type: this._sidebarRowType(row),
    }
  }

  /**
   * @param {string} name
   * @return {boolean}
   * @private
   */
  _isTagInIgnoreList(name)
  {
    return this._configurationManager.hasTagSoleAttribute(DOWNLOAD_TAG_IGNORE, this._normalizeSimpleTagLine(name))
  }

  /**
   * @param {HTMLElement} row
   * @param {{name: string, type: string|null}} tag
   * @param {HTMLElement} actionsElement
   * @private
   */
  _renderTagSidebarActions(row, tag, actionsElement)
  {
    let config = this._getTagAttributeActionsConfig()
    actionsElement.replaceChildren()
    this.appendTagAttributeActions(actionsElement, tag, config)
    let state = config.getRowState?.(tag) ?? {}
    row.classList.toggle('bv-tag-muted', !!state.muted)
    row.classList.toggle('bv-tag-hidden', !!state.hidden)
    row.classList.toggle('r34-tag-muted', !!state.muted)
    row.classList.toggle('r34-tag-hidden', !!state.hidden)
  }

  /**
   * @param {string} tagName
   * @return {boolean}
   * @private
   */
  _sidebarContainsTag(tagName)
  {
    let target = this._normalizeTagAtom(tagName)
    if (!target) {
      return false
    }
    for (let row of document.querySelectorAll(`${SELECTOR_TAG_SIDEBAR} li[class*="tag-type-"]`)) {
      let tag = this._extractSidebarTag(row)
      if (tag?.name && this._normalizeTagAtom(tag.name) === target) {
        return true
      }
    }
    return false
  }

  /**
   * Gated chrome refresh — only surfaces that reference the changed tags.
   * @param {{tags?: string[], fieldKeys?: string[]}|null} [detail]
   * @private
   */
  _refreshTagActionSurfaces(detail = null)
  {
    this.refreshTagActionSurfaces(detail, {
      sidebarContainsTag: (tag) => this._sidebarContainsTag(tag),
      refreshSidebarRows: (tags) => this._refreshTagSidebarActions(tags),
      refreshBookmarks: () => this._getBookmarksField()?.updateUserInterface?.(),
      refreshDiscovery: (tags) => this.getDownloadManager()?.refreshTagDiscoveryPanel(tags),
      refreshDock: () => this._refreshDockButtonStates(),
    })
  }

  /**
   * @param {string[]|null} [onlyTagNames]
   * @private
   */
  async _setupTagSidebarActions(onlyTagNames = null)
  {
    let generation = ++this._tagSidebarActionsGeneration
    let scopedNames = Array.isArray(onlyTagNames) && onlyTagNames.length ?
        onlyTagNames.map((name) => this._normalizeTagAtom(name)).filter(Boolean) : null
    let paint = () => {
      if (generation !== this._tagSidebarActionsGeneration) {
        return
      }
      let bookmarksField = this._getBookmarksField()
      this._uiGen.enhanceTagSidebarCounts({
        sidebarSelector: SELECTOR_TAG_SIDEBAR,
        rowSelector: 'li[class*="tag-type-"]',
        extractTag: (row) => this._extractSidebarTag(row),
        onlyTagNames: scopedNames,
        renderActions: (row, tag, actionsElement) => {
          this._renderTagSidebarActions(row, tag, actionsElement)
        },
        isBookmarked: (url) => bookmarksField?.widget?.isCurrentPageBookmarked(url) ?? false,
      })
    }
    // Paint immediately so hover actions are clickable; icons heal after ensureNames.
    paint()
    let tagRuntime = this._configurationManager.getTagRuntime()
    if (!tagRuntime) {
      return
    }
    let names = scopedNames ? scopedNames.slice() : []
    if (!scopedNames) {
      for (let row of document.querySelectorAll(`${SELECTOR_TAG_SIDEBAR} li[class*="tag-type-"]`)) {
        let tag = this._extractSidebarTag(row)
        if (tag?.name) {
          names.push(tag.name)
        }
      }
    }
    let needsEnsure = names.filter((name) => !this._configurationManager.getCachedTagEntry(name))
    if (!needsEnsure.length) {
      return
    }
    await tagRuntime.ensureNames(needsEnsure)
    paint()
  }

  /**
   * @param {string[]|null} [onlyTagNames]
   * @private
   */
  _refreshTagSidebarActions(onlyTagNames = null)
  {
    void this._setupTagSidebarActions(onlyTagNames)
  }

  /**
   * @param {string} ruleLabel
   * @param {string} line
   * @return {boolean}
   * @private
   */
  _blacklistRuleLabelMatchesLine(ruleLabel, line)
  {
    let normalizedLabel = this._normalizeBlacklistRuleLine(ruleLabel)
    let rulePart = line
    let commentIdx = line.indexOf(' // ')
    if (commentIdx >= 0) {
      rulePart = line.slice(0, commentIdx)
    }
    let normalizedRulePart = this._normalizeBlacklistRuleLine(rulePart)
    if (normalizedRulePart === normalizedLabel) {
      return true
    }
    if (normalizedRulePart.includes(' | ')) {
      return normalizedRulePart.split(/\s*\|\s*/).includes(normalizedLabel)
    }
    return false
  }

  /**
   * @param {string} fieldKey
   * @param {string} ruleLabel
   * @return {number}
   * @private
   */
  _findTagRuleLineIndexForRuleLabel(fieldKey, ruleLabel)
  {
    let field = this._configurationManager.getField(fieldKey)
    if (!field?.value?.length) {
      return -1
    }

    let index = field.value.findIndex((line) => this._blacklistRuleLabelMatchesLine(ruleLabel, line))
    if (index >= 0) {
      return index
    }

    let normalizedLabel = this._normalizeBlacklistRuleLine(ruleLabel)
    if (!field.onOptimize) {
      return -1
    }

    return field.value.findIndex((line) => {
      let optimized = Utilities.callEventHandler(field.onOptimize, [[line]])
      return optimized?.some((rule) => this._normalizeBlacklistRuleLine(rule.join(' & ')) === normalizedLabel)
    })
  }

  /**
   * @param {string} filterKey
   * @return {boolean}
   * @private
   */
  _isRemovableTagComplianceFilter(filterKey)
  {
    return filterKey === FILTER_TAG_BLACKLIST || filterKey === FILTER_EXPLORED_TAGS
  }

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {boolean}
   * @protected
   */
  _canRemoveComplianceRule(filterKey, ruleLabel)
  {
    if (!this._isRemovableTagComplianceFilter(filterKey)) {
      return false
    }
    return this._findTagRuleLineIndexForRuleLabel(filterKey, ruleLabel) >= 0
  }

  /**
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {Promise<void>}
   * @protected
   */
  async _removeComplianceRule(filterKey, ruleLabel)
  {
    if (!this._isRemovableTagComplianceFilter(filterKey)) {
      return
    }

    let field = this._configurationManager.getField(filterKey)
    if (!field) {
      return
    }

    let lines = [...(field.value ?? [])]
    let index = this._findTagRuleLineIndexForRuleLabel(filterKey, ruleLabel)
    if (index < 0) {
      return
    }

    lines.splice(index, 1)
    // Registry fields ignore setRules+save unless the textarea is dirty — persist via writeTagRuleLines.
    await this._configurationManager.writeTagRuleLines(filterKey, lines)
  }

  /**
   * Color single-token tag blacklist / explore rules only. Non-tag filters stay default text.
   * @param {string} filterKey
   * @param {string} ruleLabel
   * @return {string|null}
   * @protected
   */
  _getComplianceRuleColor(filterKey, ruleLabel)
  {
    if (!this._isRemovableTagComplianceFilter(filterKey)) {
      return null
    }
    let normalizedLabel = this._normalizeBlacklistRuleLine(ruleLabel)
    if (!normalizedLabel || normalizedLabel.includes(' & ')) {
      return null
    }
    let typeName = this._resolveComplianceRuleTagType(normalizedLabel)
    return TAG_DISCOVERY_TYPES[typeName]?.color ?? null
  }

  /**
   * Resolve a single-token rule label to a canonical type name for Active Hide Rules colors.
   * @param {string} normalizedLabel
   * @return {string|null}
   * @private
   */
  _resolveComplianceRuleTagType(normalizedLabel)
  {
    for (let row of document.querySelectorAll(SELECTOR_TAG_SIDEBAR_ROW)) {
      let name = this._sidebarRowTagName(row)
      if (!name) {
        continue
      }
      if (this._normalizeTagAtom(name) !== normalizedLabel) {
        continue
      }
      return this._sidebarRowType(row)
    }
    let cached = this._configurationManager.getCachedTagEntry(normalizedLabel)
    let typeEntryId = cached?.typeEntryId ?? cached?.meta?.lastSeenTypeEntryId ?? null
    if (typeEntryId == null) {
      return null
    }
    return TYPE_ENTRY_ID_TO_TYPE[typeEntryId] ?? null
  }

  /**
   * @private
   */
  _onApplyNewSettings()
  {
    this._configurationManager.update()
    if (this.isPage('search')) {
      this._handleDefaultInjection()
      if (!this._redirecting) {
        this._refreshSearchInputDisplay()
      }
    }
    this._validateCompliance()
    this._refreshTagSidebarActions()
    this._refreshBookmarkPanel()
    this._refreshDockButtonStates()
  }

  // -------------------------------------------------------------------------
  // Setup
  // -------------------------------------------------------------------------

  /**
   * @private
   */
  _setupFeatures()
  {
    this._configurationManager.addBookmarksField(BOOKMARKS).
        setTitle('Bookmarks').
        setHelpText('Save the current search tags as a bookmark.').
        setNormalizeBookmarks((stored) => this._normalizeBookmarksFromStorage(stored)).
        setShowAddButton(false).
        setPageMatch({
          getCurrentUrl: () => location.href,
          normalizeUrl: (url) => this._normalizeBookmarkUrl(url),
          onMatchChange: () => this._refreshDockButtonStates(),
          watchSelectors: [SELECTOR_SEARCH_INPUT],
          isActive: () => this.isPage('search'),
        }).
        setOnSort(() => this._handleSortBookmarks()).
        setOnRemove((id) => this._handleRemoveBookmark(id)).
        setAttributeActionsConfig({
          host: this,
          tokenize: (tags) => SEARCH_ADAPTER.engine.tokenize(tags),
          normalize: (value) => this._normalizeTagAtom(value),
          blacklist: {fieldKey: FILTER_TAG_BLACKLIST},
          explore: {fieldKey: FILTER_EXPLORED_TAGS},
          iconClass: 'r34-tag-action-icon',
          // Persist toggles notify `tags` with detail — gated refresh via onConfigurationChange.
        }).
        reload()

    this._configurationManager.addFlagField(OPTION_ENABLE_DEFAULT_TAGS).
        setTitle('Enable Default Tags').
        setHelpText('Add the default tags below to every search.').
        applyDockTemplate('defaultTags')
    this._configurationManager.addRulesetField(DEFAULTS_TAGS).
        setTitle('Default Tags').
        setRows(3).
        setHelpText('Tags added to every search. One tag or rule per line; the full search grammar is allowed (e.g. <code>sort:score:desc</code>, <code>-futanari</code>, <code>( catgirl ~ foxgirl )</code>).').
        setTranslateFromUI((lines) => lines.map((line) => this._normalizeDefaultTagLine(line)))

    this._configurationManager.addFlagField(OPTION_ENABLE_RESOLUTION).
        setTitle('Enable Resolution Filter').
        setHelpText('Restrict searches to the width / height range below.').
        applyDockTemplate('resolutionFilter')
    this._configurationManager.addRangeField(DEFAULTS_WIDTH, 0, 100000).
        setTitle('Width').
        setHelpText('Only show posts within this width (pixels). Leave a bound at 0 to ignore it.')
    this._configurationManager.addRangeField(DEFAULTS_HEIGHT, 0, 100000).
        setTitle('Height').
        setHelpText('Only show posts within this height (pixels). Leave a bound at 0 to ignore it.')

    this._configurationManager.addFlagField(MEDIA_AUTO_DOWNLOAD).
        setTitle('Auto Download Media').
        setHelpText('Automatically download the media when opening a post page.').
        applyDockTemplate('autoDownload')
    this._configurationManager.addFlagField(MEDIA_REMOVE_ON_SUCCESS).
        setTitle('Remove Media On Download Success').
        setHelpText('Best effort to avoid a second in-page fetch of the media — only the script save runs when suppression wins. With auto-download, the picture or video player is removed even when the post was already saved; it stays on the page only if a new download attempt fails (pictures can be restored on failure).').
        applyDockTemplate('removeMediaOnDownload')

    this._configurationManager.addTextField(DOWNLOAD_FOLDER).
        setTitle('Download Folder').
        setHelpText('Root folder all downloads are saved under. It can be renamed but not removed - clearing it restores the default.').
        setDefault(DEFAULT_DOWNLOAD_FOLDER)
    this._configurationManager.addTextField(DOWNLOAD_FILENAME_PATTERN).
        setTitle('Filename Pattern').
        setHelpText('Tap the chips below to build the saved file name. The file extension is added automatically.').
        setDefault('ID')
    this._configurationManager.addTextField(DOWNLOAD_SUBFOLDER_PATTERN).
        setTitle('Subfolder Pattern').
        setHelpText('Optional — tap chips to add sub-folders inside your download folder. Use / between folder levels. Leave empty for none.')

    this._configurationManager.addRulesetField(DOWNLOAD_TAG_SUBSTITUTIONS).
        setTitle('Filename Tag Substitutions').
        setRows(3).
        setHelpText('Rename tags for filename and subfolder tokens. Use Subject → Alias above (underscores, not spaces). The list below is read-only.').
        setTranslateFromUI((lines) => this._parseDownloadTagSubstitutionLines(lines)).
        setFormatForUI((rules) => {
          if (!Array.isArray(rules)) {
            return []
          }
          return rules.map((rule) => typeof rule === 'string' ? rule : rule.subject + ' → ' + rule.replacement)
        }).
        setOptimize((rules) => {
          let map = new Map()
          for (let rule of Array.isArray(rules) ? rules : []) {
            if (!rule?.subject || !rule?.replacement) {
              continue
            }
            map.set(rule.subject, rule.replacement)
          }
          return [...map.entries()].
              sort((left, right) => left[0].localeCompare(right[0], undefined, {sensitivity: 'base'})).
              map(([subject, replacement]) => ({subject, replacement}))
        }).
        setSortRules(true).
        setSubstitutionComposer({
          normalize: (tag) => this._normalizeTagAtom(tag),
          subjectPlaceholder: 'Subject',
          aliasPlaceholder: 'Alias',
          addHelpText: 'Add Subject → Alias substitution',
        })

    this._configurationManager.addRulesetField(DOWNLOAD_TAG_IGNORE).
        setTitle('Filename Tag Ignore List').
        setRows(3).
        setHelpText('Tags to exclude from tag-type tokens in file names. One tag per line.').
        setTranslateFromUI((lines) => lines.map((line) => this._normalizeSimpleTagLine(line))).
        setSortRules(true)

    this._configurationManager.addFlagField(DOWNLOAD_STRIP_CHARACTER_SERIES).
        setTitle('Strip Series From Character Tags').
        setHelpText('Drop the series suffix from disambiguated character tags (e.g. <code>sciel_(clair_obscur_expedition_33)</code> → <code>sciel</code>) in filename and subfolder tokens.')
    this._configurationManager.addFlagField(SEARCH_AUTO_NEXT).
        setTitle('Auto Next Page').
        setHelpText('Automatically navigate to the next page when every result is filtered out.').
        applyDockTemplate('autoNextPage')

    this._addItemTagAttribute(ITEM_TAGS, false, false, (item) => this._extractItemTags(item))

    this._itemAttributesResolver.addAttribute(ITEM_POST_ID, (item) => this._getItemPostId(item))
  }

  /**
   * @private
   */
  _setupFilters()
  {
    this._addItemTagBlacklistFilter(
        ITEM_TAGS, false, 3,
        'Hide posts with these tags. One tag name per line (underscores, not spaces).<br>' +
        'Combine with <code>&amp;</code> (and) / <code>|</code> (or); add <code>// note</code> for comments.')

    let blacklistField = this._configurationManager.getField(FILTER_TAG_BLACKLIST)
    blacklistField.onTranslateFromUI = (lines) => lines.map((line) => this._normalizeBlacklistRuleLine(line))

    this._addItemTagBlacklistFilter(
        ITEM_TAGS, false, 3,
        'Hide posts with these explored tags while paging through large result sets. One tag name per line (underscores, not spaces).<br>' +
        'Sidebar hide buttons add here when the tracker is enabled. Combine with <code>&amp;</code> (and) / <code>|</code> (or); add <code>// note</code> for comments.',
        FILTER_EXPLORED_TAGS, OPTION_ENABLE_EXPLORED_TAGS_TRACKER, 'exploredTags')

    this._configurationManager.getField(OPTION_ENABLE_EXPLORED_TAGS_TRACKER)?.setTitle('Enable Explored Tags Tracker')
    this._configurationManager.getField(FILTER_EXPLORED_TAGS)?.setTitle('Explored Tags Tracker')

    let exploredField = this._configurationManager.getField(FILTER_EXPLORED_TAGS)
    exploredField.onTranslateFromUI = (lines) => lines.map((line) => this._normalizeBlacklistRuleLine(line))

    this._configurationManager.addFlagField(OPTION_HIDE_OLDER_POSTS).
        setTitle('Hide Older Posts').
        setHelpText('Hide posts with an id below the Last ID value.').
        applyDockTemplate('hideOlderPosts')
    this._configurationManager.addNumberField(LAST_ID, 0, Number.MAX_SAFE_INTEGER).
        setTitle('Last ID').
        setHelpText('Posts with an id below this are hidden. Use the capture button to set it from the current page.')

    this._addItemComplexComplianceFilter(
        OPTION_HIDE_OLDER_POSTS,
        (enabled) => enabled && this._getConfig(LAST_ID) > 0,
        (item) => {
          let id = this._get(item, ITEM_POST_ID)
          let lastId = this._getConfig(LAST_ID)
          if (id === null || id >= lastId) {
            return true
          }
          return {complies: false, rule: 'Post id < ' + lastId}
        })

    // Prefer live post id — cached attributes can miss it after DOM refresh.
    this._addItemHideDownloadedMediaFilter(
        (item) => this._getItemPostId(item) ?? this._get(item, ITEM_POST_ID),
        OPTION_HIDE_DOWNLOADED_MEDIA_HELP,
    )

    this._setupRulesetFieldMatchers()
  }

  /**
   * @private
   */
  _setupDock()
  {
    this.registerCurrentSearchBookmarkDock(DOCK_BOOKMARK_SEARCH, {
      fieldKey: BOOKMARKS,
      getCurrentUrl: () => location.href,
      getTags: () => this._getCurrentBookmarkTags(),
      formatLabel: (tags) => this._formatBookmarkLabel(tags),
      normalizeUrl: (url) => this._normalizeBookmarkUrl(url),
      include: function() { return this.isPage('search') },
    })

    this._configurationManager.getField(MEDIA_AUTO_DOWNLOAD).
        setDockSlideOut([MEDIA_REMOVE_ON_SUCCESS])

    this._configurationManager.addActionField(DOCK_DOWNLOAD_MEDIA).
        setTitle('Download Media').
        setHelpText('Downloads the media on this post page.').
        setAction(() => this._handleDownloadMedia()).
        applyDockTemplate('singleDownload', {
          include: function() {
            return this.isPage('media') && !this._getConfig(MEDIA_AUTO_DOWNLOAD)
          },
        })

    // Framework / registration templates own button bodies; app only sets slide-out children.
    this._configurationManager.getField(OPTION_DISABLE_COMPLIANCE_VALIDATION).
        setDockSlideOut([
          OPTION_ENABLE_DEFAULT_TAGS,
          OPTION_ENABLE_RESOLUTION,
          OPTION_ENABLE_TAG_BLACKLIST,
        ])

    this._configurationManager.addActionField(DOCK_SET_LATEST_ID).
        setTitle('Set Latest ID').
        setHelpText('Use the highest post id on this page as the Last ID.').
        setAction(() => this._handleSetMarkerFromSearch()).
        setDockButton({
          icon: 'target',
          tooltip: 'Capture highest post id as Last ID',
          include: function() { return this.isPage('search') },
        })

    this._configurationManager.addActionField(DOCK_SET_POST_ID).
        setTitle('Set Post ID').
        setHelpText('Use this post id as the Last ID.').
        setAction(() => this._handleSetMarkerFromMedia()).
        setDockButton({
          icon: 'target',
          tooltip: 'Capture this post id as Last ID',
          include: function() { return this.isPage('media') },
        })

    this._configurationManager.getField(OPTION_HIDE_OLDER_POSTS).
        setDockSlideOut([DOCK_SET_LATEST_ID, DOCK_SET_POST_ID]).
        setDockButton({
          include: function() { return !this.isPage('home') },
        })

    // Homepage is a dedicated control shell — keep page-oriented ledger/filter dock
    // controls off here. Framework does not auto-hide these on `dashboard` pages so
    // other consumers can still surface them when home has search/media chrome.
    this._configurationManager.getField(OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER)?.
        setDockButton({
          include: function() {
            return this.isDownloadManagerEnabled() && !this.isPage('home')
          },
        })
    this._configurationManager.getField(OPTION_HIDE_DOWNLOADED_MEDIA)?.
        setDockButton({
          include: function() {
            return this.isDownloadManagerEnabled() && !this.isPage('home')
          },
        })
  }

  /**
   * @param {HTMLElement} railBody
   */
  composeDockRail(railBody)
  {
    this._configurationManager.setDockIncludeContext(this)
    this._configurationManager.clearDockElements()
    this._uiGen.mountDockRail(railBody, this._createDockRailItems())
  }

  /**
   * @return {HTMLElement[]}
   * @private
   */
  _createDockRailItems()
  {
    let items = []
    let pushField = (key) => {
      if (!this._configurationManager.hasField(key)) {
        return
      }
      if (!this._configurationManager.evaluateDockInclude(key)) {
        return
      }
      let node = this._configurationManager.createDockElement(key)
      if (node != null) {
        items.push(node)
      }
    }
    let pushSeparator = (include) => {
      if (include && !include.call(this)) {
        return
      }
      items.push(this._uiGen.createDockSeparator())
    }

    pushField(DOCK_BOOKMARK_SEARCH)
    pushField(MEDIA_AUTO_DOWNLOAD)
    pushField(DOCK_DOWNLOAD_MEDIA)
    pushField(SEARCH_AUTO_NEXT)
    pushSeparator(function() { return this.isDownloadManagerEnabled() })
    pushField('dock-tag-discovery-mode')
    pushField('dock-selection-mode')
    pushField('dock-add-to-queue')
    pushField('dock-download-start-pause')
    pushField(OPTION_ENABLE_DOWNLOAD_DUPLICATE_LEDGER)
    pushSeparator(function() { return this.isDownloadManagerEnabled() })
    pushField(OPTION_DISABLE_COMPLIANCE_VALIDATION)
    pushField(OPTION_ENABLE_EXPLORED_TAGS_TRACKER)
    pushField(OPTION_HIDE_OLDER_POSTS)
    return items
  }

  /**
   * @protected
   * @return {HTMLElement}
   */
  _createSettingsBackupRestoreFormActions()
  {
    let restoreInput = this._uiGen.createFormGroupInput('file')
    restoreInput.id = 'restore-settings'
    restoreInput.placeholder = 'Browse for settings file...'

    return this._uiGen.createFormActions([
      this._uiGen.createFormButton('Backup Configuration', 'Download configuration file.', () => this._onBackupSettings()),
      this._uiGen.createSeparator(),
      restoreInput,
      this._uiGen.createFormButton('Restore Configuration', 'Restore configuration from the selected file.', () => this._onRestoreSettings()),
      this._uiGen.createSeparator(),
      this._uiGen.createFormButton(
          'Clear Download Ledger',
          'Delete all remembered download ids from IndexedDB. Hide Downloaded Media and Skip Duplicate Downloads will treat prior saves as unseen until they are downloaded again.',
          () => this._onClearDownloadLedger(),
      ),
      this._uiGen.createSeparator(),
      this._uiGen.createFormButton(
          'Clear Database',
          'Delete this script’s entire IndexedDB store (settings, bookmarks, download memory, tags, and queues). The page reloads so the database can be rebuilt from scratch.',
          () => this._onClearScriptDatabase(),
      ),
    ], 'bv-flex-column')
  }

  /**
   * @private
   */
  _onClearDownloadLedger()
  {
    if (this._configurationManager.isIdbBlocked()) {
      alert('IndexedDB is unavailable — the download ledger cannot be cleared.')
      return
    }
    if (!this._getDownloadDuplicateLedgerField()) {
      alert('Download ledger is not configured for this script.')
      return
    }
    if (!confirm(
        'Clear the download ledger?\n\n' +
        'This permanently deletes every remembered download id. Previously downloaded posts can be downloaded again, and Hide Downloaded Media will stop hiding them until they are recorded again.',
    )) {
      return
    }
    void this.clearDownloadDuplicateLedger().
        then(() => {
          alert('Download ledger cleared.')
          this._validateCompliance()
        }).
        catch((error) => {
          console.error(error)
          alert('Failed to clear the download ledger. See the browser console for details.')
        })
  }

  /**
   * @private
   */
  _onClearScriptDatabase()
  {
    if (this._configurationManager.isIdbBlocked()) {
      alert('IndexedDB is unavailable — the database cannot be cleared.')
      return
    }
    if (!confirm(
        'Clear this script’s entire database?\n\n' +
        'Settings, bookmarks, download memory, tags, and download queues will be permanently deleted. ' +
        'The page will reload so the script can rebuild an empty database.',
    )) {
      return
    }
    void this.clearScriptDatabase().
        catch((error) => {
          console.error(error)
          alert('Failed to clear the database. See the browser console for details.')
        })
  }

  /**
   * @private
   */
  _setupUI()
  {
    this.configureDock({
      orientations: ['bottom', 'right'],
      defaultOrientation: 'bottom',
      scriptName: 'Rule34.xxx - Search, Download & UI Enhancements',
      showBranding: false,
    })
    if (FORCE_DOCK_MIGRATION_STATUS) {
      this.setForceDockMigrationStatus(true)
    }

    this._setupDownloadManager()

    let statistics = []

    if (this.isPage('search')) {
      statistics = [
        this._uiGen.createStatisticsFormGroup(FILTER_TAG_BLACKLIST, 'Tag Blacklist'),
        this._uiGen.createStatisticsFormGroup(FILTER_EXPLORED_TAGS, 'Explored Tags Tracker'),
        this._uiGen.createStatisticsFormGroup(OPTION_HIDE_OLDER_POSTS, 'Hide Older Posts'),
        this._uiGen.createStatisticsFormGroup(OPTION_HIDE_DOWNLOADED_MEDIA, 'Hide Downloaded Media'),
      ]
    }

    let filtersTab = [
      this._configurationManager.createElement(DEFAULTS_TAGS),
      this._uiGen.createSeparator(),
      this._configurationManager.createElement(OPTION_ENABLE_RESOLUTION),
      this._configurationManager.createElement(DEFAULTS_WIDTH),
      this._configurationManager.createElement(DEFAULTS_HEIGHT),
      this._uiGen.createSeparator(),
      this._configurationManager.createElement(FILTER_TAG_BLACKLIST),
      this._configurationManager.createElement(FILTER_EXPLORED_TAGS),
      this._uiGen.createSeparator(),
      this._configurationManager.createElement(LAST_ID),
    ]

    let globalTab = [
      this._createSettingsBackupRestoreFormActions(),
    ]

    this._userInterface = [
      this._uiGen.createTabsSection(['Bookmarks', 'Filters', 'Downloads', 'Behaviours', 'Toolbox', 'Donate'], [
        Utilities.appendChildren(this._uiGen.createTabPanel('Bookmarks', true),
            this._configurationManager.createElement(BOOKMARKS)),
        Utilities.appendChildren(this._uiGen.createTabPanel('Filters'), filtersTab),
        Utilities.appendChildren(this._uiGen.createTabPanel('Downloads'), [
          this._configurationManager.createElement(OPTION_DOWNLOAD_SELECTION_MODE_DEFAULT),
          this._uiGen.createSeparator(),
          this._createPatternGroup(DOWNLOAD_FOLDER),
          this._createPatternGroup(DOWNLOAD_SUBFOLDER_PATTERN),
          this._createPatternGroup(DOWNLOAD_FILENAME_PATTERN),
          this._uiGen.createSharedPatternTokenBuilder(DOWNLOAD_PATTERN_SECTIONS, DOWNLOAD_PATTERN_SEPARATORS),
          this._uiGen.createSeparator(),
          this._configurationManager.createElement(DOWNLOAD_STRIP_CHARACTER_SERIES),
          this._uiGen.createSeparator(),
          this._configurationManager.createElement(DOWNLOAD_TAG_SUBSTITUTIONS),
          this._configurationManager.createElement(DOWNLOAD_TAG_IGNORE),
        ]),
        this.createBehavioursTabPanel(),
        Utilities.appendChildren(this._uiGen.createTabPanel('Toolbox'), globalTab),
        this.createDonateTabPanel(),
      ]),
      this._uiGen.createBottomSection([
        ...statistics,
        ...(statistics.length ? [this._uiGen.createSeparator()] : []),
        this._createSettingsFormActions(),
      ]),
    ]
  }

  /**
   * @private
   */
  _setupEvents()
  {
    this.onConfigurationChange((event) => {
      this._syncDiscoveryTagTypesFromPatterns()
      let source = event?.source ?? 'all'
      if (source === 'tags') {
        this._refreshTagActionSurfaces(event?.detail ?? null)
      } else if (source === 'bookmarks' || source === 'settings' || source === 'all') {
        this._refreshTagActionSurfaces(null)
      }
      if (this.isPage('search') && !this._redirecting &&
          (source === 'settings' || source === 'all' || source === 'tags')) {
        this._refreshSearchInputDisplay()
      }
    })
    this.onTagSubstitutionUiChange(() => {
      // Link-mode UI has no tags notify yet — refresh visible chrome.
      this._refreshTagActionSurfaces(null)
    })

    this._forPage('search', () => {
      this._onBeforeUIBuild.push(() => {
        this._handleDefaultInjection()
        if (!this._redirecting) {
          this._setupSearchInputHook()
        }
      })
    })

    this._forPage('media', () => {
      this._onBeforeUIBuild.push(() => {
        // Config is loaded; sync pins before auto-download can open Tag Discovery.
        this._syncDiscoveryTagTypesFromPatterns()
        if (this._getConfig(MEDIA_AUTO_DOWNLOAD)) {
          this._handleDownloadMedia()
        }
      })
      window.addEventListener('pagehide', () => _releaseSuppressedMediaCapture())
    })

    this._onAfterUIBuild.push(() => {
      this._syncDiscoveryTagTypesFromPatterns()
      this._setupTagSidebarActions()
      let section = this._uiGen.getSelectedSection()
      if (section) {
        section.userScript = this
      }
      this._patchPanelResizer()
      this._patchPatternTokenBuilder()
      this._uiGen.patchStackedPatternFieldGroups(this._configurationManager, [DOWNLOAD_FOLDER, DOWNLOAD_FILENAME_PATTERN, DOWNLOAD_SUBFOLDER_PATTERN])
      this._uiGen.patchRangeInputLayout()
      if (!this._searchInputHooked) {
        this._setupSearchInputHook()
      }
      if (!this._redirecting) {
        this._refreshSearchInputDisplay()
      }
      this._refreshBookmarkPanel()
    })

    this._forPage('search', () => {
      this._onAfterComplianceRun.push(() => this._handleSearchAutoNext())
      document.addEventListener('keydown', (event) => this._handleSearchKeyboardNavigation(event))

      let resyncHideDownloaded = () => {
        if (document.visibilityState !== 'visible') {
          return
        }
        if (this._hideDownloadedResyncTimer) {
          clearTimeout(this._hideDownloadedResyncTimer)
        }
        this._hideDownloadedResyncTimer = setTimeout(() => {
          this._hideDownloadedResyncTimer = null
          this._validateCompliance()
        }, 400)
      }
      document.addEventListener('visibilitychange', resyncHideDownloaded)
      window.addEventListener('focus', resyncHideDownloaded)
    })
  }
}

/**
 * Seeds rule34xxx IndexedDB documents during first-time setup.
 *
 * @param {BrazenStorageRepositories} repos
 * @param {BrazenConfigurationManager} cm
 * @return {Promise<void>}
 */
async function rule34xxxSetupStorage(repos, cm)
{
  void cm

  let apis = await repos.storage.get(IDB_STORE_APIS, 'apis')
  if (!apis?.entries?.length) {
    await repos.storage.put(IDB_STORE_APIS, {
      id: 'apis',
      entries: [{entryId: 1, name: 'gelbooru', label: 'Gelbooru'}],
    })
  }

  let tagTypes = await repos.storage.get(IDB_STORE_TAG_TYPES, 'tagTypes')
  if (!tagTypes?.entries?.length) {
    await repos.storage.put(IDB_STORE_TAG_TYPES, {
      id: 'tagTypes',
      entries: [
        {entryId: 2, name: 'author', label: 'Artist', sortOrder: 0},
        {entryId: 3, name: 'copyright', label: 'Series', sortOrder: 1},
        {entryId: 4, name: 'character', label: 'Characters', sortOrder: 2},
        {entryId: 5, name: 'meta', label: 'Meta', sortOrder: 3},
        {entryId: 6, name: 'general', label: 'General', sortOrder: 4},
        {entryId: 7, name: 'artist', label: 'Artist', apiEntryId: 1, sidebarClass: 'artist', aliasOfEntryIds: [2]},
      ],
    })
  }
}

/** Build id for Tampermonkey local-script checks (must match this workspace file). */
const RULE34XXX_BUILD = 'local-4.1.0'

void (new Rule34xxxSearchEnhancer).init()