TreeControl/SRxCustomizationEngine.ps1

Set-StrictMode -Off

class SRxCustomizationEngine {

    [string] $SourceTemplatePath       = ''
    [string] $ProvisioningDatabasePath = ''
    [string] $PnpNamespace = 'http://schemas.dev.office.com/PnP/2022/09/ProvisioningSchema'

    [string[]] $LayerOrder = @('TargetDesign','MasterDesign','TargetEnvironment','ProvisioningSchema')

    [System.Collections.Specialized.OrderedDictionary] $Pipeline

    [bool] $TraceEnabled = $false
    [scriptblock] $OnTrace = $null

    hidden [System.Xml.XmlDocument] $_doc
    hidden [System.Xml.XmlNamespaceManager] $_ns
    hidden [hashtable] $_byLayer
    hidden [hashtable] $_protected
    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
    }

    [void] SetSourceXml($xmlDoc) {
        $this._doc = $xmlDoc
    }

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

    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
    }

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

    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
    }

    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)

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

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

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

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

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

    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) {
                    $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'
        }
    }

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

    [object] ResultTemplateElement() {
        try { return $this._doc.$('Provisioning').$('Templates').$('ProvisioningTemplate') }
        catch { return $null }
    }

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