FilterBox/SRxFilterBoxControl.psm1
|
<# ================================================================================ 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) [bool] $DeferredMode = $false [bool] $ReadOnlyMode = $false # Composition simulation - view only [scriptblock] $OnPendingChanged = $null # & $OnPendingChanged $pendingChange (deferred only) [scriptblock] $QueueLookup = $null # & $QueueLookup $xpath -> {Kind;Attributes} | $null hidden [System.Collections.Hashtable] $_cellDiffState = @{} # key -> [bool] differs [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.ToolStripLabel] $_lblKind = $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 = '' hidden [System.Drawing.Font] $_strikeFont = $null 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) # Cosmetic fix: match the fixed 35px header-bar height used by the # left-hand tree panel's own top bar (Explorer.ps1's _toolStripTree, # hosted in a TableLayoutPanel row with RowStyles.Item(0).Height=35). # Left unset, a ToolStrip auto-sizes to a shorter height than that, # so the two header bars looked visually mismatched side-by-side. $this._title.AutoSize = $false $this._title.Height = 35 $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.ImageTransparentColor = [System.Drawing.Color]::Magenta; #$this._btnSave.AutoSize = $false # Cosmetic fix: widened from 24 to 29 to match the button sizing # convention used on the left-hand tree panel's own top bar # (Explorer.ps1's _buttonProperties/_buttonTerminal, both 29x24). $this._btnSave.Size = [System.Drawing.Size]::new(29, 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]::Text $this._btnFilter.AutoSize = $true $this._btnFilter.Text = '' $this._btnFilter.ForeColor = [System.Drawing.SystemColors]::ControlText $this._btnFilter.Font = [System.Drawing.Font]::new('Segoe UI', 9, [System.Drawing.FontStyle]::Regular) $this._btnFilter.TextAlign = [System.Drawing.ContentAlignment]::MiddleLeft $this._btnFilter.AutoToolTip = $false $this._btnFilter.ToolTipText = '' $this._btnFilter.Alignment = [System.Windows.Forms.ToolStripItemAlignment]::Left # Cosmetic fix: widened from 36 to 41 (same +5 adjustment as the # other header buttons here) to match the left-hand tree panel's # button sizing convention (Explorer.ps1's _buttonProperties/ # _buttonTerminal, both 29x24 - this is a split button so it keeps # its own wider base width, just nudged by the same amount). $this._btnFilter.Tag = $this $this._miInclude = [System.Windows.Forms.ToolStripMenuItem]::new() $this._miInclude.Text = 'Include XML Node' $this._miInclude.CheckOnClick = $false $this._miInclude.Tag = $this $this._miInclude.AutoToolTip = $false $this._miInclude.ToolTipText = '' $this._miInclude.add_Click({ param($s, $e) $s.Tag.onSelectFilterKind('Include') }) $this._miExclude = [System.Windows.Forms.ToolStripMenuItem]::new() $this._miExclude.Text = 'Exclude XML Node' $this._miExclude.CheckOnClick = $false $this._miExclude.Tag = $this $this._miExclude.AutoToolTip = $false $this._miExclude.ToolTipText = '' $this._miExclude.add_Click({ param($s, $e) $s.Tag.onSelectFilterKind('Exclude') }) $this._miReset = [System.Windows.Forms.ToolStripMenuItem]::new() $this._miReset.Text = 'Reset Filter' $this._miReset.Tag = $this $this._miReset.AutoToolTip = $false $this._miReset.ToolTipText = '' $this._miReset.add_Click({ param($s, $e) $s.Tag.onSelectFilterKind('Reset') }) [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._lblKind = [System.Windows.Forms.ToolStripLabel]::new('') #$this._lblKind.Name = '_lblKind' #$this._lblKind.Font = [System.Drawing.Font]::new('Segoe UI', 8.25, [System.Drawing.FontStyle]::Bold) #$this._lblKind.ForeColor = [System.Drawing.Color]::FromArgb(0, 102, 0) #$this._lblKind.Visible = $false $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.ImageTransparentColor = [System.Drawing.Color]::Magenta; # $this._btnClose.AutoSize = $false # Cosmetic fix: widened from 24 to 29 to match the button sizing # convention used on the left-hand tree panel's own top bar # (Explorer.ps1's _buttonProperties/_buttonTerminal, both 29x24). $this._btnClose.Size = [System.Drawing.Size]::new(29, 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._titleLabel) [void]$this._title.Items.Add($this._btnFilter) [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) } }) $this._dataGridView.add_EditingControlShowing({ param($s,$e) $ctl = $e.Control if ($null -ne $ctl) { $ctl.Tag = $s.Tag $ctl.add_KeyDown({ param($c,$k) if ($k.KeyCode -eq [System.Windows.Forms.Keys]::Escape -or ($k.Control -and $k.KeyCode -eq [System.Windows.Forms.Keys]::Z)) { $c.Tag.onGridKeyDown($k) } }) $ctl.add_TextChanged({ param($c,$ev) $box = $c.Tag if ($null -ne $box) { $box.onEditorTextChanged([string]$c.Text) } }) } }) # 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)" } } hidden [void] onEditorTextChanged([string]$text) { try { $cell = $this._dataGridView.CurrentCell if ($null -eq $cell) { return } $row = $this._dataGridView.Rows[$cell.RowIndex] if (-not $row.Tag) { return } $differs = ($text -ne [string]$row.Tag.Template) $fore = $(if ($differs) { [System.Drawing.Color]::Red } else { [System.Drawing.Color]::Black }) $cell.Style.ForeColor = $fore $ed = $this._dataGridView.EditingControl if ($null -ne $ed) { $ed.ForeColor = $fore } # colour the live editor too } catch { } } # an ancestor is excluded -> nothing here can ever apply hidden [boolean] isInheritedExcluded() { try { if ($null -eq $this._currentCtx) { return $false } if ($this._currentCtx.PSObject.Properties.Name -contains 'InheritedExcluded') { return [bool]$this._currentCtx.InheritedExcluded } } catch { } return $false } hidden [boolean] isGridEditable() { if ($this.ReadOnlyMode) { return $false } if ($this.isInheritedExcluded()) { return $false } # [R4-3] $pending = $this.getPendingKind() $committed = $this.getCommittedKind() if ($pending -in @('Exclude','Include','Reset')) { return $false } if ($pending -eq 'None' -and $committed -eq 'Include') { return $false } if ($pending -eq 'None' -and $committed -eq 'Exclude') { return $false } return $true } hidden [boolean] isGridStruck() { if ($this.isInheritedExcluded()) { return $true } # [R4-3] $pending = $this.getPendingKind() $committed = $this.getCommittedKind() if ($pending -eq 'Exclude') { return $true } if ($pending -eq 'None' -and $committed -eq 'Exclude') { return $true } return $false } hidden [boolean] isShowingTemplateValues() { if ($this.isInheritedExcluded()) { return $true } # [R4-3] $pending = $this.getPendingKind() return ($pending -in @('Exclude','Include','Reset')) } # 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 @{} } # (overlay the queued change so re-selecting the node restores the view) hidden [object] getActiveCommittedFilter() { $lf = $this.getLayerFilters(); $al = $this.getActiveLayer() $base = $null if ($al -ge 0 -and $lf.ContainsKey($al)) { $base = $lf[$al] } $q = $this.getQueuedChange() if ($null -eq $q) { return $base } # a queued change presents as if it were committed (it is "virtually saved") $kind = [string]$this.cprop($q,'Kind','None') $attrs = $this.cattrs($base) if ($kind -eq 'Modified') { $qa = $this.cprop($q,'Attributes',@{}) if ($qa -is [hashtable]) { foreach ($k in $qa.Keys) { $attrs[[string]$k] = [string]$qa[$k] } } elseif ($null -ne $qa) { foreach ($p in $qa.PSObject.Properties) { $attrs[$p.Name] = [string]$p.Value } } } elseif ($kind -in @('Exclude','Include','Reset')) { $attrs = @{} } return [pscustomobject]@{ Attributes = $attrs Exclude = ($kind -eq 'Exclude') Include = ($kind -eq 'Include') } } 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 = @{} $exclude = $false $include = $false try { $flt = $this.cprop($fn, 'Filter', $null) if ($flt -is [hashtable]) { foreach ($k in @($flt.Keys)) { $key = [string]$k $val = $flt[$k] if ($key -eq 'ts_Deprecated') { if ([string]$val -ieq 'true' -or $val -eq $true) { $exclude = $true } } elseif ($key -eq 'ts_CustomizationTarget') { if ([string]$val -ieq 'true' -or $val -eq $true) { $include = $true } } elseif ($key -like 'ts_*') { } # internal - hide else { $attrs[$key] = [string]$val } } } } catch { Write-Host "filterNodeToLayer - $($_.Exception.Message)" } return [pscustomobject]@{ Attributes = $attrs; Exclude = $exclude; Include = $include } } hidden [void] notifyFiltersChanged() { try { if (-not $this.OnFiltersChanged) { return } if ($null -eq $this._currentCtx) { return } $d = $this.getDraft() $committed = $this.getActiveCommittedFilter() $reset = ($null -ne $d) -and [bool]$this.cprop($d, 'Reset', $false) $ex = $false; $in = $false; $mod = $false if (-not $reset) { if ($null -ne $d) { $ex = [bool]$this.cprop($d, 'Exclude', $false) $in = [bool]$this.cprop($d, 'Include', $false) $da = $this.cattrs($d) if ($da.Count -gt 0) { $mod = $true } } elseif ($null -ne $committed) { $ex = [bool]$this.cprop($committed, 'Exclude', $false) $in = [bool]$this.cprop($committed, 'Include', $false) } if (-not $mod -and $null -ne $committed) { $ca = $this.cattrs($committed) if ($ca.Count -gt 0) { $mod = $true } } # a value typed back to the template default is NOT a modification if ($mod) { $anyDiff = $false foreach ($gr in $this._dataGridView.Rows) { if ($gr.IsNewRow -or -not $gr.Tag) { continue } if ([string]$gr.Cells['Value'].Value -ne [string]$gr.Tag.Template) { $anyDiff = $true; break } } $mod = $anyDiff } } & $this.OnFiltersChanged ([pscustomobject]@{ Node = $this._currentCtx.Token XPath = [string]$this._currentCtx.XPath Exclude = $ex Include = $in Modified = $mod Reset = $reset Draft = $true }) } 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 $this._cellDiffState = @{} [void]$this._dataGridView.Rows.Clear() if ($null -ne $ctx -and $null -ne $ctx.XmlAttributes) { $draft = $this.getDraft() $showTemplate = $this.isShowingTemplateValues() $reset = ($null -ne $draft) -and [bool]$this.cprop($draft, 'Reset', $false) $committedAttrs = @{} if (-not $reset -and -not $showTemplate) { $committedAttrs = $this.cattrs($this.getActiveCommittedFilter()) } $draftAttrs = @{} if (-not $showTemplate) { $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 } } } # capability $editable = $this.isGridEditable() $this._dataGridView.Columns['Value'].ReadOnly = (-not $editable) $this._dataGridView.ReadOnly = (-not $editable) $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 $template = [string]$row.Tag.Template $differs = ($val -ne $template) $key = [string]$row.Tag.Key $this._dataGridView.InvalidateCell($c, $r) # --- draft always tracks the CURRENT value (cheap, no UI work) --- $d = $this.ensureDraft() $d.Exclude = $false; $d.Include = $false; $d.Reset = $false if ($differs) { $d.Attributes[$key] = $val } elseif ($d.Attributes.ContainsKey($key)) { [void]$d.Attributes.Remove($key) } # --- heavy UI refresh only when the match-state FLIPPED [R5-2b] --- $prev = $null if ($this._cellDiffState.ContainsKey($key)) { $prev = [bool]$this._cellDiffState[$key] } $this._cellDiffState[$key] = $differs $flipped = ($null -eq $prev -or $prev -ne $differs) $anyDiff = $false foreach ($gr in $this._dataGridView.Rows) { if ($gr.IsNewRow -or -not $gr.Tag) { continue } if ([string]$gr.Cells['Value'].Value -ne [string]$gr.Tag.Template) { $anyDiff = $true; break } } if ($flipped) { $this.pruneDraft() $this.syncFilterMenuChecks() $this.updateSaveButton() $this.notifyFiltersChanged() } # [BUG-1] ALWAYS re-publish so the queue holds the LATEST value $this.raisePendingIfDeferred($(if ($anyDiff) { 'Modified' } else { 'None' })) } catch { Write-Host "onGridValueChanged - $($_.Exception.Message)" } } hidden [void] onGridKeyDown($e) { try { $isEsc = ($e.KeyCode -eq [System.Windows.Forms.Keys]::Escape) $isUndo = ($e.Control -and ($e.KeyCode -eq [System.Windows.Forms.Keys]::Z)) if (-not $isEsc -and -not $isUndo) { 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 -ne 'Value') { return } # abandon the in-progress edit, then restore the committed value if ($this._dataGridView.IsCurrentCellInEditMode) { $this._dataGridView.CancelEdit() } $row.Cells['Value'].Value = $row.Tag.Committed # triggers onGridValueChanged -> prunes draft $this._dataGridView.InvalidateRow($cell.RowIndex) $this.syncFilterMenuChecks() $this.updateSaveButton() $this.notifyFiltersChanged() $this.raisePendingIfDeferred('None') $e.Handled = $true $e.SuppressKeyPress = $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.isGridStruck()) { if ($null -eq $this._strikeFont) { $this._strikeFont = [System.Drawing.Font]::new($this._dataGridView.Font, [System.Drawing.FontStyle]::Strikeout) } $e.CellStyle.Font = $this._strikeFont $e.CellStyle.ForeColor = [System.Drawing.Color]::FromArgb(160,160,160) 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 { $inherited = $this.isInheritedExcluded() $committed = $this.getCommittedKind() $pending = $this.getPendingKind() $effective = if ($pending -ne 'None') { $pending } else { $committed } $label = switch ($effective) { 'Include' { 'Include XML Node ' } 'Exclude' { 'Exclude XML Node ' } 'Modified' { 'XML Node Attribute ' } 'Reset' { 'Reset ' } default { '' } } if ($inherited) { $label = 'Excluded by parent ' } # [R4-3] $this._btnFilter.Text = $label $this._btnFilter.ForeColor = [System.Drawing.SystemColors]::ControlText $this._btnFilter.Enabled = (-not $inherited) # [R4-3] if ($this.ReadOnlyMode) { $this._btnFilter.Enabled = $false } # radio marks; the CHECKED pending item doubles as Undo [R4-2] $this._miInclude.Checked = ($effective -eq 'Include') $this._miExclude.Checked = ($effective -eq 'Exclude') $this._miReset.Checked = ($effective -eq 'Reset') # an already-saved choice cannot be re-selected, EXCEPT when it is the # pending one (then the check acts as Undo and must stay clickable) $this._miInclude.Enabled = (($pending -eq 'Include') -or ($committed -ne 'Include')) $this._miExclude.Enabled = (($pending -eq 'Exclude') -or ($committed -ne 'Exclude')) $this._miReset.Enabled = (($committed -ne 'None') -or ($pending -ne 'None')) } catch { Write-Host "syncFilterMenuChecks - $($_.Exception.Message)" } } hidden [void] onFilterDropDownOpening() { try { $this.syncFilterMenuChecks() } 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() { if ($this.ReadOnlyMode) { $this._btnSave.Visible = $false; $this._btnSave.Enabled = $false; return } 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) } # [FIX 3] in Deferred mode the Filter Box never saves - the queue does if ($this.DeferredMode -and -not $running) { $this._btnSave.Visible = $false $this._btnSave.Enabled = $false return } $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)" } } # In Deferred mode: hand the pending change to the host RIGHT NOW. hidden [void] raisePendingIfDeferred([string]$kindHint) { try { if (-not $this.DeferredMode) { return } if (-not $this.OnPendingChanged) { return } if ($null -eq $this._currentCtx) { return } $kind = $kindHint if ([string]::IsNullOrEmpty($kind)) { $kind = $this.getPendingKind() } $attrs = @{} if ($kind -eq 'Modified') { foreach ($gr in $this._dataGridView.Rows) { if ($gr.IsNewRow -or -not $gr.Tag) { continue } $v = [string]$gr.Cells['Value'].Value if ($v -ne [string]$gr.Tag.Template) { $attrs[[string]$gr.Tag.Key] = $v } } } & $this.OnPendingChanged ([pscustomobject]@{ XPath = [string]$this._currentCtx.XPath Kind = $kind Attributes = $attrs Node = $this._currentCtx.Token }) } catch { Write-Host "raisePendingIfDeferred - $($_.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)" } } [void] SaveNow() { try { if ($null -eq $this._currentCtx) { return } if (-not $this.nodeHasChanges()) { return } $this.startBranchForNode($this._currentCtx) } catch { Write-Host "SaveNow - $($_.Exception.Message)" } } # one entry point: 'Include' | 'Exclude' | 'Reset' hidden [void] onSelectFilterKind([string]$kind) { try { if ($this.ReadOnlyMode) { return } if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return } # clicking the CHECKED pending kind = Undo if ($kind -eq $this.getPendingKind()) { $this.undoPending(); return } # [BUG-3] Deferred + Reset -> discard the pending work outright if ($kind -eq 'Reset' -and $this.DeferredMode) { $hasCommitted = $false $lf = $this.getLayerFilters(); $al = $this.getActiveLayer() if ($al -ge 0 -and $lf.ContainsKey($al) -and $null -ne $lf[$al]) { $hasCommitted = $true } if (-not $hasCommitted) { $this.undoPending(); return } # nothing saved -> just cancel } $committedKind = $this.getCommittedKind() if ($kind -eq $committedKind) { return } $d = $this.ensureDraft() switch ($kind) { 'Include' { $d.Include = $true; $d.Exclude = $false; $d.Reset = $false; $d.Attributes = @{} } 'Exclude' { $d.Include = $false; $d.Exclude = $true; $d.Reset = $false; $d.Attributes = @{} } 'Reset' { $d.Include = $false; $d.Exclude = $false; $d.Attributes = @{} $d.Reset = ($null -ne $this.getActiveCommittedFilter()) } } $this.pruneDraft() $this.raisePendingIfDeferred($kind) $this.bindNodeToGrid($this._currentCtx) $this.syncFilterMenuChecks() $this.updateSaveButton() $this.notifyFiltersChanged() } catch { Write-Host "onSelectFilterKind - $($_.Exception.Message)" } } # discard the PENDING change only; nothing is written to the termstore hidden [void] undoPending() { try { if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return } $tag = $this._currentCtx.Token.Tag if ($tag.PSObject.Properties.Name -contains 'Draft') { $tag.Draft = $null } $this._cellDiffState = @{} # [BUG-2] tell the host to REMOVE the queued entry BEFORE re-binding, # so getPendingKind() no longer sees it via QueueLookup $this.raisePendingIfDeferred('None') $this.bindNodeToGrid($this._currentCtx) $this.syncFilterMenuChecks() $this.updateSaveButton() $this.notifyFiltersChanged() } catch { Write-Host "undoPending - $($_.Exception.Message)" } } # [R4-4] host reads the pending change to put it on the deferred queue [object] GetPendingChange() { try { if ($null -eq $this._currentCtx) { return $null } $kind = $this.getPendingKind() if ($kind -eq 'None') { return $null } $attrs = @{} if ($kind -eq 'Modified') { foreach ($gr in $this._dataGridView.Rows) { if ($gr.IsNewRow -or -not $gr.Tag) { continue } $v = [string]$gr.Cells['Value'].Value if ($v -ne [string]$gr.Tag.Template) { $attrs[[string]$gr.Tag.Key] = $v } } } return [pscustomobject]@{ XPath = [string]$this._currentCtx.XPath Kind = $kind Attributes = $attrs Node = $this._currentCtx.Token } } catch { Write-Host "GetPendingChange - $($_.Exception.Message)"; return $null } } # [R4-4] drop the draft WITHOUT writing (the queue now owns it) [void] ClearPending() { try { if ($null -eq $this._currentCtx -or $null -eq $this._currentCtx.Token) { return } $tag = $this._currentCtx.Token.Tag if ($tag.PSObject.Properties.Name -contains 'Draft') { $tag.Draft = $null } $this.updateSaveButton() } catch { Write-Host "ClearPending - $($_.Exception.Message)" } } [void] RefreshSaveButton() { $this.updateSaveButton() } # what is COMMITTED at the active layer: 'None'|'Include'|'Exclude'|'Modified' hidden [string] getCommittedKind() { $c = $this.getActiveCommittedFilter() if ($null -eq $c) { return 'None' } if ([bool]$this.cprop($c, 'Exclude', $false)) { return 'Exclude' } if ([bool]$this.cprop($c, 'Include', $false)) { return 'Include' } $a = $this.cattrs($c) if ($a.Count -gt 0) { return 'Modified' } return 'None' } # what is PENDING right now (draft overlay): same vocabulary + 'Reset' hidden [object] getQueuedChange() { try { if (-not $this.QueueLookup) { return $null } if ($null -eq $this._currentCtx) { return $null } $q = & $this.QueueLookup ([string]$this._currentCtx.XPath) if ($null -eq $q) { return $null } $k = [string]$this.cprop($q,'Kind','None') if ([string]::IsNullOrEmpty($k) -or $k -eq 'None') { return $null } return $q } catch { return $null } } hidden [string] getPendingKind() { # 1) a live draft wins (On-Demand, or mid-edit before enqueue) $d = $this.getDraft() if ($null -ne $d) { if ([bool]$this.cprop($d,'Reset',$false)) { return 'Reset' } if ([bool]$this.cprop($d,'Exclude',$false)) { return 'Exclude' } if ([bool]$this.cprop($d,'Include',$false)) { return 'Include' } foreach ($gr in $this._dataGridView.Rows) { if ($gr.IsNewRow -or -not $gr.Tag) { continue } if ([string]$gr.Cells['Value'].Value -ne [string]$gr.Tag.Template) { return 'Modified' } } return 'None' } # 2) otherwise a QUEUED change is still "pending" from the user's view [R5-2/R5-3] $q = $this.getQueuedChange() if ($null -ne $q) { $k = [string]$this.cprop($q,'Kind','None') if (-not [string]::IsNullOrEmpty($k)) { return $k } } return 'None' } # Changing ReadOnlyMode alone does not touch the node already on screen: its # grid editability and the Filter dropdown were decided at bind time. Re-bind # so both are re-evaluated against the new flag. [void] SetReadOnly([bool]$ro) { try { $this.ReadOnlyMode = $ro if ($null -ne $this._currentCtx) { $this.bindNodeToGrid($this._currentCtx) } else { $this.updateSaveButton() } # Q7 - diagnostic, remove once confirmed $editable = $null try { $editable = $this.isGridEditable() } catch { } TR ("FILTERBOX SetReadOnly ro={0} ctx={1} gridEditable={2} filterBtnEnabled={3}" -f ` $ro, ($null -ne $this._currentCtx), $editable, $this._btnFilter.Enabled) } catch { Write-Host "SetReadOnly - $($_.Exception.Message)" } } } function New-SRxFilterBoxControl { [OutputType([object])] param() return [SRxFilterBoxControl]::new() } Export-ModuleMember -Function New-SRxFilterBoxControl |