TreeControl/SRxSchemaTreeControl.ps1
|
<# ================================================================================ SRxSchemaTreeControl.psm1 STEP 3 of the tree extraction ----------------------------------------------------------------------------- The PROVISIONING SCHEMA wrapper around the generic SRxTreeControl. SEPARATION (per the Step-1 finding - two trees, two identity models): GENERIC control : Id / ParentId / Text / Tag (no semantics) THIS wrapper : term GUID identity + ts_* role markers + grouping XML tree wrapper : xPath identity + element names (Step 4, later) WHAT MOVES HERE FROM Explorer.ps1 * building the model from the profile XML (was addNodesToTreeview / addChildNodesToTreeNode mutating TreeNodes directly) * ts_Group header synthesis * role / id accessors (Step 1) now operating on the MODEL, not TreeNodes * scope + customization colouring as OVERLAYS instead of ForeColor loops WHAT STAYS IN Explorer.ps1 * where the profile XML comes from, context menus, command lifecycle, panel layout, and every existing public method name. FIXES CARRIED IN (found while reading the real source) * `$line[1]` indexing a STRING when no "--" separator exists * `[boolean]"false"` being $true -> explicit -eq 'true' * missing ID attribute no longer produces an unaddressable node REQUIRES: WinForms/Drawing loaded BEFORE Import-Module. SRxTreeControl.psm1 beside this file. ================================================================================ #> class SRxSchemaTreeControl : SRxTreeControl { # role markers a term may carry to declare what it represents hidden [string[]] $_roleKeys = @( 'ts_KeyProvisioning','ts_TermGroup','ts_Site','ts_Sites', 'ts_SubSite','ts_SubSites','ts_Design','ts_Environment' ) [bool] $ShowCustomizations = $false [bool] $Verbose = $false SRxSchemaTreeControl() : base() { $this.SetSideBorder( [SRxBorderSides]::Left, [System.Drawing.SystemColors]::ControlDark, [float]1.0 ) # Reserve one pixel so a DockStyle.Fill child does not cover the border. $this.Padding = [System.Windows.Forms.Padding]::new(1, 0, 0, 0) } # ======================================================== MODEL BUILD # Rebuild the whole model from the profile XML, then sync the view. # $xmlRoot : the <pnp:...> document element (as Explorer.loadTreeView has it) [void] LoadFromXml($xmlRoot) { try { $this.ClearModel() if ($null -eq $xmlRoot) { $this.SyncToUI(); return } $this.addXmlChildren($xmlRoot, '') $this.SyncToUI() } catch { Write-Host "SRxSchemaTreeControl.LoadFromXml - $($_.Exception.Message)" } } # Recursive model builder. Mirrors the ORDER and FILTERING of the original # addChildNodesToTreeNode, but writes to the MODEL - never to TreeNodes. hidden [void] addXmlChildren($xElement, [string]$parentId) { foreach ($xEl in $xElement.ChildNodes) { if ($xEl.LocalName -eq 'CustomProperties') { continue } # same filter list as the original $treeFilter = @('Operations') if (-not $this.ShowCustomizations) { $treeFilter += 'Customizations' } $nameAttr = '' try { if ($null -ne $xEl.Attributes['Name']) { $nameAttr = [string]$xEl.Attributes['Name'].Value } } catch { } if ($nameAttr -in $treeFilter) { continue } # TermSets / Terms are structural - descend without creating a node if ($xEl.LocalName -in @('TermSets','Terms')) { if ($xEl.HasChildNodes) { $this.addXmlChildren($xEl, $parentId) } continue } # ---- identity ---- <# $id = '' try { if ($null -ne $xEl.Attributes['ID']) { $id = [string]$xEl.Attributes['ID'].Value } } catch { } if ([string]::IsNullOrEmpty($id)) { # never create an unaddressable node - synthesise a stable key $id = ('syn:{0}:{1}' -f $parentId, $xElement.ChildNodes.IndexOf($xEl)) } #> $id = '' try { if ($null -ne $xEl.Attributes['ID']) { $id = [string]$xEl.Attributes['ID'].Value } } catch { } if ([string]::IsNullOrEmpty($id)) { # XmlNodeList has no IndexOf - count position manually $pos = 0 for ($k = 0; $k -lt $xElement.ChildNodes.Count; $k++) { if ($xElement.ChildNodes[$k] -eq $xEl) { $pos = $k; break } } $id = ('syn:{0}:{1}' -f $parentId, $pos) } # ---- display text ---- $text = $nameAttr if ([string]::IsNullOrEmpty($text)) { $text = [string]$xEl.LocalName if ($text -eq 'Property') { try { $text = [string]$xEl.Attributes['Key'].Value } catch { } } } # FIX: @() forces ARRAY semantics - indexing a bare string threw # "argument out of range" for short names with no "--" separator. $line = @($text -split '--') $title = [string]$line[0] $tooltip = $null if ($line.Count -gt 1) { $tooltip = [string]$line[1] [datetime]$logDate = New-Object DateTime if ([DateTime]::TryParseExact($tooltip, 'o', [System.Globalization.CultureInfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::None, [ref]$logDate)) { $tooltip = $logDate.ToString('yyyy-MM-dd HH:mm:ss') } } # FIX: [boolean]"false" is $true in PowerShell - compare explicitly. $deprecated = $false try { $s = [string]$xEl.Attributes['IsDeprecated'].Value if (-not [string]::IsNullOrEmpty($s)) { $deprecated = ($s -eq 'true') } } catch { } # ---- custom properties + role ---- $props = @{} foreach ($cp in $xEl.ChildNodes) { if ($cp.LocalName -ne 'CustomProperties') { continue } foreach ($p in $cp.ChildNodes) { if ($p.LocalName -ne 'Property') { continue } try { $props[[string]$p.Attributes['Key'].Value] = [string]$p.Attributes['Value'].Value } catch { } } } #if ($props.Count -eq 0 -and $role -eq 'Unknown' -and -not [string]::IsNullOrEmpty($) { # Write-Host ("MODEL no-props: {0} id={1} childNodes={2}" -f $nameAttr, $id, $xEl.ChildNodes.Count) #} $role = 'Unknown' foreach ($rk in $this._roleKeys) { if ($props.ContainsKey($rk) -and -not [string]::IsNullOrEmpty([string]$props[$rk])) { if ($rk -eq 'ts_KeyProvisioning') { $role = 'ProvisioningSchema' } elseif ($rk -eq 'ts_TermGroup') { $role = 'Root' } elseif ($rk -eq 'ts_Site') { $role = 'Site' } elseif ($rk -eq 'ts_SubSite') { $role = 'SubSite' } elseif ($rk -in @('ts_Sites','ts_SubSites')) { $role = 'SitesCollection' } elseif ($rk -eq 'ts_Design') { $role = 'Design' } elseif ($rk -eq 'ts_Environment') { $role = 'Environment' } break } } # ---- ts_Group header (synthetic parent) ---- $hostId = $parentId if ($props.ContainsKey('ts_Group') -and -not [string]::IsNullOrEmpty([string]$props['ts_Group'])) { $gText = [string]$props['ts_Group'] $gKey = $gText try { $gKey = Remove-SRxStringSpecialCharacter -String $gText } catch { } $gid = ('{0}#{1}' -f $parentId, $gKey) if ($null -eq $this.GetNode($gid)) { $this.AddNode($gid, $parentId, $gText, @{ role = 'Group'; synthetic = $true }) } $hostId = $gid } $tag = @{ termID = $id role = $role CustomProperties = $props Operations = @{} OuterXml = [string]$xEl.OuterXml deprecated = $deprecated tooltip = $tooltip title = $title synthetic = ($id -like 'syn:*') } $this.AddNode($id, $hostId, $text, $tag) if ($xEl.HasChildNodes) { $this.addXmlChildren($xEl, $id) } } } # ======================================================== SEMANTICS (Step 1, model-based) [string] GetRole([string]$id) { $n = $this.GetNode($id) if ($null -eq $n -or $null -eq $n.Tag) { return 'Unknown' } if ($n.Tag -is [hashtable] -and $n.Tag.ContainsKey('role')) { return [string]$n.Tag['role'] } return 'Unknown' } [string] GetOwnProp([string]$id, [string]$key) { $n = $this.GetNode($id) if ($null -eq $n -or $null -eq $n.Tag) { return '' } if ($n.Tag -is [hashtable] -and $n.Tag.ContainsKey('CustomProperties')) { $cp = $n.Tag['CustomProperties'] if ($cp -is [hashtable] -and $cp.ContainsKey($key)) { return [string]$cp[$key] } } return '' } # own value, else nearest ancestor that declares it (replaces label walking) [string] GetEffectiveProp([string]$id, [string]$key) { $own = $this.GetOwnProp($id, $key) if (-not [string]::IsNullOrEmpty($own)) { return $own } foreach ($a in $this.AncestorIds($id)) { $v = $this.GetOwnProp($a, $key) if (-not [string]::IsNullOrEmpty($v)) { return $v } } return '' } [string] GetOwningId([string]$id, [string]$key) { if (-not [string]::IsNullOrEmpty($this.GetOwnProp($id, $key))) { return $id } foreach ($a in $this.AncestorIds($id)) { if (-not [string]::IsNullOrEmpty($this.GetOwnProp($a, $key))) { return $a } } return '' } [string] GetRoleValue([string]$id) { $role = $this.GetRole($id) switch ($role) { 'ProvisioningSchema' { return $this.GetOwnProp($id, 'ts_KeyProvisioning') } 'Root' { return $this.GetOwnProp($id, 'ts_TermGroup') } 'Site' { return $this.GetOwnProp($id, 'ts_Site') } 'SubSite' { return $this.GetOwnProp($id, 'ts_SubSite') } 'Design' { return $this.GetOwnProp($id, 'ts_Design') } 'Environment' { return $this.GetOwnProp($id, 'ts_Environment') } } return '' } [boolean] IsSynthetic([string]$id) { if ([string]::IsNullOrEmpty($id)) { return $true } if ($id.Contains('#')) { return $true } if ($id.StartsWith('syn:')) { return $true } return $false } # ======================================================== COLOURING AS OVERLAYS # Deprecated terms - a permanent, low-priority overlay. [void] ApplyDeprecatedOverlay() { try { $this.PushOverlay('deprecated') $this.ClearOverlayStyles('deprecated') foreach ($id in $this.AllIds()) { $n = $this.GetNode($id) if ($null -eq $n -or $null -eq $n.Tag) { continue } if ($n.Tag -is [hashtable] -and $n.Tag.ContainsKey('deprecated') -and [bool]$n.Tag['deprecated']) { $this.SetOverlayStyle('deprecated', $id, @{ Fore = [System.Drawing.Color]::LightGray }) } } $this.applyAppearance() } catch { Write-Host "ApplyDeprecatedOverlay - $($_.Exception.Message)" } } # The current scope node (replaces setTreeViewColor / setTreeViewNodeColor). [void] ApplyScopeOverlay([string]$scopeId) { try { $this.PushOverlay('scope') $this.ClearOverlayStyles('scope') if (-not [string]::IsNullOrEmpty($scopeId)) { $this.SetOverlayStyle('scope', $scopeId, @{ Fore = [System.Drawing.Color]::Red FontStyle = [System.Drawing.FontStyle]::Bold }) } $this.applyAppearance() } catch { Write-Host "ApplyScopeOverlay - $($_.Exception.Message)" } } [void] ClearScopeOverlay() { $this.PopOverlay('scope') } [string[]] AllIds() { $out = New-Object System.Collections.ArrayList foreach ($rid in $this.RootIds()) { $this.collectIds($rid, $out) } return @($out) } hidden [void] collectIds([string]$id, $acc) { [void]$acc.Add($id) foreach ($c in $this.ChildIds($id)) { $this.collectIds($c, $acc) } } # ------------------------------------------------------------------ # STEP 3 host-integration additions # ------------------------------------------------------------------ [void] LoadFromRoot($termGroupElement, [string]$rootKey, [string]$rootText) { try { $this.ClearModel() $outer = '' if ($null -ne $termGroupElement) { $outer = [string]$termGroupElement.OuterXml } $rootTermId = '' if ($null -ne $termGroupElement) { $rootTermId = $rootKey } $this.AddNode($rootKey, '', $rootText, @{ termID = $rootTermId role = 'Root' CustomProperties = @{ 'ts_TermGroup' = $rootText } Operations = @{} OuterXml = $outer deprecated = $false tooltip = $null title = $rootText synthetic = $false }) if ($null -ne $termGroupElement) { $this.addXmlChildren($termGroupElement, $rootKey) } $this.SyncToUI() if ($this.Verbose) { Write-Host ("LoadFromRoot - model built: {0} nodes" -f $this.Count()) } } catch { # A mid-walk exception leaves a PARTIAL model that still renders, which # is how the XmlNodeList.IndexOf bug hid for several rounds. Say so. Write-Host "LoadFromRoot - FAILED (model is PARTIAL): $($_.Exception.Message)" try { $this.SyncToUI() } catch { } } } [object] FindUiNode([string]$id) { try { $found = $this.InnerTree().Nodes.Find($id, $true) if ($found.Count -gt 0) { return $found[0] } } catch { } return $null } [object] SelectedTag() { $id = $this.SelectedId() if (:IsNullOrEmpty($id)) { return $null } $n = $this.GetNode($id) if ($null -eq $n) { return $null } return $n.Tag } # Hide (or reveal) every Customizations branch without touching the model. [void] ShowCustomizationBranches([bool]$show) { try { if ($show) { $this.PopOverlay('customizations-hidden') return } $this.PushOverlay('customizations-hidden') $this.ClearOverlayStyles('customizations-hidden') foreach ($id in $this.AllIds()) { if ($this.GetRole($id) -ne 'Customizations') { continue } $this.SetOverlayStyle('customizations-hidden', $id, @{ Hidden = $true }) } $this.RebuildUI() } catch { Write-Host "ShowCustomizationBranches - $($_.Exception.Message)" } } } |