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