TreeControl/SRxCustomizationEngine.ps1

<#
================================================================================
  SRxCustomizationEngine.psm1
  -----------------------------------------------------------------------------
  AUTONOMOUS XML template transformation engine.

  Runs the customization pipeline with NO dependency on TemplateExplorer, the
  Explorer form, or WinForms. The same engine therefore serves two callers:

      TemplateExplorer - in-memory, on-demand simulation while modelling
      Provisioning - file in, file out, unattended

  EVERY I/O BOUNDARY IS OPTIONAL
      source : SourceTemplatePath OR SetSourceXml($xmlDoc)
      customizations: ProvisioningDatabasePath OR SetCustomizations($filterNodes)
      result : SaveResult($path) OR $result.Document (in memory)

  Hosted callers skip the file system entirely; unattended callers use it for
  all three. Neither path is privileged.

  THE PIPELINE
      source XML -> [layer 1] -> intermediate -> [layer 2] -> ... -> FINAL

    Each customization is applied to the document produced by the previous one.
    Three actions:
      ts_Deprecated=true DELETE the node and all descendants
      ts_CustomizationTarget=true PROTECT the node + descendants from LATER steps
      other attributes MODIFY those attribute values

    Protection is ORDER-DEPENDENT: a node protected at step 2 survives a delete
    issued at step 3. That is why the pipeline must run sequentially rather than
    computing a per-node state in one pass.

  USAGE - unattended (provisioning)
      $e = New-SRxCustomizationEngine
      $e.SourceTemplatePath = "C:\cache\TemplateD.xml"
      $e.ProvisioningDatabasePath = "C:\cache\termSet.xml"
      $e.Pipeline = [ordered]@{ TargetDesign = "<termId>"; MasterDesign = "<termId>" }
      $r = $e.Run()
      $e.SaveResult("C:\out\TemplateD.transformed.xml")

  USAGE - hosted (TemplateExplorer)
      $e = New-SRxCustomizationEngine
      $e.SetSourceXml($xmlDoc)
      $e.SetCustomizations($byLayerHashtable) # already indexed by layer key
      $e.TraceEnabled = $true
      $r = $e.Run()
      # $r.Document is the transformed XmlDocument - load it straight into a tree
================================================================================
#>


Set-StrictMode -Off

class SRxCustomizationEngine {

    # ---------------- inputs (all optional; set what you have) ----------------
    [string] $SourceTemplatePath       = ''
    [string] $ProvisioningDatabasePath = ''
    [string] $PnpNamespace = 'http://schemas.dev.office.com/PnP/2022/09/ProvisioningSchema'

    # Execution order. First element runs FIRST. Change it to experiment.
    [string[]] $LayerOrder = @('TargetDesign','MasterDesign','TargetEnvironment','ProvisioningSchema')

    # layerKey -> term id of that layer hosting term. Only keys present here run,
    # and they run in LayerOrder sequence.
    [System.Collections.Specialized.OrderedDictionary] $Pipeline

    # ---------------- behaviour ----------------
    [bool] $TraceEnabled = $false
    [scriptblock] $OnTrace = $null        # & $OnTrace $line (defaults to Write-Host)

    # ---------------- state ----------------
    hidden [System.Xml.XmlDocument] $_doc
    hidden [System.Xml.XmlNamespaceManager] $_ns
    hidden [hashtable] $_byLayer          # layerKey -> @( customization, ... )
    hidden [hashtable] $_protected        # xPath -> step that protected it
    hidden [System.Collections.ArrayList] $_log

    SRxCustomizationEngine() {
        $this.Pipeline = New-Object System.Collections.Specialized.OrderedDictionary
        $this._byLayer = @{}
        $this._protected = @{}
        $this._log = New-Object System.Collections.ArrayList
    }

    # ======================================================== INPUT (in memory)

    # Hand the engine a document directly - no file read.
    [void] SetSourceXml($xmlDoc) {
        $this._doc = $xmlDoc
    }

