FilterBox/SRxFilterBox.psm1

<#
================================================================================
  SRxFilterBox.psm1 (Iteration 1)
  -----------------------------------------------------------------------------
  Orchestration + local reconciliation + effective-filter merge for the Filter Box.
  Runs in the PS 5.1 TemplateExplorer UI process. The remote branch write happens in
  an isolated child process (Write-SRxFilterBranch.ps1), so the UI never blocks and
  every batch is a killable PID tracked on its customizer-tree node.

  Mirrors SRxPropertyBox.psm1 (proven), adapted from single-term to term-BRANCH.

  Public surface:
    # branch lifecycle (per node)
    Start-SRxFilterBranchOperation launch worker -> {ProcessId, ResultPath, ...}
    Get-SRxFilterBranchOperationState poll worker -> {State, Success, Conflict, Data, Error}
    Stop-SRxFilterBranchOperation kill one batch by PID
    Remove-SRxFilterBranchTempFiles cleanup a finished op's temp files

    # local reconciliation (customizations sub-tree)
    Sync-SRxLocalFilterBranch patch termSet.xml Customizations branch + regen JSON

    # pure merge (4-layer priority stack)
    Get-SRxEffectiveFilter merge LayerFilters(0..3) + Draft -> effective filter

    # helpers
    Get-SRxProfileFolderPath
================================================================================
#>


$script:SRxPnpNs     = 'http://schemas.dev.office.com/PnP/2022/09/ProvisioningSchema'
$script:SRxChunkSize = 9000

# Bring the shared JSON-customization helpers into THIS module's scope so
# Sync-SRxLocalJsonCustomization / New-SRxJsonContainerTerm resolve.
Import-Module (Join-Path $PSScriptRoot 'SRxCustomizationJson.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue

# ------------------------------------------------------------------------------
function Get-SRxProfileFolderPath {
    [CmdletBinding()] param()
    $sgroupName = Remove-SRxStringSpecialCharacter -String $global:SRxEnv.Tenancy.TermGroupName
    return (Join-Path (Join-Path $global:SRxEnv.CachePath 'Profiles') $sgroupName)
}

# ==============================================================================
# Get-SRxEffectiveFilter (PURE) - the single merge function replacing the old
# 4-hashtable tangle. Layers are a priority stack: lower index = higher priority,
# applied first. A layerFilter = @{ Attributes=@{k=v}; Exclude=$bool; Include=$bool }.
# Draft (if provided) is the unsaved edit for $ActiveLayer only.
#
# Resolution rules (match Optimize-SiteTemplate intent):
# - Include (ts_CustomizationTarget) at ANY layer wins and BLOCKS Exclude below it.
# - Otherwise Exclude (ts_Deprecated) from the highest-priority layer that sets it wins.
# - Attributes merge highest-priority-first (a higher layer's value is authoritative;
# lower layers fill keys not already set).
# ==============================================================================
function Get-SRxEffectiveFilter {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory=$false)] [hashtable]$LayerFilters = @{},   # @{0=lf;1=lf;2=lf;3=lf}
        [Parameter(Mandatory=$false)] [int]$ActiveLayer = -1,
        [Parameter(Mandatory=$false)] $Draft = $null                    # @{Attributes;Exclude;Include}
    )
    if ($null -eq $LayerFilters) { $LayerFilters = @{} }

    # Build an effective view of each layer, overlaying Draft on the active layer.
    $effLayers = @{}
    foreach ($i in 0..3) {
        $lf = $null
        if ($LayerFilters.ContainsKey($i) -and $LayerFilters[$i]) { $lf = $LayerFilters[$i] }
        if ($i -eq $ActiveLayer -and $null -ne $Draft) {
            $lf = [pscustomobject]@{
                Attributes = (Merge-SRxHash (Get-SRxLayerAttrs $lf) (Get-SRxDraftAttrs $Draft))
                Exclude    = [bool](Get-SRxProp $Draft 'Exclude' (Get-SRxProp $lf 'Exclude' $false))
                Include    = [bool](Get-SRxProp $Draft 'Include' (Get-SRxProp $lf 'Include' $false))
            }
        }
        if ($null -ne $lf) { $effLayers[$i] = $lf }
    }

    # Include wins & blocks Exclude below it.
    $include = $false; $includeLayer = 99
    foreach ($i in 0..3) {
        if ($effLayers.ContainsKey($i) -and (Get-SRxProp $effLayers[$i] 'Include' $false)) {
            $include = $true; $includeLayer = $i; break
        }
    }

    $exclude = $false
    if (-not $include) {
        foreach ($i in 0..3) {
            if ($effLayers.ContainsKey($i) -and (Get-SRxProp $effLayers[$i] 'Exclude' $false)) { $exclude = $true; break }
        }
    } else {
        # an Exclude only counts if it is at a HIGHER priority than the winning Include
        foreach ($i in 0..3) {
            if ($i -ge $includeLayer) { break }
            if ($effLayers.ContainsKey($i) -and (Get-SRxProp $effLayers[$i] 'Exclude' $false)) { $exclude = $true; break }
        }
    }

    # Attributes: highest priority first fills, lower layers fill remaining keys.
    $attrs = @{}
    foreach ($i in 0..3) {
        if (-not $effLayers.ContainsKey($i)) { continue }
        $la = Get-SRxLayerAttrs $effLayers[$i]
        foreach ($k in $la.Keys) { if (-not $attrs.ContainsKey($k)) { $attrs[$k] = [string]$la[$k] } }
    }

    return [pscustomobject]@{
        Attributes = $attrs
        Exclude    = [bool]$exclude
        Include    = [bool]$include
        HasFilter  = ([bool]$exclude -or [bool]$include -or ($attrs.Count -gt 0))
    }
}

