TreeControl/SRxXmlTreeControl.ps1

<#
================================================================================
  SRxXmlTreeControl.psm1 STEP 8 - XML template tree extraction
  -----------------------------------------------------------------------------
  The XML TEMPLATE wrapper around the generic SRxTreeControl.

  TWO TREES, TWO IDENTITY MODELS (the Step-1 finding, applied)
      Schema tree : term GUID identity + ts_* ROLE markers (semantic model)
      THIS tree : xPath identity + element names (XML document)
  There are no roles here - a PnP XML node means what its element name says.

  WHY xPath AND NOT THE OLD INTEGER KEY
    The legacy builder named nodes with a running counter ($this._key++), while
    the xPath - the value every lookup actually needs - lived only in .Tag.
    Using xPath as the model id makes computeTemplateNodeState a direct
    dictionary hit instead of walking TreeNodes, and it survives a rebuild.
    Collisions (same xPath twice) are disambiguated with a #n suffix; the TRUE
    xPath is always preserved in the tag, so existing lookups are unchanged.

  SIMULATION BECOMES AN OVERLAY
    applyCompositionSimulation used to REMOVE nodes, and restoreFromSimulation
    had to rebuild the tree (hence the _simulationActive flag and the reload
    it forced). Here it is PushOverlay(*simulation*) + Hidden styles, so
    restoring is PopOverlay - the model is never mutated and the flag is gone.

  REQUIRES: WinForms/Drawing loaded BEFORE Import-Module.
            SRxTreeControl.psm1 beside this file.
================================================================================
#>


class SRxXmlTreeControl : SRxTreeControl {

    [bool] $Verbose = $false

    # xPath -> model id (ids may carry a #n suffix when an xPath repeats)
    hidden [System.Collections.Hashtable] $_byXPath = @{}

