PropertyBox/SRxPropertyBox.psm1
|
<# ================================================================================ SRxPropertyBox.psm1 (Iteration 2) ----------------------------------------------------------------------------- Orchestration + local reconciliation for the WRITABLE, BATCH "Term custom properties" box. Runs in the PS 5.1 Explorer UI process. Actual remote writes happen in isolated per-node child processes (Write-SRxTermBatch.ps1) so the UI never blocks and every batch is a killable PID tracked on its treeview node. Public surface: # chunking helpers (grid <-> store) ConvertTo-SRxLogicalProps coalesce key_0,key_1,... -> single logical value Split-SRxChunkedProperties split long values -> key_0,key_1,... (9000 chars) # batch lifecycle (per node) Start-SRxTermBatchOperation launch worker -> {ProcessId, ResultPath, ...} Get-SRxTermBatchOperationState poll worker -> {State, Success, Conflict, Data, Error} Stop-SRxTermBatchOperation kill one batch by PID # local reconciliation (whole-node replace) Sync-SRxLocalTermBatch on success: replace node's props in XML+JSON, reload # path helper (same as iteration 1) Get-SRxProfileFolderPath ================================================================================ #> $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) } # ------------------------------------------------------------------------------ # Coalesce chunk keys (key_0,key_1,...) -> single logical value. # ------------------------------------------------------------------------------ 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]) } # a base that also had a non-chunk value (rare) is overwritten by the concatenation $logical[$base] = $sb.ToString() } return $logical } # ------------------------------------------------------------------------------ # Split long values (> chunk size) into key_0,key_1,... ; short values pass through. # ------------------------------------------------------------------------------ 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 } # ------------------------------------------------------------------------------ # Launch the isolated per-node batch worker. Non-blocking. # DesiredLogical = complete desired set (logical, pre-chunk). OriginalLogical = # user's pre-edit originals for the property-level concurrency guard. # ------------------------------------------------------------------------------ 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 = Join-Path $SourcePath 'Write-SRxTermBatch.ps1' $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") # pass big JSON via files to avoid command-line length limits Set-Content -LiteralPath $desiredPath -Value $desiredJson -Encoding UTF8 Set-Content -LiteralPath $originalPath -Value $originalJson -Encoding UTF8 # Worker reads DesiredSetJson/OriginalPropsJson from files via Get-Content indirection # done in a tiny -Command shim so we keep Write-SRxTermBatch.ps1 signature clean. $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 } } } # ------------------------------------------------------------------------------ # Poll one batch. State: Running | Completed | Conflict | Failed # ------------------------------------------------------------------------------ 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 { } # 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-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 } # ------------------------------------------------------------------------------ # Cleanup a finished operation's temp files. # ------------------------------------------------------------------------------ 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 { } } } # ------------------------------------------------------------------------------ # Whole-node local reconciliation after a successful batch. Replaces the term's # ENTIRE CustomProperties element in termSet.xml with $AfterRaw (chunk keys as # returned by the store), regenerates JSON copies, reloads the in-memory DB. # Returns the logical property hashtable for grid/node refresh. # ------------------------------------------------------------------------------ function Sync-SRxLocalTermBatch { [CmdletBinding()] param( [Parameter(Mandatory)] [string]$TermId, [Parameter(Mandatory)] $AfterRaw # hashtable OR PSCustomObject of raw props (incl. chunks) ) $result = [pscustomobject]@{ Success=$false; Error=$null; CustomProperties=@{}; OuterXml=$null } try { # normalize AfterRaw to a hashtable $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" } # Replace the whole CustomProperties element. $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) # 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 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 } } #begin Jobs Phase 7 =========================================================== # Job templates ('operation' terms): create / delete a term in the Termstore # in an isolated child process (Write-SRxOperationTerm.ps1), like the property # batch. The worker also updates the local termSet.xml + JSON copies. # -Action New : -ParentTermId (the Operations term), -Name, -DesiredLogical # -Action Delete : -TermId # The returned operation is polled with Get-SRxTermBatchOperationState; on # success Data = { Action; TermId; ParentTermId; Name; Props; SyncWarning }. # ------------------------------------------------------------------------------ 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 } } } # termSet.xml + JSON copies after a structural change (term added / removed). # -Action New : a Term element (Name, ID, attributes copied from a sibling, # CustomProperties = $Raw) appended under -ParentTermId # -Action Delete : the Term element -TermId removed 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 } } #end Jobs Phase 7 ============================================================ Export-ModuleMember -Function ` Start-SRxOperationTermOperation, ` Sync-SRxLocalTermStructure, ` Get-SRxProfileFolderPath, ` ConvertTo-SRxLogicalProps, ` Split-SRxChunkedProperties, ` Start-SRxTermBatchOperation, ` Get-SRxTermBatchOperationState, ` Stop-SRxTermBatchOperation, ` Remove-SRxBatchTempFiles, ` Sync-SRxLocalTermBatch |