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 } } Export-ModuleMember -Function ` Get-SRxProfileFolderPath, ` ConvertTo-SRxLogicalProps, ` Split-SRxChunkedProperties, ` Start-SRxTermBatchOperation, ` Get-SRxTermBatchOperationState, ` Stop-SRxTermBatchOperation, ` Remove-SRxBatchTempFiles, ` Sync-SRxLocalTermBatch |