    SRxXmlTreeControl() : base() { 

        $this.SetSideBorder(
            [SRxBorderSides]::Right,
            [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(0, 0, 1, 0)
    }

    # ======================================================== MODEL BUILD

    # Build the whole model from the ProvisioningTemplate element.
    # $xmlElement : the <pnp:ProvisioningTemplate> element
    # $rootXPath : "/pnp:Provisioning/pnp:Templates/pnp:ProvisioningTemplate"
    # $xPathFn : scriptblock { param($xEl,$parentXPath) -> [string] } supplied
    # by the host so formatXPath stays where the domain lives.
    [void] LoadFromXml($xmlElement, [string]$rootXPath, $xPathFn) {
        try {
            $this.ClearModel()
            $this._byXPath.Clear()
            if ($null -eq $xmlElement) { $this.SyncToUI(); return }

            $rootId = $this.reserveId($rootXPath)
            $this.AddNode($rootId, "", $this.headText($xmlElement, $true), (
                $this.newTag($rootXPath, $xmlElement)))

            $this.addXmlChildren($xmlElement, $rootXPath, $rootId, $xPathFn)
            $this.SyncToUI()
            if ($this.Verbose) { Write-Host ("SRxXmlTreeControl - model built: {0} nodes" -f $this.Count()) }
        }
        catch {
            # a mid-walk failure leaves a PARTIAL model that still renders - say so
            Write-Host "SRxXmlTreeControl.LoadFromXml - FAILED (model is PARTIAL): $($_.Exception.Message)"
            try { $this.SyncToUI() } catch { }
        }
    }

    hidden [void] addXmlChildren($xElement, [string]$parentXPath, [string]$parentId, $xPathFn) {
        foreach ($xEl in $xElement.ChildNodes) {

            # skip whitespace / comment / CDATA - only ELEMENTS are tree nodes.
            # With PreserveWhitespace the indentation around a deleted element
            # survives as a text node and would render as an empty row.
            if ($xEl.NodeType -ne [System.Xml.XmlNodeType]::Element) { continue }

            $xPth = ""
            if ($null -ne $xPathFn) {
                try { $xPth = [string](& $xPathFn $xEl $parentXPath) } catch { $xPth = "" }
            }
            if ([string]::IsNullOrEmpty($xPth)) { $xPth = ($parentXPath + "/" + [string]$xEl.LocalName) }

            $hasKids = $false
            try { $hasKids = [bool]$xEl.HasChildNodes } catch { $hasKids = $false }

            $id = $this.reserveId($xPth)
            $this.AddNode($id, $parentId, $this.headText($xEl, $hasKids), ($this.newTag($xPth, $xEl)))

            if ($hasKids) { $this.addXmlChildren($xEl, $xPth, $id, $xPathFn) }
        }
    }

    # node label: opening tag only when it has children, else the whole fragment
    hidden [string] headText($xEl, [bool]$hasKids) {
        try {
            $outer = [string]$xEl.OuterXml
            if ($hasKids) { return ($outer.Split(">")[0] + ">") }
            return $outer
        } catch { return "" }
    }

    # the legacy Tag shape - every existing consumer keeps working unchanged
    hidden [object] newTag([string]$xPth, $xEl) {
        $outer = ""
        try { $outer = [string]$xEl.OuterXml } catch { }
        return (New-Object PSObject -Property @{
            xPath                = $xPth
            Filter               = @{}
            SavedFilter          = @{}
            ActiveScopeChanges   = @{}
            ActiveScopeFilter    = @{}
            OuterXml             = $outer
            DeprecatedOnScope_In = -1
        })
    }

    # xPaths CAN repeat (same element name, no distinguishing attribute), so the
    # model id gets a #n suffix. The TRUE xPath always stays in the tag.
    hidden [string] reserveId([string]$xPth) {
        $id = $xPth
        if ($this._byXPath.ContainsKey($xPth)) {
            $n = [int]$this._byXPath[$xPth]
            $n++
            $this._byXPath[$xPth] = $n
            $id = ("{0}#{1}" -f $xPth, $n)
        } else {
            $this._byXPath[$xPth] = 0
        }
        return $id
    }

    # ======================================================== LOOKUPS

    [string] GetXPath([string]$id) {
        $n = $this.GetNode($id)
        if ($null -eq $n -or $null -eq $n.Tag) { return "" }
        try { return [string]$n.Tag.xPath } catch { return "" }
    }

    # first model id carrying this xPath ("" when absent)
    [string] IdForXPath([string]$xPth) {
        if ($this.GetNode($xPth)) { return $xPth }
        foreach ($id in $this.AllIds()) {
            if ($this.GetXPath($id) -eq $xPth) { return $id }
        }
        return ""
    }

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

    [object] SelectedTag() {
        $id = $this.SelectedId()
        if ([string]::IsNullOrEmpty($id)) { return $null }
        $n = $this.GetNode($id)
        if ($null -eq $n) { return $null }
        return $n.Tag
    }

    [object] FindUiNode([string]$id) {
        try {
            $found = $this.InnerTree().Nodes.Find($id, $true)
            if ($found.Count -gt 0) { return $found[0] }
        } catch { }
        return $null
    }

    # ======================================================== CUSTOMIZATION STYLING

    # The host computes STATE (it owns the customization semantics); the control
    # owns PRESENTATION. $stateById : id -> @{ Fore; FontStyle; Back; Hidden }
    [void] ApplyCustomizationOverlay([hashtable]$styleById) {
        try {
            $this.PushOverlay('customizations')
            $this.ClearOverlayStyles('customizations')
            if ($null -ne $styleById) {
                foreach ($id in $styleById.Keys) {
                    $this.SetOverlayStyle('customizations', [string]$id, [hashtable]$styleById[$id])
                }
            }
            $this.applyAppearance()
        } catch { Write-Host "ApplyCustomizationOverlay - $($_.Exception.Message)" }
    }

    [void] ClearCustomizationOverlay() { $this.PopOverlay('customizations') }

    # ======================================================== SIMULATION
<#
    # Hide the ids the host says are filtered out. No node is REMOVED, so
    # restoring is just dropping the overlay - no rebuild, no _simulationActive.
    [int] ApplySimulation([string[]]$hiddenIds) {
        try {
            $this.PushOverlay('simulation')
            $this.ClearOverlayStyles('simulation')
            $n = 0
            foreach ($id in @($hiddenIds)) {
                if ([string]::IsNullOrEmpty([string]$id)) { continue }
                $this.SetOverlayStyle('simulation', [string]$id, @{ Hidden = $true })
                $n++
            }
            $this.RebuildUI()
            return $n
        } catch { Write-Host "ApplySimulation - $($_.Exception.Message)"; return 0 }
    }

    [void] ClearSimulation() {
        try { $this.PopOverlay('simulation') }
        catch { Write-Host "ClearSimulation - $($_.Exception.Message)" }
    }

    [boolean] IsSimulating() { return $this.HasOverlay('simulation') }
#>

    # ======================================================== ROOT

    [void] ExpandRoot() {
        try {
            $roots = $this.RootIds()
            if ($roots.Count -eq 0) { return }
            $ui = $this.FindUiNode([string]$roots[0])
            if ($null -ne $ui -and -not $ui.IsExpanded) { $ui.Expand() }
        } catch { Write-Host "ExpandRoot - $($_.Exception.Message)" }
    }
}