# --- tiny helpers for the merge (kept internal) ---
function Get-SRxProp { param($obj,[string]$name,$default)
    if ($null -eq $obj) { return $default }
    if ($obj -is [hashtable]) { if ($obj.ContainsKey($name)) { return $obj[$name] } else { return $default } }
    $p = $obj.PSObject.Properties[$name]; if ($p) { return $p.Value } else { return $default }
}
function Get-SRxLayerAttrs { param($lf)
    $a = Get-SRxProp $lf 'Attributes' @{}
    $h = @{}; if ($a -is [hashtable]) { foreach ($k in $a.Keys) { $h[$k]=[string]$a[$k] } }
    elseif ($null -ne $a) { foreach ($p in $a.PSObject.Properties) { $h[$p.Name]=[string]$p.Value } }
    return $h
}
function Get-SRxDraftAttrs { param($draft) return (Get-SRxLayerAttrs $draft) }
function Merge-SRxHash { param([hashtable]$base,[hashtable]$over)
    $h = @{}; if ($base) { foreach ($k in $base.Keys) { $h[$k]=$base[$k] } }
    if ($over) { foreach ($k in $over.Keys) { $h[$k]=$over[$k] } }
    return $h
}

# ==============================================================================
# Launch the isolated per-node branch worker. Non-blocking.
# $Pack is the BranchPack hashtable (Operation/Scope/Optype/Environment/Design/
# TermID/Branch[]/BaselineModified). Passed to the worker via a temp JSON file.
# ==============================================================================
function Start-SRxFilterBranchOperation {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] [hashtable]$Pack,
        [Parameter(Mandatory)] [string]$SourcePath,
        [Parameter(Mandatory=$false)] [switch]$Force,
        [Parameter(Mandatory=$false)] [switch]$HideWindow
    )
    try {
        #$worker = Join-Path $SourcePath 'Write-SRxFilterBranch.ps1'

        #$worker = $("$PSScriptRoot\Write-SRxFilterBranch.ps1")
        $worker = $("$PSScriptRoot\Write-SRxFilterBranch.debug.ps1")
        if (-not (Test-Path $worker)) { throw "Worker not found: $worker" }

        $tag        = ("{0:yyyyMMddHHmmssfff}-{1}" -f (Get-Date), ([guid]::NewGuid().ToString('N').Substring(0,8)))
        $resultPath = Join-Path $env:TEMP ("srx-filter-$tag.json")
        $packPath   = Join-Path $env:TEMP ("srx-filter-$tag.pack.json")

        $packJson = ($Pack | ConvertTo-Json -Depth 12 -Compress)
        if ([string]::IsNullOrWhiteSpace($packJson)) { $packJson = '{}' }
        Set-Content -LiteralPath $packPath -Value $packJson -Encoding UTF8

        $shim = @"
& '$worker' -RootPath '$SourcePath' -PackPath '$packPath' -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
            PackPath   = $packPath
            Pack       = $Pack
            StartedUtc = (Get-Date).ToUniversalTime()
            Op         = 'filter-branch'
        }
    }
    catch {
        return [pscustomobject]@{ ProcessId=0; Process=$null; ResultPath=$null; PackPath=$null; Pack=$Pack; StartError=$_.Exception.Message }
    }
}

