FilterBox/SRxFilterBoxControl.psm1.txt

<#
================================================================================
  SRxFilterBoxControl.psm1 (Iteration 1 SCAFFOLD)
  -----------------------------------------------------------------------------
  Self-contained WinForms UserControl for the "Filter Box": edits attributes of a
  read-only XML template node and authors customization Filters (change value /
  Exclude node / Include node / Reset), then commits a Term BRANCH to the Termstore
  via an isolated worker (Write-SRxFilterBranch.ps1) - mirroring the Property Box.
 
  MECHANICS (learned from Property Box):
   * Host uses this by COMPOSITION + DYNAMIC DISPATCH (untyped field). No 'using module'.
   * WinForms/Drawing must be loaded BEFORE this module is imported.
   * Create via factory New-SRxFilterBoxControl.
   * Import the logic module (SRxFilterBox.psm1) INSIDE this module (scope isolation).
 
  SIMPLIFIED STATE (architecture doc §6) - per XML tree node .Tag:
     @{ XPath; XmlAttributes(@{key=originalValue}); LayerFilters(@{0..3=layerFilter});
        Draft($null | @{Attributes=@{}; Exclude; Include}) }
  Grid is Key|Value, name-based cells (no numeric indexes). Key readonly; Value red when
  != XmlAttributes[key]. Exclude/Include are node-level toggles; Reset clears active layer.
 
  SCAFFOLD: constructor builds the layout + contract; heavy methods are stubbed with a
  clear TODO marker to fill with the merge/dirty/build-branch logic in the next pass.
================================================================================
#>
 
