PropertyBox/SRxPropertyBox.psm1
|
$script:SRxPnpNs = 'http://schemas.dev.office.com/PnP/2022/09/ProvisioningSchema' $script:SRxChunkSize = 9000 function Get-SRxProfileFolderPath { [CmdletBinding()] param() $sgroupName = Remove-SRxStringSpecialCharacter -String $global:SRxEnv.Tenancy.TermGroupName return (Join-Path (Join-Path $global:SRxEnv.CachePath 'Profiles') $sgroupName) } function ConvertTo-SRxLogicalProps { [CmdletBinding()] param([Parameter(Mandatory)][hashtable]$Raw) $logical = @{} $chunks = @{} foreach ($k in $Raw.Keys) { if ($k -match '^(?<base>.+)_(?<idx>\d+)$') { $base = $Matches['base']; $idx = [int]$Matches['idx'] if (-not $chunks.ContainsKey($base)) { $chunks[$base] = @{} } $chunks[$base][$idx] = [string]$Raw[$k] } else { $logical[$k] = [string]$Raw[$k] } } foreach ($base in $chunks.Keys) { $sb = New-Object System.Text.StringBuilder foreach ($i in ($chunks[$base].Keys | Sort-Object)) { [void]$sb.Append($chunks[$base][$i]) } $logical[$base] = $sb.ToString() } return $logical } function Split-SRxChunkedProperties { [CmdletBinding()] param( [Parameter(Mandatory)][hashtable]$Logical, [int]$ChunkSize = $script:SRxChunkSize ) $out = @{} foreach ($k in $Logical.Keys) { $v = [string]$Logical[$k] if ($v.Length -le $ChunkSize) { $out[$k] = $v } else { $parts = @($v -split ("(.{$ChunkSize})") | Where-Object { $_ }) for ([int]$i = 0; $i -lt $parts.Count; $i++) { $out["$($k)_$($i)"] = $parts[$i] } } } return $out } function Start-SRxTermBatchOperation { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TermId, [Parameter(Mandatory)] [hashtable]$DesiredLogical, [Parameter(Mandatory=$false)] [hashtable]$OriginalLogical = @{}, [Parameter(Mandatory=$false)] [AllowEmptyString()] [string]$BaselineModified = '', [Parameter(Mandatory)] [string]$SourcePath, [Parameter(Mandatory=$false)] [switch]$Force, [Parameter(Mandatory=$false)] [switch]$HideWindow ) try { $worker = $("$PSScriptRoot\Write-SRxTermBatch.ps1") if (-not (Test-Path $worker)) { throw "Worker not found: $worker" } $desiredChunked = Split-SRxChunkedProperties -Logical $DesiredLogical $desiredJson = ($desiredChunked | ConvertTo-Json -Depth 5 -Compress) $originalJson = ($OriginalLogical | ConvertTo-Json -Depth 5 -Compress) if ([string]::IsNullOrWhiteSpace($desiredJson)) { $desiredJson = '{}' } if ([string]::IsNullOrWhiteSpace($originalJson)) { $originalJson = '{}' } $tag = ("{0:yyyyMMddHHmmssfff}-{1}" -f (Get-Date), ([guid]::NewGuid().ToString('N').Substring(0,8))) $resultPath = Join-Path $env:TEMP ("srx-batch-$tag.json") $desiredPath = Join-Path $env:TEMP ("srx-batch-$tag.desired.json") $originalPath = Join-Path $env:TEMP ("srx-batch-$tag.original.json") Set-Content -LiteralPath $desiredPath -Value $desiredJson -Encoding UTF8 Set-Content -LiteralPath $originalPath -Value $originalJson -Encoding UTF8 $shim = @" `$d = Get-Content -LiteralPath '$desiredPath' -Raw `$o = Get-Content -LiteralPath '$originalPath' -Raw & '$worker' -RootPath '$SourcePath' -TermId '$TermId' -DesiredSetJson `$d -OriginalPropsJson `$o -BaselineModified '$BaselineModified' -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 DesiredPath = $desiredPath OriginalPath = $originalPath TermId = $TermId DesiredLogical = $DesiredLogical StartedUtc = (Get-Date).ToUniversalTime() Op = 'batch' } } catch { return [pscustomobject]@{ ProcessId=0; Process=$null; ResultPath=$null; TermId=$TermId; StartError=$_.Exception.Message } } } function Get-SRxTermBatchOperationState { [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 { } } $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-SRxTermBatchOperation { [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-SRxBatchTempFiles { [CmdletBinding()] param([Parameter(Mandatory)]$Operation) foreach ($p in @($Operation.ResultPath, $Operation.DesiredPath, $Operation.OriginalPath)) { try { if ($p -and (Test-Path $p)) { Remove-Item $p -Force -ErrorAction SilentlyContinue } } catch { } } } function Sync-SRxLocalTermBatch { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TermId, [Parameter(Mandatory)] $AfterRaw ) $result = [pscustomobject]@{ Success=$false; Error=$null; CustomProperties=@{}; OuterXml=$null } try { $raw = @{} if ($AfterRaw -is [hashtable]) { foreach ($k in $AfterRaw.Keys) { $raw[$k] = [string]$AfterRaw[$k] } } else { foreach ($p in $AfterRaw.PSObject.Properties) { $raw[$p.Name] = [string]$p.Value } } $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) $termNode = $xml.SelectSingleNode("//pnp:Term[@ID='$TermId']", $ns) if (-not $termNode) { throw "Term node ID='$TermId' not found in termSet.xml" } $cpNode = $termNode.SelectSingleNode('pnp:CustomProperties', $ns) if ($cpNode) { [void]$termNode.RemoveChild($cpNode) } if ($raw.Count -gt 0) { $cpNode = $xml.CreateElement('pnp', 'CustomProperties', $script:SRxPnpNs) [void]$termNode.AppendChild($cpNode) foreach ($k in ($raw.Keys | Sort-Object)) { $prop = $xml.CreateElement('pnp', 'Property', $script:SRxPnpNs) $prop.SetAttribute('Key', $k) $prop.SetAttribute('Value', $raw[$k]) [void]$cpNode.AppendChild($prop) } } $xml.Save($schemaPath) $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 if (Get-Command Connect-SRxProvisioningDatabase_JSON -ErrorAction SilentlyContinue) { Connect-SRxProvisioningDatabase_JSON | Out-Null } $result.Success = $true $result.CustomProperties = ConvertTo-SRxLogicalProps -Raw $raw $result.OuterXml = $termNode.OuterXml return $result } catch { $result.Error = $_.Exception.Message; return $result } } function Start-SRxOperationTermOperation { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('New', 'Delete')] [string]$Action, [Parameter(Mandatory=$false)] [string]$TermId = '', [Parameter(Mandatory=$false)] [string]$ParentTermId = '', [Parameter(Mandatory=$false)] [string]$Name = '', [Parameter(Mandatory=$false)] [hashtable]$DesiredLogical = @{}, [Parameter(Mandatory)] [string]$SourcePath, [Parameter(Mandatory=$false)] [switch]$HideWindow ) try { $worker = $("$PSScriptRoot\Write-SRxOperationTerm.ps1") if (-not (Test-Path $worker)) { throw "Worker not found: $worker" } if ($Action -eq 'New' -and ([string]::IsNullOrEmpty($ParentTermId) -or [string]::IsNullOrWhiteSpace($Name))) { throw 'New: ParentTermId and Name are required' } if ($Action -eq 'Delete' -and [string]::IsNullOrEmpty($TermId)) { throw 'Delete: TermId is required' } if ($Action -eq 'New' -and [string]::IsNullOrEmpty($TermId)) { $TermId = [guid]::NewGuid().ToString() } $desiredChunked = Split-SRxChunkedProperties -Logical $DesiredLogical $desiredJson = ($desiredChunked | ConvertTo-Json -Depth 5 -Compress) if ([string]::IsNullOrWhiteSpace($desiredJson)) { $desiredJson = '{}' } $tag = ("{0:yyyyMMddHHmmssfff}-{1}" -f (Get-Date), ([guid]::NewGuid().ToString('N').Substring(0,8))) $resultPath = Join-Path $env:TEMP ("srx-opterm-$tag.json") $desiredPath = Join-Path $env:TEMP ("srx-opterm-$tag.desired.json") $namePath = Join-Path $env:TEMP ("srx-opterm-$tag.name.txt") Set-Content -LiteralPath $desiredPath -Value $desiredJson -Encoding UTF8 Set-Content -LiteralPath $namePath -Value $Name -Encoding UTF8 -NoNewline $shim = @" `$d = Get-Content -LiteralPath '$desiredPath' -Raw `$n = Get-Content -LiteralPath '$namePath' -Raw & '$worker' -RootPath '$SourcePath' -Action '$Action' -TermId '$TermId' -ParentTermId '$ParentTermId' -Name `$n -DesiredSetJson `$d -ResultPath '$resultPath' "@ $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 DesiredPath = $desiredPath OriginalPath = $namePath TermId = $TermId ParentTermId = $ParentTermId Name = $Name Action = $Action DesiredLogical = $DesiredLogical StartedUtc = (Get-Date).ToUniversalTime() Op = 'opterm' } } catch { return [pscustomobject]@{ ProcessId=0; Process=$null; ResultPath=$null; TermId=$TermId; Action=$Action; StartError=$_.Exception.Message } } } function Sync-SRxLocalTermStructure { [CmdletBinding()] param( [Parameter(Mandatory)] [ValidateSet('New', 'Delete')] [string]$Action, [Parameter(Mandatory)] [string]$TermId, [Parameter(Mandatory=$false)] [string]$ParentTermId = '', [Parameter(Mandatory=$false)] [string]$Name = '', [Parameter(Mandatory=$false)] $Raw = @{} ) $result = [pscustomobject]@{ Success=$false; Error=$null; OuterXml=$null } try { $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) if ($Action -eq 'Delete') { $node = $xml.SelectSingleNode("//pnp:Term[@ID='$TermId']", $ns) if ($null -ne $node) { [void]$node.ParentNode.RemoveChild($node) } } else { $parent = $xml.SelectSingleNode("//pnp:Term[@ID='$ParentTermId']", $ns) if (-not $parent) { throw "Parent term ID='$ParentTermId' not found in termSet.xml" } $terms = $parent.SelectSingleNode('pnp:Terms', $ns) if (-not $terms) { $terms = $xml.CreateElement('pnp', 'Terms', $script:SRxPnpNs) [void]$parent.PrependChild($terms) } $existing = $xml.SelectSingleNode("//pnp:Term[@ID='$TermId']", $ns) if ($null -ne $existing) { [void]$existing.ParentNode.RemoveChild($existing) } $sibling = $terms.SelectSingleNode('pnp:Term', $ns) $node = $xml.CreateElement('pnp', 'Term', $script:SRxPnpNs) if ($null -ne $sibling) { foreach ($a in $sibling.Attributes) { if ($a.Name -in @('Name', 'ID', 'SourceTermId')) { continue } $node.SetAttribute($a.Name, $a.Value) } } $node.SetAttribute('Name', $Name) $node.SetAttribute('ID', $TermId) if ($null -ne $sibling -and $sibling.HasAttribute('SourceTermId')) { $node.SetAttribute('SourceTermId', $TermId) } $raw = @{} if ($Raw -is [hashtable]) { foreach ($k in $Raw.Keys) { $raw[$k] = [string]$Raw[$k] } } elseif ($null -ne $Raw) { foreach ($p in $Raw.PSObject.Properties) { $raw[$p.Name] = [string]$p.Value } } if ($raw.Count -gt 0) { $cp = $xml.CreateElement('pnp', 'CustomProperties', $script:SRxPnpNs) foreach ($k in ($raw.Keys | Sort-Object)) { $prop = $xml.CreateElement('pnp', 'Property', $script:SRxPnpNs) $prop.SetAttribute('Key', $k) $prop.SetAttribute('Value', $raw[$k]) [void]$cp.AppendChild($prop) } [void]$node.AppendChild($cp) } [void]$terms.AppendChild($node) $result.OuterXml = $node.OuterXml } $xml.Save($schemaPath) $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 return $result } catch { $result.Error = $_.Exception.Message; return $result } } Export-ModuleMember -Function ` Start-SRxOperationTermOperation, ` Sync-SRxLocalTermStructure, ` Get-SRxProfileFolderPath, ` ConvertTo-SRxLogicalProps, ` Split-SRxChunkedProperties, ` Start-SRxTermBatchOperation, ` Get-SRxTermBatchOperationState, ` Stop-SRxTermBatchOperation, ` Remove-SRxBatchTempFiles, ` Sync-SRxLocalTermBatch |