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 |