# Bring the Filter Box logic into THIS module's scope (start/poll/kill/sync/reload +
# the pure Get-SRxEffectiveFilter merge function).
Import-Module (Join-Path $PSScriptRoot 'SRxFilterBox.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue
 
class SRxFilterBoxControl : System.Windows.Forms.UserControl {
 
    # ---- public config ----
    [string] $SourcePath = ''
    [hashtable] $Icons = @{}
 
    # ---- public callbacks (host provides) ----
    [scriptblock] $OnStatus = $null # & $OnStatus $text
    [scriptblock] $OnClose = $null # & $OnClose
    [scriptblock] $OnLayerContext = $null # & $OnLayerContext -> {Scope;Optype;Environment;Design;TermID;ScopeTitle}
    [scriptblock] $OnExecuteEditCustomizations = $null # & $OnExecuteEditCustomizations (host runs Edit-Customizations ruleset)
    [scriptblock] $OnFiltersChanged = $null # & $OnFiltersChanged $filterNodes (host recolors main tree)
    [scriptblock] $OnBuildBranchPack = $null # & $OnBuildBranchPack $ctx $draft $operation -> BranchPack hashtable
 
    # ---- internal UI ----
    hidden [System.Windows.Forms.ToolStrip] $_title = $null
    hidden [System.Windows.Forms.ToolStripLabel] $_titleLabel = $null
    hidden [System.Windows.Forms.ToolStripButton] $_btnSave = $null
    hidden [System.Windows.Forms.ToolStripSplitButton] $_btnFilter = $null
    hidden [System.Windows.Forms.ToolStripMenuItem] $_miExclude = $null
    hidden [System.Windows.Forms.ToolStripMenuItem] $_miInclude = $null
    hidden [System.Windows.Forms.ToolStripMenuItem] $_miReset = $null
    hidden [System.Windows.Forms.ToolStripButton] $_btnClose = $null
    hidden [System.Windows.Forms.DataGridView] $_dataGridView = $null
    hidden [System.Windows.Forms.Panel] $_overlay = $null
    hidden [System.Windows.Forms.Label] $_overlayMsg = $null
    hidden [System.Windows.Forms.Button] $_overlaySave = $null
    hidden [System.Windows.Forms.Button] $_overlayDiscard = $null
    hidden [System.Windows.Forms.Button] $_overlayCancel = $null
 
    # ---- global manager bar (built here, docked by host) ----
    hidden [System.Windows.Forms.Panel] $_globalMgr = $null
    hidden [System.Windows.Forms.Label] $_globalMgrLabel = $null
    hidden [System.Windows.Forms.PictureBox] $_globalMgrKill = $null
    hidden [System.Windows.Forms.PictureBox] $_globalMgrBusy = $null
 
    # ---- engine ----
    hidden [System.Windows.Forms.Timer] $_batchTimer = $null
    hidden [System.Windows.Forms.Timer] $_spinTimer = $null
    hidden [System.Windows.Forms.Timer] $_dbReloadTimer = $null
    hidden [System.Collections.Hashtable] $_activeBatches = @{}
    hidden [boolean] $_suppressGuard = $false
 
    # ---- current context + pending overlay action ----
    hidden [object] $_currentCtx = $null # {XPath; XmlAttributes; DisplayName; Token(Tag byref)}
    hidden [object] $_layer = $null # last OnLayerContext result
    hidden [object] $_pendingTargetCtx = $null
    hidden [string] $_pendingAction = ''
 
    SRxFilterBoxControl() {
        $this.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this.buildLayout()
        $this.buildGlobalManager()
        $this.buildTimers()
    }
 
    # ========================================================== layout
    hidden [void] buildLayout() {
        $this._title = [System.Windows.Forms.ToolStrip]::new()
        $this._title.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._title.GripStyle = [System.Windows.Forms.ToolStripGripStyle]::Hidden
        $this._title.BackColor = [System.Drawing.SystemColors]::ControlLight
        $this._title.ImageScalingSize = [System.Drawing.Size]::new(20, 20)
 
        $this._btnSave = [System.Windows.Forms.ToolStripButton]::new()
        $this._btnSave.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._btnSave.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Left
        $this._btnSave.AutoSize = $false
        $this._btnSave.Size = [System.Drawing.Size]::new(24, 24)
        $this._btnSave.AutoToolTip = $false
        $this._btnSave.Enabled = $false
        $this._btnSave.Visible = $false
        $this._btnSave.Tag = $this
        $this._btnSave.add_Click({ param($s,$e) $s.Tag.onSaveButtonClick() })
 
        # Filter split button: [icon v] Exclude / Include / --- / Reset
        $this._btnFilter = [System.Windows.Forms.ToolStripSplitButton]::new()
        $this._btnFilter.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._btnFilter.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Left
        $this._btnFilter.AutoSize = $false
        $this._btnFilter.Size = [System.Drawing.Size]::new(36, 24)
        $this._btnFilter.Tag = $this
 
        $this._miInclude = [System.Windows.Forms.ToolStripMenuItem]::new()
        $this._miInclude.Text = 'Include XML Node'
        $this._miInclude.CheckOnClick = $true
        $this._miInclude.Tag = $this
        $this._miInclude.add_Click({ param($s,$e) $s.Tag.onToggleInclude([bool]$s.Checked) })
 
        $this._miExclude = [System.Windows.Forms.ToolStripMenuItem]::new()
        $this._miExclude.Text = 'Exclude XML Node'
        $this._miExclude.CheckOnClick = $true
        $this._miExclude.Tag = $this
        $this._miExclude.add_Click({ param($s,$e) $s.Tag.onToggleExclude([bool]$s.Checked) })
 
        $this._miReset = [System.Windows.Forms.ToolStripMenuItem]::new()
        $this._miReset.Text = 'Reset Filter'
        $this._miReset.Tag = $this
        $this._miReset.add_Click({ param($s,$e) $s.Tag.onResetFilter() })
 
        [void]$this._btnFilter.DropDownItems.Add($this._miInclude)
        [void]$this._btnFilter.DropDownItems.Add($this._miExclude)
        [void]$this._btnFilter.DropDownItems.Add('-')
        [void]$this._btnFilter.DropDownItems.Add($this._miReset)
        $this._btnFilter.add_DropDownOpening({ param($s,$e) $s.Tag.onFilterDropDownOpening() })
 
        $this._titleLabel = [System.Windows.Forms.ToolStripLabel]::new('Filter')
 
        $this._btnClose = [System.Windows.Forms.ToolStripButton]::new()
        $this._btnClose.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Right
        $this._btnClose.DisplayStyle = [System.Windows.Forms.ToolStripItemDisplayStyle]::Image
        $this._btnClose.AutoSize = $false
        $this._btnClose.Size = [System.Drawing.Size]::new(24, 24)
        $this._btnClose.AutoToolTip = $false
        $this._btnClose.Tag = $this
        $this._btnClose.add_Click({ param($s,$e) $s.Tag.RequestClose() })
 
        [void]$this._title.Items.Add($this._btnSave)
        [void]$this._title.Items.Add($this._btnFilter)
        [void]$this._title.Items.Add($this._titleLabel)
        [void]$this._title.Items.Add($this._btnClose)
 
        # grid: Key | Value (name-based access)
        $this._dataGridView = [System.Windows.Forms.DataGridView]::new()
        $this._dataGridView.Dock = [System.Windows.Forms.DockStyle]::Fill
        $this._dataGridView.BorderStyle = 'None'
        $this._dataGridView.ColumnHeadersVisible = $false
        $this._dataGridView.RowHeadersVisible = $false
        $this._dataGridView.AllowUserToAddRows = $false
        $this._dataGridView.EditMode = [System.Windows.Forms.DataGridViewEditMode]::EditOnKeystrokeOrF2
 
        $colKey = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
        $colKey.Name = 'Key'; $colKey.HeaderText = 'Attribute'; $colKey.Width = 100; $colKey.ReadOnly = $true
        [void]$this._dataGridView.Columns.Add($colKey)
 
        $colVal = [System.Windows.Forms.DataGridViewTextBoxColumn]::new()
        $colVal.Name = 'Value'; $colVal.HeaderText = 'Value'
        $colVal.AutoSizeMode = [System.Windows.Forms.DataGridViewAutoSizeColumnMode]::Fill
        [void]$this._dataGridView.Columns.Add($colVal)
 
        $this._dataGridView.Tag = $this
        $this._dataGridView.add_CellValueChanged({ param($s,$e) $s.Tag.onGridValueChanged($e.RowIndex, $e.ColumnIndex) })
        $this._dataGridView.add_CellFormatting({ param($s,$e) $s.Tag.onGridFormatting($e) })
        $this._dataGridView.add_KeyDown({ param($s,$e) $s.Tag.onGridKeyDown($e) })
        $this._dataGridView.add_CurrentCellDirtyStateChanged({
            param($s,$e)
            if ($s.IsCurrentCellDirty) { $s.CommitEdit([System.Windows.Forms.DataGridViewDataErrorContexts]::Commit) }
        })
 
        # overlay (child of grid, Dock=Fill; Save/Discard/Cancel) - reused pattern
        $this._overlay = [System.Windows.Forms.Panel]::new()
        $this._overlay.BackColor = [System.Drawing.Color]::FromArgb(250, 250, 250)
        $this._overlay.BorderStyle = [System.Windows.Forms.BorderStyle]::FixedSingle
        $this._overlay.Visible = $false
        $this._overlay.Dock = [System.Windows.Forms.DockStyle]::Fill
 
        $this._overlayMsg = [System.Windows.Forms.Label]::new()
        $this._overlayMsg.AutoSize = $false
        $this._overlayMsg.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._overlayMsg.Location = [System.Drawing.Point]::new(16, 16)
        $this._overlayMsg.Size = [System.Drawing.Size]::new(360, 48)
        $this._overlayMsg.Text = 'You have unsaved filter changes.'
        [void]$this._overlay.Controls.Add($this._overlayMsg)
 
        $this._overlaySave = [System.Windows.Forms.Button]::new()
        $this._overlaySave.Text = 'Save changes'; $this._overlaySave.Size = [System.Drawing.Size]::new(160, 30)
        $this._overlaySave.Location = [System.Drawing.Point]::new(16, 76)
        $this._overlaySave.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._overlaySave.Tag = $this
        $this._overlaySave.add_Click({ param($s,$e) $s.Tag.onOverlaySave() })
        [void]$this._overlay.Controls.Add($this._overlaySave)
 
        $this._overlayDiscard = [System.Windows.Forms.Button]::new()
        $this._overlayDiscard.Text = 'Discard changes'; $this._overlayDiscard.Size = [System.Drawing.Size]::new(160, 30)
        $this._overlayDiscard.Location = [System.Drawing.Point]::new(16, 112)
        $this._overlayDiscard.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._overlayDiscard.Tag = $this
        $this._overlayDiscard.add_Click({ param($s,$e) $s.Tag.onOverlayDiscard() })
        [void]$this._overlay.Controls.Add($this._overlayDiscard)
 
        $this._overlayCancel = [System.Windows.Forms.Button]::new()
        $this._overlayCancel.Text = 'Cancel'; $this._overlayCancel.Size = [System.Drawing.Size]::new(160, 30)
        $this._overlayCancel.Location = [System.Drawing.Point]::new(16, 148)
        $this._overlayCancel.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft
        $this._overlayCancel.Tag = $this
        $this._overlayCancel.add_Click({ param($s,$e) $s.Tag.onOverlayCancel() })
        [void]$this._overlay.Controls.Add($this._overlayCancel)
 
        [void]$this._dataGridView.Controls.Add($this._overlay)
 
        [void]$this.Controls.Add($this._dataGridView)
        [void]$this.Controls.Add($this._title)
    }
 
    hidden [void] buildGlobalManager() {
        $this._globalMgr = [System.Windows.Forms.Panel]::new()
        $this._globalMgr.Height = 30
        $this._globalMgr.Dock = [System.Windows.Forms.DockStyle]::Top
        $this._globalMgr.BackColor = [System.Drawing.Color]::White
        $this._globalMgr.Visible = $false
 
        $this._globalMgrKill = [System.Windows.Forms.PictureBox]::new()
        $this._globalMgrKill.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
        $this._globalMgrKill.Size = [System.Drawing.Size]::new(20, 20)
        $this._globalMgrKill.Location = [System.Drawing.Point]::new(8, 5)
        $this._globalMgrKill.Cursor = [System.Windows.Forms.Cursors]::Hand
        $this._globalMgrKill.Tag = $this
        $this._globalMgrKill.add_Click({ param($s,$e) $s.Tag.killAllBatches() })
        [void]$this._globalMgr.Controls.Add($this._globalMgrKill)
 
        $this._globalMgrBusy = [System.Windows.Forms.PictureBox]::new()
        $this._globalMgrBusy.SizeMode = [System.Windows.Forms.PictureBoxSizeMode]::Zoom
        $this._globalMgrBusy.Size = [System.Drawing.Size]::new(20, 20)
        $this._globalMgrBusy.Location = [System.Drawing.Point]::new(36, 5)
        $this._globalMgrBusy.Visible = $false
        [void]$this._globalMgr.Controls.Add($this._globalMgrBusy)
 
        $this._globalMgrLabel = [System.Windows.Forms.Label]::new()
        $this._globalMgrLabel.AutoSize = $true
        $this._globalMgrLabel.Font = [System.Drawing.Font]::new('Segoe UI', 9)
        $this._globalMgrLabel.Location = [System.Drawing.Point]::new(64, 7)
        $this._globalMgrLabel.Text = 'Filter update in progress'
        [void]$this._globalMgr.Controls.Add($this._globalMgrLabel)
    }
 
    hidden [void] buildTimers() {
        $this._batchTimer = [System.Windows.Forms.Timer]::new()
        $this._batchTimer.Interval = 500
        $this._batchTimer.Tag = $this
        $this._batchTimer.add_tick({
            param($s,$e)
            $ErrorActionPreference = 'Continue'
            try { $s.Tag.pollBatches() }
            catch { Write-Host "TICK batchTimer EXCEPTION: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
        })
 
        $this._spinTimer = [System.Windows.Forms.Timer]::new()
        $this._spinTimer.Interval = 120
        $this._spinTimer.Tag = $this
        $this._spinTimer.add_tick({
            param($s,$e)
            $ErrorActionPreference = 'Continue'
            try { $s.Tag.spinGlobalManager() }
            catch { Write-Host "TICK spinTimer EXCEPTION: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
        })
 
        $this._dbReloadTimer = [System.Windows.Forms.Timer]::new()
        $this._dbReloadTimer.Interval = 750
        $this._dbReloadTimer.Tag = $this
        $this._dbReloadTimer.add_tick({
            param($s,$e)
            $ErrorActionPreference = 'Continue'
            try { $s.Tag.onDbReloadTick() }
            catch { Write-Host "TICK dbReloadTimer EXCEPTION: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
        })
    }
    # ========================================================== public API
    [System.Windows.Forms.Panel] GlobalManagerPanel() { return $this._globalMgr }
 
    [void] ApplyIcons() {
        try {
            if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            if ($this.Icons.ContainsKey('Close') -and $this.Icons['Close']) { $this._btnClose.Image = $this.Icons['Close']; $this._globalMgrKill.Image = $this.Icons['Close'] }
            if ($this.Icons.ContainsKey('Busy') -and $this.Icons['Busy']) { $this._globalMgrBusy.Image = $this.Icons['Busy'] }
            if ($this.Icons.ContainsKey('Filter00') -and $this.Icons['Filter00']) { $this._btnFilter.Image = $this.Icons['Filter00'] }
            $this.updateSaveButton()
        } catch { Write-Host "ApplyIcons - $($_.Exception.Message)" }
    }
 
    # Bind a selected XML template node. ctx.Token is the tree node whose .Tag holds
    # XmlAttributes/LayerFilters/Draft (passed BY REFERENCE).
    [void] BindXmlNode($ctx) {
        $this._currentCtx = $ctx
        if ($this.OnLayerContext) { try { $this._layer = & $this.OnLayerContext } catch { $this._layer = $null } }
        $this.bindNodeToGrid($ctx)
    }
 
    # Host pushes the flat _filterNodes list (from Optimize-SiteTemplate cache) so the
    # component can derive LayerFilters for the bound node.
    [void] SetLayerFilters($filterNodes) {
        try {
            $ctx = $this._currentCtx
            if ($null -eq $ctx -or $null -eq $ctx.Token) { return }
            $xpath = [string]$ctx.XPath
 
            $byScope = @{}
            foreach ($fn in @($filterNodes)) {
                $fxp = [string]$this.cprop($fn, 'xPath', $this.cprop($fn, 'XPath', ''))
                if ($fxp -ne $xpath) { continue }
                $scope = [int]$this.cprop($fn, 'Scope', -1)
                if ($scope -lt 0) { continue }
                $lf = $this.filterNodeToLayer($fn)
                if ($byScope.ContainsKey($scope)) {
                    # merge multiple rows for same node+scope
                    $ex = $byScope[$scope]
                    $mergedAttrs = @{}
                    foreach ($k in (& { $ex.Attributes.Keys })) { $mergedAttrs[$k] = [string]$ex.Attributes[$k] }
                    foreach ($k in (& { $lf.Attributes.Keys })) { $mergedAttrs[$k] = [string]$lf.Attributes[$k] }
                    $byScope[$scope] = [pscustomobject]@{
                        Attributes = $mergedAttrs
                        Exclude = ([bool]$ex.Exclude -or [bool]$lf.Exclude)
                        Include = ([bool]$ex.Include -or [bool]$lf.Include)
                    }
                } else {
                    $byScope[$scope] = $lf
                }
            }
 
            $ctx.Token.Tag | Add-Member -NotePropertyName LayerFilters -NotePropertyValue $byScope -Force
            $this.bindNodeToGrid($ctx)
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
        } catch { Write-Host "SetLayerFilters - $($_.Exception.Message)" }
    }
 
    [bool] HasUnsavedChanges() { return $this.nodeHasChanges() }
 
    [bool] RequestNavigate($targetCtx) {
        try {
            if (-not $this.nodeHasChanges()) { return $true }
            $this._pendingTargetCtx = $targetCtx
            $this._pendingAction = 'switch'
            $this.showOverlay()
            return $false
        } catch { Write-Host "RequestNavigate - $($_.Exception.Message)"; return $true }
    }
 
    [void] RequestClose() {
        try {
            if ($this.nodeHasChanges()) { $this._pendingAction = 'close'; $this.showOverlay() }
            else { $this.doClose() }
        } catch { Write-Host "RequestClose - $($_.Exception.Message)" }
    }
 
    [void] Shutdown() {
        try {
            if ($this._batchTimer) { $this._batchTimer.Stop() }
            if ($this._spinTimer) { $this._spinTimer.Stop() }
            if ($this._dbReloadTimer) { $this._dbReloadTimer.Stop() }
            foreach ($entry in @($this._activeBatches.GetEnumerator())) {
                try { Stop-SRxFilterBranchOperation -Operation $entry.Value.Op | Out-Null } catch {}
            }
            $this._activeBatches.Clear()
        } catch { Write-Host "Shutdown - $($_.Exception.Message)" }
    }
 
    #Region SRxFilterBoxControl.Draft.patch
    # ---- tiny property/attribute accessors (host objects may be hashtable or PSObject) ----
    hidden [object] cprop($o, [string]$n, $d) {
        if ($null -eq $o) { return $d }
        if ($o -is [hashtable]) { if ($o.ContainsKey($n)) { return $o[$n] } else { return $d } }
        $p = $o.PSObject.Properties[$n]; if ($p) { return $p.Value } else { return $d }
    }
    hidden [hashtable] cattrs($o) {
        $a = $this.cprop($o, 'Attributes', @{})
        $h = @{}
        if ($a -is [hashtable]) { foreach ($k in $a.Keys) { $h[$k] = [string]$a[$k] } }
        elseif ($null -ne $a) { foreach ($p in $a.PSObject.Properties) { $h[$p.Name] = [string]$p.Value } }
        return $h
    }
 
    # ---- active layer + committed/draft accessors on the current node ----
    hidden [int] getActiveLayer() {
        if ($null -ne $this._layer) { return [int]$this.cprop($this._layer, 'Scope', -1) }
        return -1
    }
    hidden [hashtable] getLayerFilters() {
        if ($null -ne $this._currentCtx -and $null -ne $this._currentCtx.Token -and $this._currentCtx.Token.Tag) {
            $tag = $this._currentCtx.Token.Tag
            if (($tag.PSObject.Properties.Name -contains 'LayerFilters') -and $tag.LayerFilters) { return [hashtable]$tag.LayerFilters }
        }
        return @{}
    }
    hidden [object] getActiveCommittedFilter() {
        $lf = $this.getLayerFilters(); $al = $this.getActiveLayer()
        if ($al -ge 0 -and $lf.ContainsKey($al)) { return $lf[$al] }
        return $null
    }
    hidden [object] getDraft() {
        if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return $null }
        $tag = $this._currentCtx.Token.Tag
        if ($tag.PSObject.Properties.Name -contains 'Draft') { return $tag.Draft }
        return $null
    }
    hidden [object] ensureDraft() {
        $tag = $this._currentCtx.Token.Tag
        if (-not ($tag.PSObject.Properties.Name -contains 'Draft') -or $null -eq $tag.Draft) {
            $committed = $this.getActiveCommittedFilter()
            $draft = [pscustomobject]@{
                Attributes = @{} # diffs vs committed only
                Exclude = [bool]$this.cprop($committed, 'Exclude', $false)
                Include = [bool]$this.cprop($committed, 'Include', $false)
                Reset = $false
            }
            $tag | Add-Member -NotePropertyName Draft -NotePropertyValue $draft -Force
        }
        return $tag.Draft
    }
    # Drop the draft entirely if it no longer differs from the committed active layer.
    hidden [void] pruneDraft() {
        if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
        $tag = $this._currentCtx.Token.Tag
        if (-not ($tag.PSObject.Properties.Name -contains 'Draft') -or $null -eq $tag.Draft) { return }
        $d = $tag.Draft
        $committed = $this.getActiveCommittedFilter()
        $cEx = [bool]$this.cprop($committed, 'Exclude', $false)
        $cIn = [bool]$this.cprop($committed, 'Include', $false)
        $attrCount = 0; if ($d.Attributes -is [hashtable]) { $attrCount = $d.Attributes.Count }
        if ($attrCount -eq 0 -and [bool]$d.Exclude -eq $cEx -and [bool]$d.Include -eq $cIn -and -not [bool]$d.Reset) {
            $tag.Draft = $null
        }
    }
 
    # Map a raw _filterNodes entry to a layerFilter { Attributes; Exclude; Include }.
    # Defensive about shape (Iteration-1: exact _filterNodes schema still being verified).
    hidden [object] filterNodeToLayer($fn) {
        $attrs = $this.cattrs($fn)
        $exclude = $false; $include = $false
        if ($attrs.ContainsKey('ts_Deprecated')) { $exclude = ([string]$attrs['ts_Deprecated'] -ieq 'true'); [void]$attrs.Remove('ts_Deprecated') }
        if ($attrs.ContainsKey('ts_CustomizationTarget')) { $include = ([string]$attrs['ts_CustomizationTarget'] -ieq 'true'); [void]$attrs.Remove('ts_CustomizationTarget') }
        $exclude = [bool]$this.cprop($fn, 'Deprecated', $exclude)
        $include = [bool]$this.cprop($fn, 'Include', $include)
        $flt = [string]$this.cprop($fn, 'Filter', '')
        if ($flt -ieq 'exclude') { $exclude = $true }
        if ($flt -ieq 'include') { $include = $true }
        # strip other ts_ internals from the visible attribute set
        foreach ($k in @($attrs.Keys)) { if ([string]$k -like 'ts_*') { [void]$attrs.Remove($k) } }
        return [pscustomobject]@{ Attributes = $attrs; Exclude = $exclude; Include = $include }
    }
 
    hidden [void] notifyFiltersChanged() {
        try {
            if (-not $this.OnFiltersChanged) { return }
            $eff = Get-SRxEffectiveFilter -LayerFilters $this.getLayerFilters() -ActiveLayer $this.getActiveLayer() -Draft $this.getDraft()
            & $this.OnFiltersChanged ([pscustomobject]@{ Node = $this._currentCtx.Token; Effective = $eff })
        } catch { Write-Host "notifyFiltersChanged - $($_.Exception.Message)" }
    }
 
    #EndRegion
 
    # ========================================================== callback helpers
    hidden [void] setStatus([string]$text) { try { if ($this.OnStatus) { & $this.OnStatus $text } } catch { } }
    hidden [void] doClose() { try { if ($this.OnClose) { & $this.OnClose } } catch { } }
 
    # ========================================================== TODO stubs (next pass)
    # Fill these using the simplified state model (§6) + Get-SRxEffectiveFilter, and the
    # branch build from buildNodesStack (cleaned up). Stubbed so the scaffold parses & the
    # reap loop / overlay / save-button wiring all work end-to-end from day one.
 
    hidden [void] bindNodeToGrid($ctx) {
        try {
            $this._suppressGuard = $true
            [void]$this._dataGridView.Rows.Clear()
            if ($null -ne $ctx -and $null -ne $ctx.XmlAttributes) {
                $draft = $this.getDraft()
                $reset = ($null -ne $draft) -and [bool]$this.cprop($draft, 'Reset', $false)
                $committedAttrs = if ($reset) { @{} } else { $this.cattrs($this.getActiveCommittedFilter()) }
                $draftAttrs = $this.cattrs($draft)
 
                $srcAttrs = $ctx.XmlAttributes
                $keys = if ($srcAttrs -is [hashtable]) { $srcAttrs.Keys } else { ($srcAttrs.PSObject.Properties | ForEach-Object { $_.Name }) }
                foreach ($k in ($keys | Sort-Object)) {
                    if ([string]$k -like 'ts_*') { continue }
                    $template = [string]$this.cprop($srcAttrs, $k, '')
                    $committedVal = if ($committedAttrs.ContainsKey($k)) { [string]$committedAttrs[$k] } else { $template }
                    $display = if ($draftAttrs.ContainsKey($k)) { [string]$draftAttrs[$k] } else { $committedVal }
 
                    $idx = $this._dataGridView.Rows.Add()
                    $row = $this._dataGridView.Rows[$idx]
                    $row.Cells['Key'].Value = $k
                    $row.Cells['Value'].Value = $display
                    $row.Cells['Key'].ReadOnly = $true
                    $row.Tag = [pscustomobject]@{ Key = $k; Template = $template; Committed = $committedVal }
                }
            }
            $this.syncFilterMenuChecks()
            $this._dataGridView.ClearSelection()
            $this.updateSaveButton()
        } catch { Write-Host "bindNodeToGrid - $($_.Exception.Message)" }
        finally { $this._suppressGuard = $false }
    }
 
    hidden [boolean] nodeHasChanges() {
        if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return $false }
        $tag = $this._currentCtx.Token.Tag
        return (($tag.PSObject.Properties.Name -contains 'Draft') -and $null -ne $tag.Draft)
    }
 
    hidden [void] onGridValueChanged([int]$r, [int]$c) {
        try {
            if ($this._suppressGuard) { return }
            if ($r -lt 0) { return }
            $row = $this._dataGridView.Rows[$r]
            if (-not $row.Tag) { return }
            if ($this._dataGridView.Columns[$c].Name -ne 'Value') { return }
            $val = [string]$row.Cells['Value'].Value
            $d = $this.ensureDraft()
            if ($val -eq [string]$row.Tag.Committed) {
                if ($d.Attributes.ContainsKey($row.Tag.Key)) { [void]$d.Attributes.Remove($row.Tag.Key) }
            } else {
                $d.Attributes[$row.Tag.Key] = $val
            }
            $this.pruneDraft()
            $this._dataGridView.InvalidateRow($r)
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        } catch { Write-Host "onGridValueChanged - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridKeyDown($e) {
        try {
            if ($e.KeyCode -ne [System.Windows.Forms.Keys]::Escape) { return }
            $cell = $this._dataGridView.CurrentCell
            if ($null -eq $cell) { return }
            $row = $this._dataGridView.Rows[$cell.RowIndex]
            if (-not $row.Tag) { return }
            if ($this._dataGridView.Columns[$cell.ColumnIndex].Name -eq 'Value') {
                $this._dataGridView.CancelEdit()
                $row.Cells['Value'].Value = $row.Tag.Committed # triggers onGridValueChanged -> prunes draft
                $this._dataGridView.InvalidateRow($cell.RowIndex)
                $e.Handled = $true
            }
        } catch { Write-Host "onGridKeyDown - $($_.Exception.Message)" }
    }
 
    hidden [void] onGridFormatting($e) {
        try {
            if ($e.RowIndex -lt 0) { return }
            $row = $this._dataGridView.Rows[$e.RowIndex]
            if (-not $row.Tag) { return }
            if ($this._dataGridView.Columns[$e.ColumnIndex].Name -eq 'Value') {
                if ([string]$row.Cells['Value'].Value -ne [string]$row.Tag.Template) { $e.CellStyle.ForeColor = [System.Drawing.Color]::Red }
                else { $e.CellStyle.ForeColor = [System.Drawing.Color]::Black }
            }
        } catch { Write-Host "onGridFormatting - $($_.Exception.Message)" }
    }
 
    hidden [void] syncFilterMenuChecks() {
        try {
            $this._suppressGuard = $true
            $committed = $this.getActiveCommittedFilter()
            $draft = $this.getDraft()
            $reset = ($null -ne $draft) -and [bool]$this.cprop($draft, 'Reset', $false)
            $ex = $false; $in = $false
            if (-not $reset -and $null -ne $committed) {
                $ex = [bool]$this.cprop($committed, 'Exclude', $false)
                $in = [bool]$this.cprop($committed, 'Include', $false)
            }
            if ($null -ne $draft) {
                $ex = [bool]$this.cprop($draft, 'Exclude', $false)
                $in = [bool]$this.cprop($draft, 'Include', $false)
            }
            $this._miExclude.Checked = $ex
            $this._miInclude.Checked = $in
        } catch { Write-Host "syncFilterMenuChecks - $($_.Exception.Message)" }
        finally { $this._suppressGuard = $false }
    }
    hidden [void] onFilterDropDownOpening() {
        try {
            $hasCommitted = ($null -ne $this.getActiveCommittedFilter())
            $hasDraft = ($null -ne $this.getDraft())
            $this._miReset.Enabled = ($hasCommitted -or $hasDraft)
            $enabled = ($null -ne $this._currentCtx)
            $this._miInclude.Enabled = $enabled
            $this._miExclude.Enabled = $enabled
        } catch { Write-Host "onFilterDropDownOpening - $($_.Exception.Message)" }
    }
    hidden [void] onToggleExclude([bool]$on) {
        try {
            if ($this._suppressGuard) { return }
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $d = $this.ensureDraft()
            $d.Exclude = $on
            if ($on) { $d.Include = $false }
            $d.Reset = $false
            $this.pruneDraft()
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        } catch { Write-Host "onToggleExclude - $($_.Exception.Message)" }
    }
    hidden [void] onToggleInclude([bool]$on) {
        try {
            if ($this._suppressGuard) { return }
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $d = $this.ensureDraft()
            $d.Include = $on
            if ($on) { $d.Exclude = $false }
            $d.Reset = $false
            $this.pruneDraft()
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        } catch { Write-Host "onToggleInclude - $($_.Exception.Message)" }
    }
    hidden [void] onResetFilter() {
        try {
            if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return }
            $committed = $this.getActiveCommittedFilter()
            if ($null -eq $committed) {
                # nothing saved to reset -> just drop any pending draft
                $tag = $this._currentCtx.Token.Tag
                if ($tag.PSObject.Properties.Name -contains 'Draft') { $tag.Draft = $null }
            } else {
                $d = $this.ensureDraft()
                $d.Attributes = @{}
                $d.Exclude = $false
                $d.Include = $false
                $d.Reset = $true
            }
            $this.bindNodeToGrid($this._currentCtx)
            $this.syncFilterMenuChecks()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        } catch { Write-Host "onResetFilter - $($_.Exception.Message)" }
    }
 
    hidden [void] updateSaveButton() {
        try {
            $running = $false
            if ($null -ne $this._currentCtx -and $null -ne $this._currentCtx.Token -and $this._currentCtx.Token.Tag) {
                $tag = $this._currentCtx.Token.Tag
                $running = (($tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $tag.FilterUpdate)
            }
            $hasChanges = $this.nodeHasChanges()
            if ($hasChanges) {
                $this._btnSave.Visible = $true; $this._btnSave.Enabled = $true
                if ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            }
            elseif ($running) {
                $this._btnSave.Visible = $true; $this._btnSave.Enabled = $true
                if ($this.Icons.ContainsKey('Stop') -and $this.Icons['Stop']) { $this._btnSave.Image = $this.Icons['Stop'] }
                elseif ($this.Icons.ContainsKey('Save') -and $this.Icons['Save']) { $this._btnSave.Image = $this.Icons['Save'] }
            }
            else {
                $this._btnSave.Visible = $false; $this._btnSave.Enabled = $false
            }
        } catch { Write-Host "updateSaveButton - $($_.Exception.Message)" }
    }
 
    hidden [void] onSaveButtonClick() {
        try {
            $ctx = $this._currentCtx
            if ($null -eq $ctx) { return }
            if ($this.nodeHasChanges()) {
                $this.startBranchForNode($ctx)
            }
            elseif ($null -ne $ctx.Token -and ($ctx.Token.Tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $ctx.Token.Tag.FilterUpdate) {
                $this.killNodeBatch($ctx)
            }
        } catch { Write-Host "onSaveButtonClick - $($_.Exception.Message)" }
    }
 
    hidden [void] startBranchForNode($ctx) {
        try {
            if ($null -eq $ctx -or $null -eq $ctx.Token) { return }
            $draft = $this.getDraft()
            if ($null -eq $draft) { return }
 
            $operation = $this.getFilterOperation($draft)
 
            if (-not $this.OnBuildBranchPack) {
                $this.setStatus("Cannot save: branch-pack builder not wired (OnBuildBranchPack).")
                return
            }
            $pack = & $this.OnBuildBranchPack $ctx $draft $operation
            if ($null -eq $pack) {
                $this.setStatus("Cannot save '$($ctx.DisplayName)': empty branch pack.")
                return
            }
 
            $op = Start-SRxFilterBranchOperation -Pack ([hashtable]$pack) -SourcePath $this.SourcePath -HideWindow
            if ($op.StartError) { $this.setStatus("Filter save failed to start: $($op.StartError)"); return }
 
            $ctx.Token.Tag | Add-Member -NotePropertyName FilterUpdate -NotePropertyValue $op -Force
            $this._activeBatches[[string]$op.ProcessId] = @{ Ctx = $ctx; Op = $op; Operation = $operation }
 
            # optimistic: reflect saved state now; clear the draft (a new edit re-creates it)
            $this.applyDraftToLayer($ctx, $draft, $operation)
            if ($ctx.Token.Tag.PSObject.Properties.Name -contains 'Draft') { $ctx.Token.Tag.Draft = $null }
            $this.bindNodeToGrid($ctx)
 
            $this.setStatus("Filter save started for '$($ctx.DisplayName)' (PID $($op.ProcessId)).")
            if (-not $this._batchTimer.Enabled) { $this._batchTimer.Start() }
            $this.updateGlobalManager()
            $this.updateSaveButton()
            $this.notifyFiltersChanged()
        } catch { Write-Host "startBranchForNode - $($_.Exception.Message)" }
    }
 
    hidden [void] pollBatches() {
        $ErrorActionPreference = 'Continue'
        $stage = 'enter'
        try {
            if ($this._activeBatches.Count -eq 0) { $this._batchTimer.Stop(); $this.updateGlobalManager(); return }
            $done = @()
            $finishedXPaths = @()
            foreach ($entry in @($this._activeBatches.GetEnumerator())) {
                $key = $entry.Key
                $rec = $entry.Value
                $ctx = $rec.Ctx
                $op = $rec.Op
 
                $stage = "getState:$key"
                $st = $null
                try { $st = Get-SRxFilterBranchOperationState -Operation $op }
                catch { Write-Host "pollBatches getState EXC: $($_.Exception.Message)"; continue }
                if ($null -eq $st) { continue }
                if ($st.State -eq 'Running') { continue }
 
                if ($st.State -eq 'Completed' -and $st.Success) {
                    $stage = "callback:$key"
                    if ($st.Data -and $st.Data.FilterNodes -and $this.OnFiltersChanged) {
                        try { & $this.OnFiltersChanged ([pscustomobject]@{ Node = $ctx.Token; FilterNodes = $st.Data.FilterNodes; Committed = $true }) }
                        catch { Write-Host "pollBatches OnFiltersChanged EXC: $($_.Exception.Message)" }
                    }
                    if ($st.Data -and $st.Data.SyncWarning) { $this.setStatus("Saved filter for '$($ctx.DisplayName)' (note: $($st.Data.SyncWarning))") }
                    else { $this.setStatus("Saved filter for '$($ctx.DisplayName)'.") }
                    $stage = "scheduleReload:$key"
                    $this.scheduleDbReload()
                }
                elseif ($st.State -eq 'Conflict') {
                    $this.setStatus("Filter conflict for '$($ctx.DisplayName)' - remote changed (merge in Iteration 2).")
                }
                else {
                    $this.setStatus("Filter save failed for '$($ctx.DisplayName)': $($st.Error)")
                }
 
                $stage = "cleanup:$key"
                try { Remove-SRxFilterBranchTempFiles -Operation $op } catch { Write-Host "pollBatches cleanup EXC: $($_.Exception.Message)" }
                if (($ctx.Token.Tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $ctx.Token.Tag.FilterUpdate -and `
                    ([string]$ctx.Token.Tag.FilterUpdate.ProcessId -eq [string]$op.ProcessId)) {
                    $ctx.Token.Tag.FilterUpdate = $null
                }
                $done += $key
                $finishedXPaths += [string]$ctx.XPath
            }
            foreach ($k in $done) { $this._activeBatches.Remove($k) }
 
            $stage = 'rebind'
            $sel = $this._currentCtx
            if ($null -ne $sel -and ($finishedXPaths -contains [string]$sel.XPath)) {
                if (-not $this._dataGridView.IsCurrentCellInEditMode) { $this.bindNodeToGrid($sel) }
                else { $this.updateSaveButton() }
            } else {
                $this.updateSaveButton()
            }
 
            if ($this._activeBatches.Count -eq 0) { $this._batchTimer.Stop(); $this.setStatus('Ready') }
            $this.updateGlobalManager()
        }
        catch { Write-Host "pollBatches EXC at stage [$stage]: $($_.Exception.GetType().FullName): $($_.Exception.Message)" }
    }
 
    hidden [void] scheduleDbReload() { } # Iteration 1: disabled (races worker's SRx session log)
 
    hidden [void] onDbReloadTick() {
        try { if ($this._dbReloadTimer) { $this._dbReloadTimer.Stop() } } catch { }
    }
    hidden [void] killAllBatches() {
        try { foreach ($entry in @($this._activeBatches.GetEnumerator())) { try { Stop-SRxFilterBranchOperation -Operation $entry.Value.Op | Out-Null } catch {} } ; $this.setStatus("Killed all running filter batches.") }
        catch { Write-Host "killAllBatches - $($_.Exception.Message)" }
    }
 
    hidden [void] updateGlobalManager() {
        try {
            $active = ($this._activeBatches.Count -gt 0)
            $this._globalMgr.Visible = $active
            if ($null -ne $this._globalMgrBusy) { $this._globalMgrBusy.Visible = $active }
            if ($active) { $this._globalMgrLabel.Text = "Filter update in progress ($($this._activeBatches.Count))"; if (-not $this._spinTimer.Enabled) { $this._spinTimer.Start() } }
            else { if ($this._spinTimer.Enabled) { $this._spinTimer.Stop() } }
        } catch { Write-Host "updateGlobalManager - $($_.Exception.Message)" }
    }
    hidden [void] spinGlobalManager() { try { $this._globalMgrLabel.Text = "Filter update in progress ($($this._activeBatches.Count))" } catch { } }
 
    # ---- overlay (reused pattern) ----
    hidden [void] showOverlay() {
        try {
            $name = ''
            if ($null -ne $this._currentCtx) { $name = [string]$this._currentCtx.DisplayName }
            $this._overlayMsg.Text = "You have unsaved filter changes for '$name'.`r`nWhat would you like to do?"
            if ($this.Icons['Save']) { $this._overlaySave.Image = $this.Icons['Save'] }
            $this._overlay.Dock = [System.Windows.Forms.DockStyle]::Fill
            $this._overlay.Visible = $true
            $this._overlay.BringToFront()
        } catch { Write-Host "showOverlay - $($_.Exception.Message)" }
    }
    hidden [void] hideOverlay() { try { $this._overlay.Visible = $false } catch { } }
    hidden [void] finishPendingAction() {
        try {
            $action = $this._pendingAction; $this._pendingAction = ''
            if ($action -eq 'close') { $this.doClose() }
            # switch case is host-driven for the customizer tree; left as TODO if needed
            $this._pendingTargetCtx = $null
        } catch { Write-Host "finishPendingAction - $($_.Exception.Message)" }
    }
    hidden [void] onOverlaySave() {
        try { $this.hideOverlay(); if ($null -ne $this._currentCtx) { $this.startBranchForNode($this._currentCtx) } ; $this.finishPendingAction() }
        catch { Write-Host "onOverlaySave - $($_.Exception.Message)" }
    }
    hidden [void] onOverlayDiscard() {
        try {
            if ($null -ne $this._currentCtx -and $null -ne $this._currentCtx.Token -and $this._currentCtx.Token.Tag) {
                if ($this._currentCtx.Token.Tag.PSObject.Properties.Name -contains 'Draft') { $this._currentCtx.Token.Tag.Draft = $null }
            }
            $this.hideOverlay(); $this.bindNodeToGrid($this._currentCtx); $this.finishPendingAction()
        } catch { Write-Host "onOverlayDiscard - $($_.Exception.Message)" }
    }
    hidden [void] onOverlayCancel() { try { $this._pendingTargetCtx = $null; $this._pendingAction = ''; $this.hideOverlay() } catch { } }
 
    # ---- SRxFilterBoxControl.SavePath.patch ----
    hidden [string] getFilterOperation($draft) {
        # Reset => delete the active-layer customizations; everything else is an upsert.
        if ([bool]$this.cprop($draft, 'Reset', $false)) { return 'remove' }
        return 'add'
    }
 
    # Optimistically fold the draft into LayerFilters[activeLayer] so the UI reflects the
    # saved state immediately; the worker confirms + syncs the cache asynchronously.
    hidden [void] applyDraftToLayer($ctx, $draft, [string]$operation) {
        try {
            $tag = $ctx.Token.Tag
            $lf = @{}
            if (($tag.PSObject.Properties.Name -contains 'LayerFilters') -and $tag.LayerFilters) { $lf = [hashtable]$tag.LayerFilters }
            $al = $this.getActiveLayer()
            if ($al -lt 0) { return }
            if ($operation -eq 'remove') {
                if ($lf.ContainsKey($al)) { [void]$lf.Remove($al) }
            } else {
                $committed = $null; if ($lf.ContainsKey($al)) { $committed = $lf[$al] }
                $attrs = $this.cattrs($committed)
                foreach ($k in @($draft.Attributes.Keys)) { $attrs[$k] = [string]$draft.Attributes[$k] }
                $lf[$al] = [pscustomobject]@{ Attributes = $attrs; Exclude = [bool]$draft.Exclude; Include = [bool]$draft.Include }
            }
            $tag | Add-Member -NotePropertyName LayerFilters -NotePropertyValue $lf -Force
        } catch { Write-Host "applyDraftToLayer - $($_.Exception.Message)" }
    }
 
    hidden [void] killNodeBatch($ctx) {
        try {
            if ($null -eq $ctx -or $null -eq $ctx.Token) { return }
            $tag = $ctx.Token.Tag
            if (($tag.PSObject.Properties.Name -contains 'FilterUpdate') -and $tag.FilterUpdate) {
                [void](Stop-SRxFilterBranchOperation -Operation $tag.FilterUpdate)
                $this.setStatus("Killed filter save for '$($ctx.DisplayName)'.")
            }
        } catch { Write-Host "killNodeBatch - $($_.Exception.Message)" }
    }
 
}
 
function New-SRxFilterBoxControl {
    [OutputType([object])]
    param()
    return [SRxFilterBoxControl]::new()
}
 
Export-ModuleMember -Function New-SRxFilterBoxControl