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