    # Hand the engine customizations already indexed as layerKey -> collection.
    # Each item needs: .xPath (string) and .Filter (hashtable of ts_*/attributes).
    [void] SetCustomizations([hashtable]$byLayer) {
        $this._byLayer = @{}
        if ($null -eq $byLayer) { return }
        foreach ($k in $byLayer.Keys) {
            $v = $byLayer[$k]
            if ($v -is [hashtable]) { $this._byLayer[[string]$k] = @($v.Values) }
            else                    { $this._byLayer[[string]$k] = @($v) }
        }
    }

    # ======================================================== INPUT (file system)

    hidden [void] loadSourceFromFile() {
        if ([string]::IsNullOrEmpty($this.SourceTemplatePath)) { throw "SourceTemplatePath is not set and no in-memory document was supplied." }
        if (-not (Test-Path $this.SourceTemplatePath)) { throw "Source template not found: $($this.SourceTemplatePath)" }
        $raw = Get-Content -Path $this.SourceTemplatePath -Raw -Encoding UTF8
        $d = New-Object System.Xml.XmlDocument
        $d.PreserveWhitespace = $true
        $d.LoadXml($raw)
        $this._doc = $d
    }

    # Read customizations for every pipeline layer out of the cached Provisioning
    # Database (termSet.xml). Supports BOTH storage formats:
    # legacy - a multi-term branch mirroring the XML node path
    # JSON - one container term with a compressed subtree payload
    hidden [void] loadCustomizationsFromFile() {
        if ([string]::IsNullOrEmpty($this.ProvisioningDatabasePath)) { throw "ProvisioningDatabasePath is not set and no in-memory customizations were supplied." }
        if (-not (Test-Path $this.ProvisioningDatabasePath)) { throw "Provisioning database not found: $($this.ProvisioningDatabasePath)" }

        $raw = Get-Content -Path $this.ProvisioningDatabasePath -Raw -Encoding UTF8
        $db = New-Object System.Xml.XmlDocument
        $db.LoadXml($raw)

        $this._byLayer = @{}
        foreach ($layerKey in $this.Pipeline.Keys) {
            $termId = [string]$this.Pipeline[$layerKey]
            if ([string]::IsNullOrEmpty($termId)) { continue }
            $this._byLayer[[string]$layerKey] = @($this.readLayerCustomizations($db, $termId))
        }
    }

    # Find a term by ID anywhere in the database.
    hidden [object] findTermById($db, [string]$termId) {
        try {
            foreach ($el in $db.GetElementsByTagName('*')) {
                if ($el.LocalName -ne 'Term' -and $el.LocalName -ne 'TermSet') { continue }
                if ($null -eq $el.Attributes['ID']) { continue }
                if ([string]$el.Attributes['ID'].Value -eq $termId) { return $el }
            }
        } catch { }
        return $null
    }

    hidden [hashtable] termProperties($termEl) {
        $h = @{}
        try {
            foreach ($c in $termEl.ChildNodes) {
                if ($c.LocalName -ne 'CustomProperties') { continue }
                foreach ($p in $c.ChildNodes) {
                    if ($p.LocalName -ne 'Property') { continue }
                    $h[[string]$p.Attributes['Key'].Value] = [string]$p.Attributes['Value'].Value
                }
            }
        } catch { }
        return $h
    }

    hidden [object] childTermsElement($termEl) {
        try {
            foreach ($c in $termEl.ChildNodes) { if ($c.LocalName -eq 'Terms') { return $c } }
        } catch { }
        return $null
    }

    # layer term -> its Customizations host -> one customization per child
    hidden [object[]] readLayerCustomizations($db, [string]$layerTermId) {
        $out = New-Object System.Collections.ArrayList
        try {
            $layerTerm = $this.findTermById($db, $layerTermId)
            if ($null -eq $layerTerm) { return @() }

            $props  = $this.termProperties($layerTerm)
            $custId = ''
            if ($props.ContainsKey('ts_ProvisioningTemplate')) { $custId = [string]$props['ts_ProvisioningTemplate'] }
            if ([string]::IsNullOrEmpty($custId)) { return @() }

            $custHost = $this.findTermById($db, $custId)
            if ($null -eq $custHost) { return @() }

            $terms = $this.childTermsElement($custHost)
            if ($null -eq $terms) { return @() }

            foreach ($child in $terms.ChildNodes) {
                if ($child.LocalName -ne 'Term') { continue }
                $cp = $this.termProperties($child)

                # only entries meant for this activity
                if ($cp.ContainsKey('ts_ProvisioningActivity')) {
                    if ([string]$cp['ts_ProvisioningActivity'] -ne 'Optimize-SiteTemplate') { continue }
                }

                if ($cp.ContainsKey('ts_CustomizationFormat') -and ([string]$cp['ts_CustomizationFormat'] -eq 'JSON')) {
                    foreach ($c in $this.expandJsonCustomization($cp)) { [void]$out.Add($c) }
                }
                else {
                    foreach ($c in $this.expandLegacyBranch($child, '')) { [void]$out.Add($c) }
                }
            }
        } catch { $this.trace("readLayerCustomizations - $($_.Exception.Message)") }
        return @($out)
    }

