FilterBox/SRxFilterBox.psm1
|
<# ================================================================================ SRxFilterBox.psm1 (Iteration 1) ----------------------------------------------------------------------------- Orchestration + local reconciliation + effective-filter merge for the Filter Box. Runs in the PS 5.1 TemplateExplorer UI process. The remote branch write happens in an isolated child process (Write-SRxFilterBranch.ps1), so the UI never blocks and every batch is a killable PID tracked on its customizer-tree node. Mirrors SRxPropertyBox.psm1 (proven), adapted from single-term to term-BRANCH. Public surface: # branch lifecycle (per node) Start-SRxFilterBranchOperation launch worker -> {ProcessId, ResultPath, ...} Get-SRxFilterBranchOperationState poll worker -> {State, Success, Conflict, Data, Error} Stop-SRxFilterBranchOperation kill one batch by PID Remove-SRxFilterBranchTempFiles cleanup a finished op's temp files # local reconciliation (customizations sub-tree) Sync-SRxLocalFilterBranch patch termSet.xml Customizations branch + regen JSON # pure merge (4-layer priority stack) Get-SRxEffectiveFilter merge LayerFilters(0..3) + Draft -> effective filter # helpers Get-SRxProfileFolderPath ================================================================================ #> $script:SRxPnpNs = 'http://schemas.dev.office.com/PnP/2022/09/ProvisioningSchema' $script:SRxChunkSize = 9000 # Bring the shared JSON-customization helpers into THIS module's scope so # Sync-SRxLocalJsonCustomization / New-SRxJsonContainerTerm resolve. Import-Module (Join-Path $PSScriptRoot 'SRxCustomizationJson.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue # ------------------------------------------------------------------------------ function Get-SRxProfileFolderPath { [CmdletBinding()] param() $sgroupName = Remove-SRxStringSpecialCharacter -String $global:SRxEnv.Tenancy.TermGroupName return (Join-Path (Join-Path $global:SRxEnv.CachePath 'Profiles') $sgroupName) } # ============================================================================== # Get-SRxEffectiveFilter (PURE) - the single merge function replacing the old # 4-hashtable tangle. Layers are a priority stack: lower index = higher priority, # applied first. A layerFilter = @{ Attributes=@{k=v}; Exclude=$bool; Include=$bool }. # Draft (if provided) is the unsaved edit for $ActiveLayer only. # # Resolution rules (match Optimize-SiteTemplate intent): # - Include (ts_CustomizationTarget) at ANY layer wins and BLOCKS Exclude below it. # - Otherwise Exclude (ts_Deprecated) from the highest-priority layer that sets it wins. # - Attributes merge highest-priority-first (a higher layer's value is authoritative; # lower layers fill keys not already set). # ============================================================================== function Get-SRxEffectiveFilter { [CmdletBinding()] param( [Parameter(Mandatory=$false)] [hashtable]$LayerFilters = @{}, # @{0=lf;1=lf;2=lf;3=lf} [Parameter(Mandatory=$false)] [int]$ActiveLayer = -1, [Parameter(Mandatory=$false)] $Draft = $null # @{Attributes;Exclude;Include} ) if ($null -eq $LayerFilters) { $LayerFilters = @{} } # Build an effective view of each layer, overlaying Draft on the active layer. $effLayers = @{} foreach ($i in 0..3) { $lf = $null if ($LayerFilters.ContainsKey($i) -and $LayerFilters[$i]) { $lf = $LayerFilters[$i] } if ($i -eq $ActiveLayer -and $null -ne $Draft) { $lf = [pscustomobject]@{ Attributes = (Merge-SRxHash (Get-SRxLayerAttrs $lf) (Get-SRxDraftAttrs $Draft)) Exclude = [bool](Get-SRxProp $Draft 'Exclude' (Get-SRxProp $lf 'Exclude' $false)) Include = [bool](Get-SRxProp $Draft 'Include' (Get-SRxProp $lf 'Include' $false)) } } if ($null -ne $lf) { $effLayers[$i] = $lf } } # Include wins & blocks Exclude below it. $include = $false; $includeLayer = 99 foreach ($i in 0..3) { if ($effLayers.ContainsKey($i) -and (Get-SRxProp $effLayers[$i] 'Include' $false)) { $include = $true; $includeLayer = $i; break } } $exclude = $false if (-not $include) { foreach ($i in 0..3) { if ($effLayers.ContainsKey($i) -and (Get-SRxProp $effLayers[$i] 'Exclude' $false)) { $exclude = $true; break } } } else { # an Exclude only counts if it is at a HIGHER priority than the winning Include foreach ($i in 0..3) { if ($i -ge $includeLayer) { break } if ($effLayers.ContainsKey($i) -and (Get-SRxProp $effLayers[$i] 'Exclude' $false)) { $exclude = $true; break } } } # Attributes: highest priority first fills, lower layers fill remaining keys. $attrs = @{} foreach ($i in 0..3) { if (-not $effLayers.ContainsKey($i)) { continue } $la = Get-SRxLayerAttrs $effLayers[$i] foreach ($k in $la.Keys) { if (-not $attrs.ContainsKey($k)) { $attrs[$k] = [string]$la[$k] } } } return [pscustomobject]@{ Attributes = $attrs Exclude = [bool]$exclude Include = [bool]$include HasFilter = ([bool]$exclude -or [bool]$include -or ($attrs.Count -gt 0)) } } # --- tiny helpers for the merge (kept internal) --- function Get-SRxProp { param($obj,[string]$name,$default) if ($null -eq $obj) { return $default } if ($obj -is [hashtable]) { if ($obj.ContainsKey($name)) { return $obj[$name] } else { return $default } } $p = $obj.PSObject.Properties[$name]; if ($p) { return $p.Value } else { return $default } } function Get-SRxLayerAttrs { param($lf) $a = Get-SRxProp $lf 'Attributes' @{} $h = @{}; if ($a -is [hashtable]) { foreach ($k in $a.Keys) { $h[$k]=[string]$a[$k] } } elseif ($null -ne $a) { foreach ($p in $a.PSObject.Properties) { $h[$p.Name]=[string]$p.Value } } return $h } function Get-SRxDraftAttrs { param($draft) return (Get-SRxLayerAttrs $draft) } function Merge-SRxHash { param([hashtable]$base,[hashtable]$over) $h = @{}; if ($base) { foreach ($k in $base.Keys) { $h[$k]=$base[$k] } } if ($over) { foreach ($k in $over.Keys) { $h[$k]=$over[$k] } } return $h } # ============================================================================== # Launch the isolated per-node branch worker. Non-blocking. # $Pack is the BranchPack hashtable (Operation/Scope/Optype/Environment/Design/ # TermID/Branch[]/BaselineModified). Passed to the worker via a temp JSON file. # ============================================================================== function Start-SRxFilterBranchOperation { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$Pack, [Parameter(Mandatory)] [string]$SourcePath, [Parameter(Mandatory=$false)] [switch]$Force, [Parameter(Mandatory=$false)] [switch]$HideWindow ) try { #$worker = Join-Path $SourcePath 'Write-SRxFilterBranch.ps1' #$worker = $("$PSScriptRoot\Write-SRxFilterBranch.ps1") $worker = $("$PSScriptRoot\Write-SRxFilterBranch.debug.ps1") if (-not (Test-Path $worker)) { throw "Worker not found: $worker" } $tag = ("{0:yyyyMMddHHmmssfff}-{1}" -f (Get-Date), ([guid]::NewGuid().ToString('N').Substring(0,8))) $resultPath = Join-Path $env:TEMP ("srx-filter-$tag.json") $packPath = Join-Path $env:TEMP ("srx-filter-$tag.pack.json") $packJson = ($Pack | ConvertTo-Json -Depth 12 -Compress) if ([string]::IsNullOrWhiteSpace($packJson)) { $packJson = '{}' } Set-Content -LiteralPath $packPath -Value $packJson -Encoding UTF8 $shim = @" & '$worker' -RootPath '$SourcePath' -PackPath '$packPath' -ResultPath '$resultPath'$(if($Force){' -Force'}) "@ $encoded = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($shim)) $startArgs = @{ FilePath = 'powershell.exe' ArgumentList = @('-NoProfile','-NonInteractive','-ExecutionPolicy','Bypass','-EncodedCommand', $encoded) PassThru = $true } if ($HideWindow) { $startArgs['WindowStyle'] = 'Hidden' } $proc = Start-Process @startArgs return [pscustomobject]@{ ProcessId = $proc.Id Process = $proc ResultPath = $resultPath PackPath = $packPath Pack = $Pack StartedUtc = (Get-Date).ToUniversalTime() Op = 'filter-branch' } } catch { return [pscustomobject]@{ ProcessId=0; Process=$null; ResultPath=$null; PackPath=$null; Pack=$Pack; StartError=$_.Exception.Message } } } # ------------------------------------------------------------------------------ # Poll one branch op. State: Running | Completed | Conflict | Failed # ------------------------------------------------------------------------------ function Get-SRxFilterBranchOperationState { [CmdletBinding()] param([Parameter(Mandatory)]$Operation) $state = [pscustomobject]@{ State='Running'; Success=$false; Conflict=$false; Data=$null; Error=$null; Pid=$Operation.ProcessId } if ($Operation.StartError) { $state.State='Failed'; $state.Error=$Operation.StartError; return $state } if ($Operation.ResultPath -and (Test-Path $Operation.ResultPath)) { try { $raw = Get-Content -LiteralPath $Operation.ResultPath -Raw -ErrorAction Stop if (-not [string]::IsNullOrWhiteSpace($raw)) { $obj = $raw | ConvertFrom-Json -ErrorAction Stop $state.Success = [bool]$obj.Success $state.Conflict = [bool]$obj.Conflict $state.Data = $obj.Data $state.Error = $obj.Error if ($obj.Success) { $state.State = 'Completed' } elseif ($obj.Conflict) { $state.State = 'Conflict' } else { $state.State = 'Failed' } return $state } } catch { } # mid-write; keep Running } $alive = $false try { $p = Get-Process -Id $Operation.ProcessId -ErrorAction SilentlyContinue; $alive = ($null -ne $p -and -not $p.HasExited) } catch { $alive = $false } if ($alive) { $state.State='Running'; return $state } $state.State='Failed'; $state.Error='Worker exited without a result (killed or crashed).'; return $state } # ------------------------------------------------------------------------------ function Stop-SRxFilterBranchOperation { [CmdletBinding()] param([Parameter(Mandatory)]$Operation) try { if ($Operation.ProcessId -gt 0) { $p = Get-Process -Id $Operation.ProcessId -ErrorAction SilentlyContinue if ($p -and -not $p.HasExited) { Stop-Process -Id $Operation.ProcessId -Force -ErrorAction Stop; return $true } } } catch { } return $false } # ------------------------------------------------------------------------------ function Remove-SRxFilterBranchTempFiles { [CmdletBinding()] param([Parameter(Mandatory)]$Operation) foreach ($p in @($Operation.ResultPath, $Operation.PackPath)) { try { if ($p -and (Test-Path $p)) { Remove-Item $p -Force -ErrorAction SilentlyContinue } } catch { } } } # ============================================================================== # LOCAL SYNC: patch the cached termSet.xml "Customizations" sub-tree to match the # branch just written, then regenerate the JSON copies. Returns {Success; OuterXml; # FilterNodes(slice); Error}. Runs in the WORKER process (invoked by the worker), # never on the UI thread. # # NOTE: this is the branch-aware analogue of Sync-SRxLocalTermBatch. It locates the # hosting Customizations block for the pack's layer, then applies the branch. The # exact XPath layout mirrors CustomizationWriter.setProvisioningBlock; the helper # Find-SRxCustomizationsHost encapsulates that so it can be adjusted in one place. # ============================================================================== function Sync-SRxLocalFilterBranch { [CmdletBinding()] param([Parameter(Mandatory)] $Pack) $result = [pscustomobject]@{ Success=$false; Error=$null; OuterXml=$null; FilterNodes=@() } try { # normalize pack (may arrive as PSCustomObject from JSON) $scope = [int](Get-SRxProp $Pack 'Scope' 0) $operation = [string](Get-SRxProp $Pack 'Operation' 'add') $branch = Get-SRxProp $Pack 'Branch' @() $profileFolder = Get-SRxProfileFolderPath $schemaPath = Join-Path $profileFolder 'termSet.xml' if (-not (Test-Path $schemaPath)) { throw "termSet.xml not found at $schemaPath" } [xml]$xml = Get-Content -LiteralPath $schemaPath -Raw -Encoding UTF8 $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) $ns.AddNamespace('pnp', $script:SRxPnpNs) # Locate/create the Customizations host term/termset for this layer. $host1 = Find-SRxCustomizationsHost -Xml $xml -Ns $ns -Pack $Pack if ($null -eq $host1) { throw "Customizations host not found for scope $scope (Pack Environment/Design/TermID)." } # Apply the branch under $host1: create/update the path of pnp:Term nodes; the # leaf carries the customization (Attributes / ts_Deprecated / ts_CustomizationTarget). $leafNode = Apply-SRxBranch -Xml $xml -Ns $ns -HostNode $host1 -Branch $branch -Operation $operation $xml.Save($schemaPath) # regenerate JSON copies (identical pipeline to Save-SRxProfile) $jsonPath = Join-Path $profileFolder 'ProvisioningDatabase.json' $minPath = Join-Path $profileFolder 'ProvisioningDatabase.min.json' [xml[]] (Get-Content -Raw -Encoding UTF8 -Path $schemaPath) ` | Convert-SRxFromXML | ConvertTo-Json -Depth 50 | Format-SRxJson -Indentation 1 ` | Set-Content -LiteralPath $jsonPath -Encoding UTF8 [xml[]] (Get-Content -Raw -Encoding UTF8 -Path $schemaPath) ` | Convert-SRxFromXML | ConvertTo-Json -Depth 50 | Format-SRxJson -Minify ` | Set-Content -LiteralPath $minPath -Encoding UTF8 $result.Success = $true $result.OuterXml = if ($leafNode) { $leafNode.OuterXml } else { $null } # FilterNodes slice for this branch (so the UI can update _filterNodes in-memory) $result.FilterNodes = @( [pscustomobject]@{ xPath = [string](Get-SRxProp ($branch | Select-Object -Last 1) 'XPath' '') Scope = $scope optype = [int](Get-SRxProp $Pack 'Optype' -1) Filter = $operation } ) return $result } catch { $result.Error = $_.Exception.Message; return $result } } # --- Customizations host + branch application (encapsulated for easy adjustment) --- # These mirror CustomizationWriter.setProvisioningBlock / branch walk. Kept as separate # functions so the exact XPath can be tuned in ONE place once verified against real data. function Find-SRxCustomizationsHost { param([xml]$Xml, $Ns, $Pack) # Preferred: locate by the hosting term's ID, then its child "Customizations" term/termset. $termId = [string](Get-SRxProp $Pack 'TermID' '') if ($termId) { $hostTerm = $Xml.SelectSingleNode("//pnp:Term[@ID='$termId']", $Ns) if ($hostTerm) { $cust = $hostTerm.SelectSingleNode("pnp:Terms/pnp:Term[@Name='Customizations']", $Ns) if (-not $cust) { $termsEl = $hostTerm.SelectSingleNode('pnp:Terms', $Ns) if (-not $termsEl) { $termsEl = $Xml.CreateElement('pnp','Terms',$script:SRxPnpNs); [void]$hostTerm.AppendChild($termsEl) } $cust = $Xml.CreateElement('pnp','Term',$script:SRxPnpNs); $cust.SetAttribute('Name','Customizations'); [void]$termsEl.AppendChild($cust) } return $cust } } return $null } function Apply-SRxBranch { param([xml]$Xml, $Ns, $HostNode, $Branch, [string]$Operation) $current = $HostNode $leaf = $null $count = @($Branch).Count $i = 0 foreach ($d in @($Branch)) { $i++ $name = [string](Get-SRxProp $d 'Name' '') if ([string]::IsNullOrEmpty($name)) { continue } $termsEl = $current.SelectSingleNode('pnp:Terms', $Ns) if (-not $termsEl) { $termsEl = $Xml.CreateElement('pnp','Terms',$script:SRxPnpNs); [void]$current.AppendChild($termsEl) } $child = $termsEl.SelectSingleNode("pnp:Term[@Name=`"$name`"]", $Ns) $isLeaf = ($i -eq $count) if ($Operation -eq 'remove' -and $isLeaf) { if ($child) { [void]$termsEl.RemoveChild($child) } $leaf = $null break } if (-not $child) { $child = $Xml.CreateElement('pnp','Term',$script:SRxPnpNs) $child.SetAttribute('Name',$name) [void]$termsEl.AppendChild($child) } if ($isLeaf) { # write custom properties on the leaf (attributes + ts_ flags), chunked $cp = $child.SelectSingleNode('pnp:CustomProperties', $Ns) if ($cp) { [void]$child.RemoveChild($cp) } $attrs = Get-SRxProp $d 'Attributes' @{} $attrHash = @{}; if ($attrs -is [hashtable]) { foreach ($k in $attrs.Keys) { $attrHash[$k]=[string]$attrs[$k] } } elseif ($null -ne $attrs) { foreach ($p in $attrs.PSObject.Properties) { $attrHash[$p.Name]=[string]$p.Value } } if ((Get-SRxProp $d 'Deprecated' $false)) { $attrHash['ts_Deprecated'] = 'true' } if ((Get-SRxProp $d 'CustomizationTarget' $false)) { $attrHash['ts_CustomizationTarget'] = 'true' } if ($attrHash.Count -gt 0) { $cpEl = $Xml.CreateElement('pnp','CustomProperties',$script:SRxPnpNs) foreach ($k in ($attrHash.Keys | Sort-Object)) { $v = [string]$attrHash[$k] if ($v.Length -le $script:SRxChunkSize) { $pe = $Xml.CreateElement('pnp','Property',$script:SRxPnpNs); $pe.SetAttribute('Key',$k); $pe.SetAttribute('Value',$v); [void]$cpEl.AppendChild($pe) } else { $parts = @($v -split ("(.{$($script:SRxChunkSize)})") | Where-Object { $_ }) for ([int]$j=0; $j -lt $parts.Count; $j++) { $pe = $Xml.CreateElement('pnp','Property',$script:SRxPnpNs); $pe.SetAttribute('Key',"$($k)_$($j)"); $pe.SetAttribute('Value',$parts[$j]); [void]$cpEl.AppendChild($pe) } } } [void]$child.AppendChild($cpEl) } $leaf = $child } $current = $child } return $leaf } <# ================================================================================ SRxFilterBox.JSONSync.patch.ps1 ----------------------------------------------------------------------------- Add Sync-SRxLocalJsonCustomization to SRxFilterBox.psm1: patch the LOCAL termSet.xml so the single JSON container term (create/update/delete) matches the remote write, then regenerate the JSON cache copies. Single-term operation - far simpler than the branch sub-tree patch (analogous to the Property Box). ONE edit: paste the function into SRxFilterBox.psm1 (before Export-ModuleMember) and ADD its name to the Export-ModuleMember list. Requires SRxCustomizationJson.psm1 (for New-SRxJsonContainerTerm + Split helper). SRxFilterBox.psm1 should import it once at top: Import-Module (Join-Path $PSScriptRoot 'SRxCustomizationJson.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue ================================================================================ #> function Sync-SRxLocalJsonCustomization { [CmdletBinding()] param( [Parameter(Mandatory)] $Pack, [Parameter(Mandatory)] [string]$Operation ) $result = [pscustomobject]@{ Success=$false; Error=$null; OuterXml=$null; FilterNodes=@() } try { $scope = [int](Get-SRxProp $Pack 'Scope' 0) $profileFolder = Get-SRxProfileFolderPath $schemaPath = Join-Path $profileFolder 'termSet.xml' if (-not (Test-Path $schemaPath)) { throw "termSet.xml not found at $schemaPath" } [xml]$xml = Get-Content -LiteralPath $schemaPath -Raw -Encoding UTF8 $ns = New-Object System.Xml.XmlNamespaceManager($xml.NameTable) $ns.AddNamespace('pnp', $script:SRxPnpNs) # locate/create the Customizations host for this layer (reuse the branch helper) $host1 = Find-SRxCustomizationsHost -Xml $xml -Ns $ns -Pack $Pack if ($null -eq $host1) { throw "Customizations host not found for scope $scope." } # build the container term descriptor (same Name/Key/props as the remote write) $container = New-SRxJsonContainerTerm -Pack $Pack -Operation $Operation $name = [string]$container.Name $termsEl = $host1.SelectSingleNode('pnp:Terms', $ns) if (-not $termsEl) { $termsEl = $xml.CreateElement('pnp','Terms',$script:SRxPnpNs); [void]$host1.AppendChild($termsEl) } $existing = $termsEl.SelectSingleNode("pnp:Term[@Name=`"$name`"]", $ns) if ($Operation -eq 'remove') { if ($existing) { [void]$termsEl.RemoveChild($existing) } $leaf = $null } else { if ($existing) { [void]$termsEl.RemoveChild($existing) } # replace wholesale $term = $xml.CreateElement('pnp','Term',$script:SRxPnpNs) $term.SetAttribute('Name', $name) # custom properties: activity/format/key + chunked ts_CustomizationJson $cp = $xml.CreateElement('pnp','CustomProperties',$script:SRxPnpNs) $props = [hashtable]$container.Attributes # ts_ProvisioningActivity/Format/Key + ts_CustomizationJson (single big value) $flat = @{} foreach ($k in $props.Keys) { $v = [string]$props[$k] if ($k -eq 'ts_CustomizationJson') { $chunked = Split-SRxJsonProperty -BaseKey 'ts_CustomizationJson' -Value $v foreach ($ck in $chunked.Keys) { $flat[$ck] = [string]$chunked[$ck] } } else { $flat[$k] = $v } } foreach ($k in ($flat.Keys | Sort-Object)) { $pe = $xml.CreateElement('pnp','Property',$script:SRxPnpNs) $pe.SetAttribute('Key', [string]$k) $pe.SetAttribute('Value', [string]$flat[$k]) [void]$cp.AppendChild($pe) } [void]$term.AppendChild($cp) [void]$termsEl.AppendChild($term) $leaf = $term } $xml.Save($schemaPath) # regenerate JSON copies (identical pipeline to Save-SRxProfile) $jsonPath = Join-Path $profileFolder 'ProvisioningDatabase.json' $minPath = Join-Path $profileFolder 'ProvisioningDatabase.min.json' [xml[]] (Get-Content -Raw -Encoding UTF8 -Path $schemaPath) ` | Convert-SRxFromXML | ConvertTo-Json -Depth 50 | Format-SRxJson -Indentation 1 ` | Set-Content -LiteralPath $jsonPath -Encoding UTF8 [xml[]] (Get-Content -Raw -Encoding UTF8 -Path $schemaPath) ` | Convert-SRxFromXML | ConvertTo-Json -Depth 50 | Format-SRxJson -Minify ` | Set-Content -LiteralPath $minPath -Encoding UTF8 $result.Success = $true $result.OuterXml = if ($leaf) { $leaf.OuterXml } else { $null } $result.FilterNodes = @( [pscustomobject]@{ xPath = [string]$container.xPath Scope = $scope optype = [int]$container.optype Filter = $Operation } ) return $result } catch { $result.Error = $_.Exception.Message; return $result } } Export-ModuleMember -Function ` Get-SRxProfileFolderPath, ` Get-SRxEffectiveFilter, ` Start-SRxFilterBranchOperation, ` Get-SRxFilterBranchOperationState, ` Stop-SRxFilterBranchOperation, ` Remove-SRxFilterBranchTempFiles, ` Sync-SRxLocalJsonCustomization, ` Sync-SRxLocalFilterBranch |