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)" }
    }

}