    # JSON format: one container term, compressed subtree payload.
    hidden [object[]] expandJsonCustomization([hashtable]$containerProps) {
        $out = New-Object System.Collections.ArrayList
        try {
            if (-not (Get-Command Join-SRxJsonProperty -ErrorAction SilentlyContinue)) {
                $this.trace('JSON customization found but SRxCustomizationJson.psm1 is not loaded - skipped')
                return @()
            }
            $payload = Join-SRxJsonProperty -Properties $containerProps -BaseKey 'ts_CustomizationJson'
            if ([string]::IsNullOrEmpty($payload)) { return @() }
            $json = Expand-SRxText -Base64 $payload
            if ([string]::IsNullOrEmpty($json)) { return @() }
            $root = $json | ConvertFrom-Json
            foreach ($c in $this.walkSubtree($root, '')) { [void]$out.Add($c) }
        } catch { $this.trace("expandJsonCustomization - $($_.Exception.Message)") }
        return @($out)
    }

    # Walk a JSON subtree (Name / CustomProperties / Terms), emitting one
    # customization per node that carries an actionable payload.
    hidden [object[]] walkSubtree($node, [string]$parentXPath) {
        $out = New-Object System.Collections.ArrayList
        try {
            if ($null -eq $node) { return @() }
            $props = @{}
            if ($node.PSObject.Properties['CustomProperties']) {
                foreach ($p in @($node.CustomProperties)) {
                    if ($null -ne $p) { $props[[string]$p.Key] = [string]$p.Value }
                }
            }
            $nodeName = ''
            if ($props.ContainsKey('ts_NodeName')) { $nodeName = [string]$props['ts_NodeName'] }
            if ([string]::IsNullOrEmpty($nodeName)) { $nodeName = [string]$node.Name }

            $xp = $parentXPath
            if (-not [string]::IsNullOrEmpty($nodeName)) { $xp = $parentXPath + '/' + $nodeName }

            $filter = @{}
            foreach ($k in $props.Keys) {
                $key = [string]$k
                if ($key -eq 'ts_NodeName') { continue }
                $filter[$key] = [string]$props[$k]
            }
            if ($filter.Count -gt 0) {
                [void]$out.Add([pscustomobject]@{ xPath = $xp; Filter = $filter })
            }

            if ($node.PSObject.Properties['Terms']) {
                foreach ($t in @($node.Terms)) {
                    foreach ($c in $this.walkSubtree($t, $xp)) { [void]$out.Add($c) }
                }
            }
        } catch { $this.trace("walkSubtree - $($_.Exception.Message)") }
        return @($out)
    }

    # Legacy format: a multi-term branch mirroring the XML node path.
    hidden [object[]] expandLegacyBranch($termEl, [string]$parentXPath) {
        $out = New-Object System.Collections.ArrayList
        try {
            $props = $this.termProperties($termEl)
            $nodeName = ''
            if ($props.ContainsKey('ts_NodeName')) { $nodeName = [string]$props['ts_NodeName'] }
            if ([string]::IsNullOrEmpty($nodeName)) { $nodeName = [string]$termEl.Attributes['Name'].Value }

            $xp = $parentXPath
            if (-not [string]::IsNullOrEmpty($nodeName)) { $xp = $parentXPath + '/' + $nodeName }

            $filter = @{}
            foreach ($k in $props.Keys) {
                $key = [string]$k
                if ($key -eq 'ts_NodeName') { continue }
                if ($key -eq 'ts_ProvisioningActivity') { continue }
                $filter[$key] = [string]$props[$k]
            }
            if ($filter.Count -gt 0) {
                [void]$out.Add([pscustomobject]@{ xPath = $xp; Filter = $filter })
            }

            $terms = $this.childTermsElement($termEl)
            if ($null -ne $terms) {
                foreach ($c in $terms.ChildNodes) {
                    if ($c.LocalName -ne 'Term') { continue }
                    foreach ($x in $this.expandLegacyBranch($c, $xp)) { [void]$out.Add($x) }
                }
            }
        } catch { $this.trace("expandLegacyBranch - $($_.Exception.Message)") }
        return @($out)
    }

