Private/Sites/Helpers.ps1

function Get-PSISiteValue($Object,[string]$Property){
    if($null -eq $Object){return $null}
    if($Object -is [collections.IDictionary]){if($Object.Contains($Property)){return $Object[$Property]};return $null}
    if($null -ne $Object.PSObject.Properties[$Property]){return $Object.$Property}
    return $null
}
function Get-PSIParentDN([string]$DN){
    $escaped=$false
    for($i=0;$i -lt $DN.Length;$i++){
        if($escaped){$escaped=$false;continue}
        if($DN[$i] -eq '\'){$escaped=$true;continue}
        if($DN[$i] -eq ','){return $DN.Substring($i+1)}
    }
    return $null
}
function Get-PSISiteSchema {
    [ordered]@{
        sites=@('Sites',@('Name','DC objects','Server objects','Subnets','Site links','Location','Description','ISTG DN','Site options raw','DN','Created','Changed'))
        subnets=@('Subnets',@('Name','Site','Assignment','Description','Location','Site DN','DN'))
        links=@('Site links',@('Name','Transport','Cost','Interval minutes','Sites','Member count','Options raw','Change notification','Two-way sync','Compression disabled','Schedule','DN'))
        linkMembers=@('Site link membership',@('Name','Site link','Site','Reference state','Site DN','Link DN'))
        bridges=@('Site link bridges',@('Name','Transport','Member links','Member count','Description','DN'))
        bridgeMembers=@('Bridge membership',@('Name','Bridge','Site link','Reference state','Link DN','Bridge DN'))
        servers=@('Directory servers',@('Name','Host name','Site','NTDS object present','Bridgehead transports','Server reference','DN'))
        settings=@('Site configuration',@('Name','Site','ISTG DN','Options raw','Preferred GC site DN','Schedule','Site DN','DN'))
        transports=@('Transports',@('Name','Options raw','Ignore schedules','Bridge all site links','Address attribute','DN'))
        schedules=@('Stored schedules',@('Name','Owner type','Parse status','Allowed hours UTC','Summary','Hourly masks UTC','Raw base64','DN'))
        collection=@('Collection ledger',@('Name','Status','Record count','Method','Collected at','Duration ms','Error ID','Message'))
    }
}
function ConvertFrom-PSISchedule($Value,[string]$Name,[string]$OwnerType,[string]$DN){
    $raw=$null;$masks=$null;$hours=$null;$status='Not set or not returned';$summary='No stored schedule returned; no availability inferred.'
    try{
        if($null -ne $Value -and @($Value).Count -gt 0){
            [byte[]]$bytes=$Value;$raw=[convert]::ToBase64String($bytes)
            if($bytes.Length -lt 20){throw 'Schedule header is truncated.'}
            $size=[bitconverter]::ToUInt32($bytes,0);$count=[bitconverter]::ToUInt32($bytes,8)
            $type=[bitconverter]::ToUInt32($bytes,12);$offset=[bitconverter]::ToUInt32($bytes,16)
            if($size -ne $bytes.Length -or $count -ne 1 -or $type -ne 0 -or $offset -lt 20 -or ([long]$offset+168) -gt $bytes.Length){throw 'Unsupported schedule header or offset.'}
            $maskValues=@($bytes[$offset..($offset+167)])
            if(@($maskValues | Where-Object{($_ -band 240) -ne 0}).Count){throw 'Reserved schedule bits are nonzero.'}
            $quarters=0
            foreach($mask in $maskValues){foreach($bit in @(1,2,4,8)){if(($mask -band $bit) -ne 0){$quarters++}}}
            $masks=($maskValues | ForEach-Object{$_.ToString('X1')}) -join ''
            $hours=$quarters/4.0;$status='Parsed';$summary="$hours / 168 permitted hours per stored UTC week."
        }
    }catch{$status='Unsupported or malformed';$summary=$_.Exception.Message}
    [pscustomobject][ordered]@{Name=$Name;'Owner type'=$OwnerType;'Parse status'=$status;'Allowed hours UTC'=$hours;Summary=$summary;'Hourly masks UTC'=$masks;'Raw base64'=$raw;DN=$DN}
}
function ConvertTo-PSISafeJson($Value){
    ($Value | ConvertTo-Json -Depth 30).Replace('&','\u0026').Replace('<','\u003c').Replace('>','\u003e').Replace([string][char]0x2028,'\u2028').Replace([string][char]0x2029,'\u2029')
}