PropertyBox/Write-SRxTermBatch.ps1
|
<# .SYNOPSIS Iteration-2/3 isolated worker: commit a WHOLE-NODE custom-property set to the remote O365 Termstore in one atomic batch, with WORKER-SIDE optimistic concurrency (full-state drift guard + optional timestamp), under the Entra App Registration identity (PnP CSOM, app-only certificate). FIX (2026-09-06): the whole-node replace no longer calls Set-PnPTerm -Identity ([GUID]$TermId) -DeleteAllCustomProperties ... which threw System.NullReferenceException inside SetTerm.ExecuteCmdlet() when re-resolving a term from a bare GUID in an app-only taxonomy session. We instead mutate the ALREADY-LOADED $term CSOM object directly (DeleteAllCustomProperties + SetCustomProperty) and commit with Invoke-PnPQuery - the proven app-only pattern already used by CustomizationWriter / Update-SRxTermCustomProperty in SRxCore. #> [CmdletBinding()] param( [Parameter(Mandatory)] [string]$RootPath, [Parameter(Mandatory)] [string]$TermId, [Parameter(Mandatory)] [string]$DesiredSetJson, [Parameter(Mandatory=$false)] [string]$OriginalPropsJson = '{}', [Parameter(Mandatory=$false)] [AllowEmptyString()] [string]$BaselineModified = '', [Parameter(Mandatory)] [string]$ResultPath, [Parameter(Mandatory=$false)] [switch]$Force ) $ErrorActionPreference = 'Stop' function Write-SRxWorkerResult { param([bool]$Success, [bool]$Conflict, $Data, [string]$ErrorText) $payload = [pscustomobject]@{ Success = $Success Conflict = $Conflict Data = $Data Error = $ErrorText TermId = $TermId Pid = $PID Ended = (Get-Date).ToString('o') } $json = $payload | ConvertTo-Json -Depth 25 -Compress try { $dir = Split-Path -Parent $ResultPath if ($dir -and -not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } Set-Content -LiteralPath $ResultPath -Value $json -Encoding UTF8 } catch { } Write-Host ("__JSON__" + $json) } function ConvertTo-SRxLogicalProps { param([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 ConvertFrom-SRxJsonToHashtable { param([string]$Json) $ht = @{} if ([string]::IsNullOrWhiteSpace($Json)) { return $ht } $obj = $Json | ConvertFrom-Json foreach ($p in $obj.PSObject.Properties) { $ht[$p.Name] = [string]$p.Value } return $ht } try { $externalScript = Join-Path $RootPath 'loadmodule.ps1' . $externalScript if ($null -ne $LoadModule) { $LoadModule.Invoke("SRxCore") | Out-Null } Initialize-SRxEnv -LoadModule2 $LoadModule -RootPath $RootPath | Out-Null Connect-SRxSPOService if ($null -eq $global:SRxEnv) { if (Get-Command Start-SRxShell -ErrorAction SilentlyContinue) { Start-SRxShell -RootPath $RootPath -isJob | Out-Null } elseif (Get-Command Initialize-SRxEnv -ErrorAction SilentlyContinue) { Initialize-SRxEnv -RootPath $RootPath | Out-Null } else { throw "SRx environment could not be initialised from '$RootPath'." } } if ($null -eq $global:SRxEnv -or $null -eq $global:SRxEnv.Tenancy) { throw "global:SRxEnv or global:SRxEnv.Tenancy is null after bootstrap." } # so we can run Sync-SRxLocalTermBatch here in the child process: try { Import-Module (Join-Path $PSScriptRoot 'SRxPropertyBox.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue } catch { } #try { Import-Module ($("$PSScriptRoot\SRxPropertyBox.psm1")) -DisableNameChecking -Force } #catch { Write-Host "SRxPropertyBox import failed: $($_.Exception.Message)" } $connection = Get-SRxConnection -siteUrl $global:SRxEnv.Tenancy.AdminUrl -Termstore -Supress if (-not $connection) { throw "Failed to establish app-only PnP connection to the Termstore." } $term = Get-PnPTerm -Identity ([GUID]$TermId) -Connection $connection -ErrorAction Stop Get-PnPProperty -ClientObject $term -Property Id, Name, CustomProperties, LastModifiedDate -Connection $connection | Out-Null $remoteRaw = @{} if ($null -ne $term.CustomProperties) { foreach ($k in $term.CustomProperties.Keys) { $remoteRaw[$k] = [string]$term.CustomProperties[$k] } } $remoteLogical = ConvertTo-SRxLogicalProps -Raw $remoteRaw $remoteModified = $null try { $remoteModified = $term.LastModifiedDate.ToString('o') } catch { } $desired = ConvertFrom-SRxJsonToHashtable -Json $DesiredSetJson $original = ConvertFrom-SRxJsonToHashtable -Json $OriginalPropsJson if (-not $Force) { $conflictReasons = @() if (-not [string]::IsNullOrWhiteSpace($BaselineModified) -and $remoteModified) { try { $baseDt = [datetime]::Parse($BaselineModified).ToUniversalTime() $remDt = [datetime]::Parse($remoteModified).ToUniversalTime() if ($remDt -gt $baseDt) { $conflictReasons += "Term was modified remotely ($remoteModified) after baseline ($BaselineModified)." } } catch { } } $allKeys = New-Object System.Collections.Generic.HashSet[string] foreach ($k in $original.Keys) { [void]$allKeys.Add([string]$k) } foreach ($k in $remoteLogical.Keys) { [void]$allKeys.Add([string]$k) } foreach ($k in $allKeys) { $inOrig = $original.ContainsKey($k) $inRem = $remoteLogical.ContainsKey($k) if (-not $inRem -and $inOrig) { $conflictReasons += "Key '$k' was deleted remotely (you based edits on '$($original[$k])')." } elseif ($inRem -and -not $inOrig) { $conflictReasons += "Key '$k' was added remotely (remote='$($remoteLogical[$k])')." } elseif ($inRem -and $inOrig -and ($remoteLogical[$k] -ne $original[$k])) { $conflictReasons += "Key '$k' changed remotely (remote='$($remoteLogical[$k])', you based edits on '$($original[$k])')." } } if ($conflictReasons.Count -gt 0) { $data = [pscustomobject]@{ TermId = $TermId RemoteProps = $remoteLogical RemoteModified = $remoteModified Reasons = $conflictReasons } Write-SRxWorkerResult -Success $false -Conflict $true -Data $data -ErrorText ($conflictReasons -join ' ') exit 2 } } # whole-node replace on the already-loaded $term (avoids Set-PnPTerm NRE) $desiredHt = @{} foreach ($k in $desired.Keys) { $desiredHt[$k] = [string]$desired[$k] } $term.DeleteAllCustomProperties() foreach ($k in $desiredHt.Keys) { $term.SetCustomProperty($k, [string]$desiredHt[$k]) } Invoke-PnPQuery -Connection $connection -ErrorAction Stop # re-read authoritative post-state $after = Get-PnPTerm -Identity ([GUID]$TermId) -Connection $connection -ErrorAction Stop Get-PnPProperty -ClientObject $after -Property CustomProperties, LastModifiedDate -Connection $connection | Out-Null $afterRaw = @{} if ($null -ne $after.CustomProperties) { foreach ($k in $after.CustomProperties.Keys) { $afterRaw[$k] = [string]$after.CustomProperties[$k] } } $afterModified = $null try { $afterModified = $after.LastModifiedDate.ToString('o') } catch { } # ---- LOCAL SYNC IN THE CHILD PROCESS (the heavy work, off the UI thread) ---- $afterLogical = ConvertTo-SRxLogicalProps -Raw $afterRaw $afterOuterXml = $null $syncWarning = $null try { if (Get-Command Sync-SRxLocalTermBatch -ErrorAction SilentlyContinue) { $sync = Sync-SRxLocalTermBatch -TermId $TermId -AfterRaw $afterRaw if ($sync.Success) { if ($sync.OuterXml) { $afterOuterXml = $sync.OuterXml } if ($sync.CustomProperties) { $afterLogical = $sync.CustomProperties } } else { $syncWarning = "Local sync failed: $($sync.Error)" } } else { $syncWarning = "Sync-SRxLocalTermBatch not available in worker; local cache not updated." } } catch { $syncWarning = "Local sync threw: $($_.Exception.Message)" } $data = [pscustomobject]@{ TermId = $TermId PropsAfterRaw = $afterRaw PropsAfter = $afterLogical OuterXml = $afterOuterXml ModifiedAfter = $afterModified SyncWarning = $syncWarning } Write-SRxWorkerResult -Success $true -Conflict $false -Data $data -ErrorText $null exit 0 } catch { $msg = $_.Exception.Message if ($_.Exception.InnerException) { $msg += " | inner: " + $_.Exception.InnerException.Message } Write-SRxWorkerResult -Success $false -Conflict $false -Data $null -ErrorText $msg exit 1 } |