    # ======================================================== THE PIPELINE

    [object] Run() {
        $tally = @{ modified = 0; deleted = 0; preserved = 0; missing = 0 }
        try {
            $this._protected = @{}
            $this._log.Clear()

            if ($null -eq $this._doc) { $this.loadSourceFromFile() }
            if ($this._byLayer.Count -eq 0) { $this.loadCustomizationsFromFile() }

            $this._ns = New-Object System.Xml.XmlNamespaceManager($this._doc.NameTable)
            # Customizations can address nodes in nested namespaces (d3p1:* in
            # search configuration). Register every prefix the document declares;
            # the first declaration of a prefix wins.
            foreach ($el in $this._doc.GetElementsByTagName('*')) {
                foreach ($at in $el.Attributes) {
                    if ($at.Prefix -ne 'xmlns') { continue }
                    $p = [string]$at.LocalName
                    if ([string]::IsNullOrEmpty($this._ns.LookupNamespace($p))) {
                        $this._ns.AddNamespace($p, [string]$at.Value)
                    }
                }
            }

            $this.trace('--- CUSTOMIZATION PIPELINE -------------------------------------')

            $step = 0
            foreach ($layerKey in $this.LayerOrder) {
                if (-not $this.Pipeline.Contains($layerKey)) { continue }
                $step++

                $custs = @()
                if ($this._byLayer.ContainsKey($layerKey)) { $custs = @($this._byLayer[$layerKey]) }
                $this.trace((" step [{0}] {1} - {2} customization(s)" -f $step, $layerKey, $custs.Count))

                foreach ($fn in $custs) {
                    $outcome = $this.applyOne($fn, $step, $layerKey)
                    if ($tally.ContainsKey($outcome)) { $tally[$outcome] = [int]$tally[$outcome] + 1 }
                }
            }

            $this.trace(('--- RESULT: modified={0} deleted={1} preserved={2} missing={3} ---' -f `
                $tally['modified'], $tally['deleted'], $tally['preserved'], $tally['missing']))
        }
        catch { $this.trace("Run - $($_.Exception.Message)") }

        return [pscustomobject]@{
            Document = $this._doc
            Tally    = $tally
            Log      = @($this._log)
        }
    }

    # A node is protected when IT or an ANCESTOR was marked Include earlier.
    # xPaths are hierarchical strings, so this is a prefix test - the trailing
    # "/" guard stops /pnp:List from matching /pnp:Lists.
    hidden [string] protectedBy([string]$xPath) {
        if ($this._protected.ContainsKey($xPath)) { return [string]$this._protected[$xPath] }
        foreach ($p in $this._protected.Keys) {
            if ($xPath.StartsWith(([string]$p) + '/')) { return [string]$this._protected[$p] }
        }
        return ''
    }

    hidden [string] applyOne($fn, [int]$step, [string]$layerKey) {
        try {
            $xp = ''
            try { $xp = [string]$fn.xPath } catch { }
            if ([string]::IsNullOrEmpty($xp)) { return 'missing' }

            $by = $this.protectedBy($xp)
            if (-not [string]::IsNullOrEmpty($by)) {
                $this.record($step, $layerKey, 'preserved', $xp, ("protected at step " + $by))
                return 'preserved'
            }

            $node = $this._doc.SelectSingleNode($xp, $this._ns)
            if ($null -eq $node) {
                $this.record($step, $layerKey, 'missing', $xp, 'node not found')
                return 'missing'
            }

            $flt = $null
            try { $flt = $fn.Filter } catch { }
            if (-not ($flt -is [hashtable])) {
                $this.record($step, $layerKey, 'missing', $xp, 'no filter payload')
                return 'missing'
            }

            $isDelete  = $false
            $isInclude = $false
            $attrs = @{}
            foreach ($k in @($flt.Keys)) {
                $key = [string]$k
                $val = $flt[$k]
                if     ($key -eq 'ts_Deprecated')          { if ([string]$val -ieq 'true') { $isDelete  = $true } }
                elseif ($key -eq 'ts_CustomizationTarget') { if ([string]$val -ieq 'true') { $isInclude = $true } }
                elseif (-not ($key -like 'ts_*'))          { $attrs[$key] = [string]$val }
            }

            if ($isDelete) {
                $parent = $node.ParentNode
                if ($null -ne $parent) {
                    # PreserveWhitespace keeps the indentation BEFORE this element
                    # as its own text node; removing only the element would leave
                    # that orphaned in the result file.
                    $prev = $node.PreviousSibling
                    if ($null -ne $prev -and $prev.NodeType -eq [System.Xml.XmlNodeType]::Whitespace) {
                        [void]$parent.RemoveChild($prev)
                    }
                    [void]$parent.RemoveChild($node)
                    $this.record($step, $layerKey, 'deleted', $xp, '')
                    return 'deleted'
                }
                $this.record($step, $layerKey, 'missing', $xp, 'no parent to remove from')
                return 'missing'
            }

            if ($isInclude) {
                $this._protected[$xp] = [string]$step
                $this.record($step, $layerKey, 'preserved', $xp, 'marked protected')
                return 'preserved'
            }

            if ($attrs.Count -eq 0) {
                $this.record($step, $layerKey, 'missing', $xp, 'no action in customization')
                return 'missing'
            }

            $changed = 0
            foreach ($a in @($attrs.Keys)) {
                $name = [string]$a
                if ($null -eq $node.Attributes[$name]) {
                    $this.record($step, $layerKey, 'missing', $xp, ("attribute not found: " + $name))
                    continue
                }
                $node.Attributes[$name].Value = [string]$attrs[$a]
                $this.record($step, $layerKey, 'modified', $xp, ($name + '=' + [string]$attrs[$a]))
                $changed++
            }
            if ($changed -gt 0) { return 'modified' }
            return 'missing'
        }
        catch {
            $this.trace("applyOne - $($_.Exception.Message)")
            return 'missing'
        }
    }

    # ======================================================== OUTPUT

    [void] SaveResult([string]$path) {
        if ($null -eq $this._doc) { throw 'Nothing to save - Run() has not produced a document.' }
        if ([string]::IsNullOrEmpty($path)) { throw 'SaveResult requires a path.' }
        $dir = Split-Path -Parent $path
        if ((-not [string]::IsNullOrEmpty($dir)) -and (-not (Test-Path $dir))) {
            [void](New-Item -ItemType Directory -Path $dir -Force)
        }
        $this._doc.Save($path)
    }

    [System.Xml.XmlDocument] ResultDocument() { return $this._doc }

    # The <pnp:ProvisioningTemplate> element - what a tree control wants to render.
    [object] ResultTemplateElement() {
        try { return $this._doc.$('Provisioning').$('Templates').$('ProvisioningTemplate') }
        catch { return $null }
    }

    # ======================================================== TRACE

    hidden [void] record([int]$step, [string]$layerKey, [string]$outcome, [string]$xPath, [string]$detail) {
        [void]$this._log.Add([pscustomobject]@{
            Step = $step; Layer = $layerKey; Outcome = $outcome; XPath = $xPath; Detail = $detail
        })
        if (-not $this.TraceEnabled) { return }
        $short = $xPath
        try {
            $parts = @($xPath -split '/')
            if ($parts.Count -gt 1) { $short = [string]$parts[$parts.Count - 1] }
        } catch { }
        $line = (" [{0}] {1,-18} {2,-9} {3}" -f $step, $layerKey, $outcome, $short)
        if (-not [string]::IsNullOrEmpty($detail)) { $line = $line + ' ' + $detail }
        $this.trace($line)
    }

    hidden [void] trace([string]$line) {
        if (-not $this.TraceEnabled) { return }
        if ($this.OnTrace) { try { & $this.OnTrace $line; return } catch { } }
        Write-Host $line
    }
}