# ------------------------------------------------------------------------------
# Poll one branch op. State: Running | Completed | Conflict | Failed
# ------------------------------------------------------------------------------
function Get-SRxFilterBranchOperationState {
    [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-SRxFilterBranchOperation {
    [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-SRxFilterBranchTempFiles {
    [CmdletBinding()] param([Parameter(Mandatory)]$Operation)
    foreach ($p in @($Operation.ResultPath, $Operation.PackPath)) {
        try { if ($p -and (Test-Path $p)) { Remove-Item $p -Force -ErrorAction SilentlyContinue } } catch { }
    }
}

# ==============================================================================
# LOCAL SYNC: patch the cached termSet.xml "Customizations" sub-tree to match the
# branch just written, then regenerate the JSON copies. Returns {Success; OuterXml;
# FilterNodes(slice); Error}. Runs in the WORKER process (invoked by the worker),
# never on the UI thread.
#
# NOTE: this is the branch-aware analogue of Sync-SRxLocalTermBatch. It locates the
# hosting Customizations block for the pack's layer, then applies the branch. The
# exact XPath layout mirrors CustomizationWriter.setProvisioningBlock; the helper
# Find-SRxCustomizationsHost encapsulates that so it can be adjusted in one place.
# ==============================================================================
function Sync-SRxLocalFilterBranch {
    [CmdletBinding()]
    param([Parameter(Mandatory)] $Pack)

    $result = [pscustomobject]@{ Success=$false; Error=$null; OuterXml=$null; FilterNodes=@() }
    try {
        # normalize pack (may arrive as PSCustomObject from JSON)
        $scope     = [int](Get-SRxProp $Pack 'Scope' 0)
        $operation = [string](Get-SRxProp $Pack 'Operation' 'add')
        $branch    = Get-SRxProp $Pack 'Branch' @()

        $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)

        # Locate/create the Customizations host term/termset for this layer.
        $host1 = Find-SRxCustomizationsHost -Xml $xml -Ns $ns -Pack $Pack
        if ($null -eq $host1) { throw "Customizations host not found for scope $scope (Pack Environment/Design/TermID)." }

        # Apply the branch under $host1: create/update the path of pnp:Term nodes; the
        # leaf carries the customization (Attributes / ts_Deprecated / ts_CustomizationTarget).
        $leafNode = Apply-SRxBranch -Xml $xml -Ns $ns -HostNode $host1 -Branch $branch -Operation $operation

        $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

        $result.Success  = $true
        $result.OuterXml = if ($leafNode) { $leafNode.OuterXml } else { $null }
        # FilterNodes slice for this branch (so the UI can update _filterNodes in-memory)
        $result.FilterNodes = @( [pscustomobject]@{
            xPath  = [string](Get-SRxProp ($branch | Select-Object -Last 1) 'XPath' '')
            Scope  = $scope
            optype = [int](Get-SRxProp $Pack 'Optype' -1)
            Filter = $operation
        } )
        return $result
    }
    catch { $result.Error = $_.Exception.Message; return $result }
}

# --- Customizations host + branch application (encapsulated for easy adjustment) ---
# These mirror CustomizationWriter.setProvisioningBlock / branch walk. Kept as separate
# functions so the exact XPath can be tuned in ONE place once verified against real data.
function Find-SRxCustomizationsHost {
    param([xml]$Xml, $Ns, $Pack)
    # Preferred: locate by the hosting term's ID, then its child "Customizations" term/termset.
    $termId = [string](Get-SRxProp $Pack 'TermID' '')
    if ($termId) {
        $hostTerm = $Xml.SelectSingleNode("//pnp:Term[@ID='$termId']", $Ns)
        if ($hostTerm) {
            $cust = $hostTerm.SelectSingleNode("pnp:Terms/pnp:Term[@Name='Customizations']", $Ns)
            if (-not $cust) {
                $termsEl = $hostTerm.SelectSingleNode('pnp:Terms', $Ns)
                if (-not $termsEl) { $termsEl = $Xml.CreateElement('pnp','Terms',$script:SRxPnpNs); [void]$hostTerm.AppendChild($termsEl) }
                $cust = $Xml.CreateElement('pnp','Term',$script:SRxPnpNs); $cust.SetAttribute('Name','Customizations'); [void]$termsEl.AppendChild($cust)
            }
            return $cust
        }
    }
    return $null
}

function Apply-SRxBranch {
    param([xml]$Xml, $Ns, $HostNode, $Branch, [string]$Operation)
    $current = $HostNode
    $leaf = $null
    $count = @($Branch).Count
    $i = 0
    foreach ($d in @($Branch)) {
        $i++
        $name = [string](Get-SRxProp $d 'Name' '')
        if ([string]::IsNullOrEmpty($name)) { continue }

        $termsEl = $current.SelectSingleNode('pnp:Terms', $Ns)
        if (-not $termsEl) { $termsEl = $Xml.CreateElement('pnp','Terms',$script:SRxPnpNs); [void]$current.AppendChild($termsEl) }

        $child = $termsEl.SelectSingleNode("pnp:Term[@Name=`"$name`"]", $Ns)
        $isLeaf = ($i -eq $count)

        if ($Operation -eq 'remove' -and $isLeaf) {
            if ($child) { [void]$termsEl.RemoveChild($child) }
            $leaf = $null
            break
        }

        if (-not $child) {
            $child = $Xml.CreateElement('pnp','Term',$script:SRxPnpNs)
            $child.SetAttribute('Name',$name)
            [void]$termsEl.AppendChild($child)
        }

        if ($isLeaf) {
            # write custom properties on the leaf (attributes + ts_ flags), chunked
            $cp = $child.SelectSingleNode('pnp:CustomProperties', $Ns)
            if ($cp) { [void]$child.RemoveChild($cp) }
            $attrs = Get-SRxProp $d 'Attributes' @{}
            $attrHash = @{}; if ($attrs -is [hashtable]) { foreach ($k in $attrs.Keys) { $attrHash[$k]=[string]$attrs[$k] } } elseif ($null -ne $attrs) { foreach ($p in $attrs.PSObject.Properties) { $attrHash[$p.Name]=[string]$p.Value } }
            if ((Get-SRxProp $d 'Deprecated' $false))          { $attrHash['ts_Deprecated'] = 'true' }
            if ((Get-SRxProp $d 'CustomizationTarget' $false)) { $attrHash['ts_CustomizationTarget'] = 'true' }
            if ($attrHash.Count -gt 0) {
                $cpEl = $Xml.CreateElement('pnp','CustomProperties',$script:SRxPnpNs)
                foreach ($k in ($attrHash.Keys | Sort-Object)) {
                    $v = [string]$attrHash[$k]
                    if ($v.Length -le $script:SRxChunkSize) {
                        $pe = $Xml.CreateElement('pnp','Property',$script:SRxPnpNs); $pe.SetAttribute('Key',$k); $pe.SetAttribute('Value',$v); [void]$cpEl.AppendChild($pe)
                    } else {
                        $parts = @($v -split ("(.{$($script:SRxChunkSize)})") | Where-Object { $_ })
                        for ([int]$j=0; $j -lt $parts.Count; $j++) { $pe = $Xml.CreateElement('pnp','Property',$script:SRxPnpNs); $pe.SetAttribute('Key',"$($k)_$($j)"); $pe.SetAttribute('Value',$parts[$j]); [void]$cpEl.AppendChild($pe) }
                    }
                }
                [void]$child.AppendChild($cpEl)
            }
            $leaf = $child
        }
        $current = $child
    }
    return $leaf
}

<#
================================================================================
  SRxFilterBox.JSONSync.patch.ps1
  -----------------------------------------------------------------------------
  Add Sync-SRxLocalJsonCustomization to SRxFilterBox.psm1: patch the LOCAL
  termSet.xml so the single JSON container term (create/update/delete) matches the
  remote write, then regenerate the JSON cache copies. Single-term operation -
  far simpler than the branch sub-tree patch (analogous to the Property Box).

  ONE edit: paste the function into SRxFilterBox.psm1 (before Export-ModuleMember)
  and ADD its name to the Export-ModuleMember list.

  Requires SRxCustomizationJson.psm1 (for New-SRxJsonContainerTerm + Split helper).
  SRxFilterBox.psm1 should import it once at top:
      Import-Module (Join-Path $PSScriptRoot 'SRxCustomizationJson.psm1') -DisableNameChecking -Force -ErrorAction SilentlyContinue
================================================================================
#>


function Sync-SRxLocalJsonCustomization {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)] $Pack,
        [Parameter(Mandatory)] [string]$Operation
    )
    $result = [pscustomobject]@{ Success=$false; Error=$null; OuterXml=$null; FilterNodes=@() }
    try {
        $scope = [int](Get-SRxProp $Pack 'Scope' 0)

        $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)

        # locate/create the Customizations host for this layer (reuse the branch helper)
        $host1 = Find-SRxCustomizationsHost -Xml $xml -Ns $ns -Pack $Pack
        if ($null -eq $host1) { throw "Customizations host not found for scope $scope." }

        # build the container term descriptor (same Name/Key/props as the remote write)
        $container = New-SRxJsonContainerTerm -Pack $Pack -Operation $Operation
        $name = [string]$container.Name

        $termsEl = $host1.SelectSingleNode('pnp:Terms', $ns)
        if (-not $termsEl) { $termsEl = $xml.CreateElement('pnp','Terms',$script:SRxPnpNs); [void]$host1.AppendChild($termsEl) }

        $existing = $termsEl.SelectSingleNode("pnp:Term[@Name=`"$name`"]", $ns)

        if ($Operation -eq 'remove') {
            if ($existing) { [void]$termsEl.RemoveChild($existing) }
            $leaf = $null
        }
        else {
            if ($existing) { [void]$termsEl.RemoveChild($existing) }   # replace wholesale
            $term = $xml.CreateElement('pnp','Term',$script:SRxPnpNs)
            $term.SetAttribute('Name', $name)

            # custom properties: activity/format/key + chunked ts_CustomizationJson
            $cp = $xml.CreateElement('pnp','CustomProperties',$script:SRxPnpNs)
            $props = [hashtable]$container.Attributes            # ts_ProvisioningActivity/Format/Key + ts_CustomizationJson (single big value)
            $flat = @{}
            foreach ($k in $props.Keys) {
                $v = [string]$props[$k]
                if ($k -eq 'ts_CustomizationJson') {
                    $chunked = Split-SRxJsonProperty -BaseKey 'ts_CustomizationJson' -Value $v
                    foreach ($ck in $chunked.Keys) { $flat[$ck] = [string]$chunked[$ck] }
                } else {
                    $flat[$k] = $v
                }
            }
            foreach ($k in ($flat.Keys | Sort-Object)) {
                $pe = $xml.CreateElement('pnp','Property',$script:SRxPnpNs)
                $pe.SetAttribute('Key', [string]$k)
                $pe.SetAttribute('Value', [string]$flat[$k])
                [void]$cp.AppendChild($pe)
            }
            [void]$term.AppendChild($cp)
            [void]$termsEl.AppendChild($term)
            $leaf = $term
        }

        $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

        $result.Success  = $true
        $result.OuterXml = if ($leaf) { $leaf.OuterXml } else { $null }
        $result.FilterNodes = @( [pscustomobject]@{
            xPath  = [string]$container.xPath
            Scope  = $scope
            optype = [int]$container.optype
            Filter = $Operation
        } )
        return $result
    }
    catch { $result.Error = $_.Exception.Message; return $result }
}


Export-ModuleMember -Function `
    Get-SRxProfileFolderPath, `
    Get-SRxEffectiveFilter, `
    Start-SRxFilterBranchOperation, `
    Get-SRxFilterBranchOperationState, `
    Stop-SRxFilterBranchOperation, `
    Remove-SRxFilterBranchTempFiles, `
    Sync-SRxLocalJsonCustomization, `
    Sync-SRxLocalFilterBranch