Public/Invoke-LnvTILogViewer.ps1

<#
.SYNOPSIS
    TI Log File Viewer - Parses Lenovo ThinInstaller log files and produces a concise summary.
 
.DESCRIPTION
    Analyzes log files generated by Lenovo ThinInstaller and provides a human-readable
    summary of key session details including system information, applicable updates,
    installation results, and errors.
 
.PARAMETER LogFile
    Path to a single ThinInstaller log file.
 
.PARAMETER LogDirectory
    Path to a directory containing ThinInstaller log files (.txt and .log).
 
.PARAMETER OutputFile
    Optional path to write the summary to a plain text file.
 
.PARAMETER Force
    Overwrite the output file without prompting.
 
.PARAMETER NoColor
    Suppress color output (useful for redirected output).
 
.EXAMPLE
    .\TI-LogFileViewer.ps1 -LogFile "C:\Logs\Update_log_250221164004.txt"
 
.EXAMPLE
    .\TI-LogFileViewer.ps1 -LogDirectory "C:\Logs" -OutputFile "C:\Logs\summary.txt"
 
.NOTES
    Version: 1.0
    Requires: PowerShell 5.1 or later
#>

function Invoke-LnvTILogViewer {
[CmdletBinding()]
param(
    [Parameter(Mandatory = $false)]
    [string]$LogFile,

    [Parameter(Mandatory = $false)]
    [string]$LogDirectory,

    [Parameter(Mandatory = $false)]
    [string]$OutputFile,

    [switch]$Force,

    [switch]$NoColor
)

$ScriptVersion = "1.0"
$ScriptName = "TI Log File Viewer"

#region Helper Functions

function Write-ColorHost {
    param(
        [string]$Text,
        [ConsoleColor]$ForegroundColor = [ConsoleColor]::White,
        [switch]$NoNewline
    )
    if ($script:NoColor) {
        if ($NoNewline) { Write-Host $Text -NoNewline }
        else { Write-Host $Text }
    }
    else {
        if ($NoNewline) { Write-Host $Text -ForegroundColor $ForegroundColor -NoNewline }
        else { Write-Host $Text -ForegroundColor $ForegroundColor }
    }
}

function Get-RebootTypeLabel {
    param([string]$RebootType)
    switch ($RebootType) {
        "0" { "No reboot" }
        "1" { "Forced reboot" }
        "3" { "Reboot recommended" }
        default { "Unknown ($RebootType)" }
    }
}

function Get-SearchScopeLabel {
    param([string]$Scope)
    switch ($Scope) {
        "A" { "All updates (-searchA)" }
        "C" { "Critical updates (-searchC)" }
        "R" { "Recommended updates (-searchR)" }
        default { $Scope }
    }
}

function ParseHeaderDateTime {
    <#
    .SYNOPSIS
        Parses the session datetime from line 1 of the TI log.
        Handles multiple locale formats:
          - English: 4/6/2023 2:18:38 PM
          - Spanish: 21/02/2025 4:40:04 p. m.
          - ISO-ish: 2025-08-01 11:43:46 AM
    #>

    param([string]$DateTimeStr)

    $DateTimeStr = $DateTimeStr.Trim()

    # Normalize non-breaking spaces (U+00A0) to regular spaces
    $normalized = $DateTimeStr -replace [char]0x00A0, ' '

    # Normalize Spanish AM/PM markers to standard
    $normalized = $normalized -replace '\s*a\.\s*m\.', ' AM'
    $normalized = $normalized -replace '\s*p\.\s*m\.', ' PM'

    # Try various formats
    $formats = @(
        "dd/MM/yyyy h:mm:ss tt",
        "dd/MM/yyyy hh:mm:ss tt",
        "dd/MM/yyyy H:mm:ss",
        "dd/MM/yyyy HH:mm:ss",
        "M/d/yyyy h:mm:ss tt",
        "M/d/yyyy hh:mm:ss tt",
        "M/d/yyyy H:mm:ss",
        "M/d/yyyy HH:mm:ss",
        "yyyy-MM-dd h:mm:ss tt",
        "yyyy-MM-dd hh:mm:ss tt",
        "yyyy-MM-dd H:mm:ss",
        "yyyy-MM-dd HH:mm:ss"
    )

    foreach ($fmt in $formats) {
        try {
            $parsed = [DateTime]::ParseExact($normalized, $fmt, [System.Globalization.CultureInfo]::InvariantCulture)
            return $parsed
        }
        catch {
            continue
        }
    }

    # Fallback: try generic parse
    try {
        return [DateTime]::Parse($normalized, [System.Globalization.CultureInfo]::InvariantCulture)
    }
    catch {
        return $null
    }
}

function Test-ThinInstallerLog {
    param([string]$FilePath)
    try {
        $firstLine = Get-Content -Path $FilePath -TotalCount 1 -ErrorAction Stop
        return $firstLine -match '^\[Thin Installer build:'
    }
    catch {
        return $false
    }
}

function ParseThinInstallerLog {
    param([string]$FilePath)

    $result = [PSCustomObject]@{
        FileName           = [System.IO.Path]::GetFileName($FilePath)
        FilePath           = $FilePath
        SessionDateTime    = $null
        SessionDateStr     = "(not found)"
        TIVersion          = "(not found)"
        TIBuildDate        = "(not found)"
        WindowsVersion     = "(not found)"
        MTM                = "(not found)"
        OS                 = "(not found)"
        OSLanguage         = "(not found)"
        ActiveLanguage     = "(not found)"
        DefaultLanguage    = "(not found)"
        SearchScope        = "(not found)"
        ActionType         = "(not found)"
        CommandString      = "(not found)"
        CandidateList      = @()
        ApplicableCount    = 0
        InstalledUpdates   = @()
        FailedUpdates      = @()
        FailureReasons     = @{}
        ProcessedAtShutdown = @()
        SevereEntries      = @()
        WarningEntries     = @()
        IsScanOnly         = $false
        RebootSummary      = "No reboot needed"
    }

    try {
        $lines = Get-Content -Path $FilePath -Encoding UTF8 -ErrorAction Stop
    }
    catch {
        Write-Error "Cannot read file: $FilePath - $_"
        return $null
    }

    $lineCount = $lines.Count

    # === Parse Header (Line 1) ===
    if ($lineCount -gt 0 -and $lines[0] -match '^\[Thin Installer build:\s*(\d{4}-\d{2}-\d{2})\s+Version:\s*([^\]]+)\]\t(.+)$') {
        $result.TIBuildDate = $Matches[1]
        $result.TIVersion = $Matches[2].Trim()
        $parsedDate = ParseHeaderDateTime $Matches[3]
        if ($parsedDate) {
            $result.SessionDateTime = $parsedDate
            $result.SessionDateStr = $parsedDate.ToString("yyyy-MM-dd HH:mm:ss")
        }
        else {
            $result.SessionDateStr = $Matches[3].Trim()
        }
    }

    # Track state for multi-line parsing
    $inCandidateList = $false
    $candidateListFound = $false
    $inSystemProps = $false
    $systemPropsLines = @()
    #$inLanguageBlock = $false
    #$lastFailedPackageId = ""
    #$lastExpectedReturnCode = ""
    #$lastActualReturnCode = ""
    $updateResponseSizes = @()

    for ($i = 1; $i -lt $lineCount; $i++) {
        $line = $lines[$i]

        # === Windows Version ===
        if ($line -match 'Message:\s*Microsoft Windows \[Vers[^\]]*\s+([\d\.]+)\]') {
            $result.WindowsVersion = "Microsoft Windows $($Matches[1])"
        }

        # === Language Block ===
        if ($line -match 'The active language is:\s*(.+)') {
            $result.ActiveLanguage = $Matches[1].Trim()
        }
        if ($line -match 'The default language is:\s*(.+)') {
            $result.DefaultLanguage = $Matches[1].Trim()
        }
        if ($line -match 'The OS language is:\s*(.+)') {
            $result.OSLanguage = $Matches[1].Trim()
        }

        # === Command String ===
        if ($line -match 'Message:\s*The command is:\s*(.+)') {
            $result.CommandString = $Matches[1].Trim()
        }

        # === Search Scope ===
        if ($line -match 'Message:\s*Searching for Updates:\s*(\w)') {
            $result.SearchScope = $Matches[1].Trim()
        }

        # === Notify Method / Action ===
        if ($line -match 'Message:\s*Notify method:\s*(\w+)') {
            $result.ActionType = $Matches[1].Trim()
        }

        # === MTM ===
        if ($line -match 'Message:\s*MTM is:\s*(.+)') {
            $result.MTM = $Matches[1].Trim()
        }

        # === System Properties Block ===
        if ($line -match 'Message:\s*The System Properties are:') {
            $inSystemProps = $true
            $systemPropsLines = @()
            continue
        }
        if ($inSystemProps) {
            if ($line -match '^\s*$' -and $systemPropsLines.Count -ge 3) {
                $inSystemProps = $false
            }
            elseif ($line -match '^\S' -and $line -notmatch '^\s') {
                # Next log entry level token (Info, Severe, Warning)
                if ($systemPropsLines.Count -ge 3) {
                    $inSystemProps = $false
                }
                elseif ($line -match '^(Info|Severe|Warning)\s') {
                    $inSystemProps = $false
                }
                else {
                    $systemPropsLines += $line.Trim()
                }
            }
            else {
                $trimmed = $line.Trim()
                if ($trimmed -ne '' -and $trimmed -notmatch '^(Info|Severe|Warning)\s') {
                    $systemPropsLines += $trimmed
                }
                elseif ($trimmed -match '^(Info|Severe|Warning)\s') {
                    $inSystemProps = $false
                }
            }

            if ($systemPropsLines.Count -ge 3 -and -not $inSystemProps) {
                if ($result.MTM -eq "(not found)") {
                    $result.MTM = $systemPropsLines[0]
                }
                $result.OS = $systemPropsLines[1]
                if ($result.OSLanguage -eq "(not found)") {
                    $result.OSLanguage = $systemPropsLines[2]
                }
            }
            continue
        }

        # === Candidate List (first occurrence only) ===
        if ($line -match 'Message:\s*Candidate list:' -and -not $candidateListFound) {
            $inCandidateList = $true
            $candidateListFound = $true
            continue
        }
        if ($inCandidateList) {
            $trimmed = $line.Trim()
            if ($trimmed -eq '' -or $trimmed -match '^(Info|Severe|Warning)\t') {
                $inCandidateList = $false
            }
            else {
                # Parse update name and reboot type
                if ($trimmed -match '^(.+?)\[reboot type (\d+)\]$') {
                    $result.CandidateList += [PSCustomObject]@{
                        Name       = $Matches[1].Trim()
                        RebootType = $Matches[2]
                    }
                }
                else {
                    # Line without reboot type info
                    $result.CandidateList += [PSCustomObject]@{
                        Name       = $trimmed
                        RebootType = "-1"
                    }
                }
            }
            if ($inCandidateList) { continue }
        }

        # === UpdateResponse Size ===
        if ($line -match 'Message:\s*The size for the UpdateResponse is (\d+)') {
            $updateResponseSizes += [int]$Matches[1]
        }

        # === Installation Success ===
        if ($line -match 'Message:\s*Update (.+?) was installed successfully') {
            $result.InstalledUpdates += $Matches[1].Trim()
        }

        # === Installation Failure ===
        if ($line -match 'Message:\s*Update (.+?) installation failed') {
            $failedName = $Matches[1].Trim()
            $result.FailedUpdates += $failedName

            # Look backward for the failure reason
            $reason = ""

            # Check for signature failure or return code in the preceding lines
            for ($j = $i - 1; $j -ge [Math]::Max(0, $i - 20); $j--) {
                if ($lines[$j] -match 'Message:\s*Package Signature Verification Failed,\s*ID:(.+)') {
                    $reason = "Package Signature Verification Failed ($($Matches[1].Trim()))"
                    break
                }
                if ($lines[$j] -match 'Message:\s*Expected return code:\s*(.+?),\s*Installation return code:\s*(\d+)') {
                    $expected = $Matches[1].Trim()
                    $actual = $Matches[2].Trim()
                    $reason = "Expected return code: $expected, Installation return code: $actual"
                    break
                }
            }

            if ($reason) {
                $result.FailureReasons[$failedName] = $reason
            }
        }

        # === Packages Processed at Shutdown ===
        if ($line -match '^Severe' -and $i + 2 -lt $lineCount) {
            if ($lines[$i + 2] -match 'Message:\s*Processing the Update:\s*(.+)') {
                $result.ProcessedAtShutdown += $Matches[1].Trim()
            }
        }

        # === Severe Entries ===
        if ($line -match '^Severe\s') {
            # Collect the message from the next lines
            $severeMsg = ""
            for ($k = $i + 1; $k -lt [Math]::Min($lineCount, $i + 4); $k++) {
                if ($lines[$k] -match 'Message:\s*(.+)') {
                    $severeMsg = $Matches[1].Trim()
                    break
                }
            }
            # Exclude routine .NET framework message
            if ($severeMsg -and $severeMsg -notmatch '^Application runs with the framework:') {
                $result.SevereEntries += $severeMsg
            }
        }

        # === Warning Entries ===
        if ($line -match '^Warning\s') {
            $warnMsg = ""
            for ($k = $i + 1; $k -lt [Math]::Min($lineCount, $i + 4); $k++) {
                if ($lines[$k] -match 'Message:\s*(.+)') {
                    $warnMsg = $Matches[1].Trim()
                    break
                }
            }
            if ($warnMsg) {
                $result.WarningEntries += $warnMsg
            }
        }
    }

    # Post-processing

    # Use the first UpdateResponse size if available
    if ($updateResponseSizes.Count -gt 0) {
        $result.ApplicableCount = $updateResponseSizes[0]
    }
    else {
        $result.ApplicableCount = $result.CandidateList.Count
    }

    # Determine scan-only
    if ($result.ActionType -eq "NOTIFY" -or
        ($result.InstalledUpdates.Count -eq 0 -and $result.FailedUpdates.Count -eq 0)) {
        $result.IsScanOnly = $true
    }

    # Reboot summary based on installed updates' reboot types
    $installedRebootTypes = @()
    foreach ($installed in $result.InstalledUpdates) {
        $match = $result.CandidateList | Where-Object { $installed -like "$($_.Name)*" -or $_.Name -like "$installed*" } | Select-Object -First 1
        if ($match) {
            $installedRebootTypes += $match.RebootType
        }
    }

    if ($installedRebootTypes -contains "1") {
        $result.RebootSummary = "Reboot required"
    }
    elseif ($installedRebootTypes -contains "3") {
        $result.RebootSummary = "Reboot recommended"
    }
    else {
        $result.RebootSummary = "No reboot needed"
    }

    return $result
}

function Format-Summary {
    param(
        [object]$Data,
        [switch]$IsVerbose
    )

    $lines = @()
    $divider = "=" * 64

    $lines += $divider
    $lines += " THININSTALLER SESSION SUMMARY"
    $lines += $divider
    $lines += "Log File : $($Data.FileName)"
    $lines += "Session Date : $($Data.SessionDateStr)"
    $lines += "TI Version : $($Data.TIVersion) (Build: $($Data.TIBuildDate))"
    $lines += "Windows : $($Data.WindowsVersion)"
    $lines += ""
    $lines += "SYSTEM"
    $lines += " Machine Type : $($Data.MTM)"
    $lines += " OS : $($Data.OS)"

    # Language line
    $langParts = @()
    if ($Data.OSLanguage -ne "(not found)") { $langParts += "$($Data.OSLanguage) (OS)" }
    if ($Data.ActiveLanguage -ne "(not found)") { $langParts += "Active: $($Data.ActiveLanguage)" }
    if ($Data.DefaultLanguage -ne "(not found)") { $langParts += "Default: $($Data.DefaultLanguage)" }
    if ($langParts.Count -gt 0) {
        $lines += " Language : $($langParts -join ' | ')"
    }
    else {
        $lines += " Language : (not found)"
    }

    $lines += ""
    $lines += "SESSION CONFIGURATION"
    $lines += " Search Scope : $(Get-SearchScopeLabel $Data.SearchScope)"
    $lines += " Action : $($Data.ActionType)"
    $lines += " Command : $($Data.CommandString)"

    $lines += ""
    $candidateCount = $Data.CandidateList.Count
    $lines += "APPLICABLE UPDATES ($($Data.ApplicableCount) found)"

    if ($candidateCount -eq 0) {
        $lines += " (no applicable updates found)"
    }
    else {
        $showCount = if ($IsVerbose) { $candidateCount } else { [Math]::Min(10, $candidateCount) }
        for ($i = 0; $i -lt $showCount; $i++) {
            $update = $Data.CandidateList[$i]
            $rebootLabel = Get-RebootTypeLabel $update.RebootType
            $lines += " $($update.Name) [$rebootLabel]"
        }
        if (-not $IsVerbose -and $candidateCount -gt 10) {
            $remaining = $candidateCount - 10
            $lines += " ... ($remaining more - use -Verbose for full list)"
        }
    }

    $lines += ""
    $lines += "INSTALLATION RESULTS"

    if ($Data.IsScanOnly -and $Data.ActionType -eq "NOTIFY") {
        $lines += " (scan only - no installs performed)"
    }
    elseif ($Data.IsScanOnly) {
        $installedCount = $Data.InstalledUpdates.Count
        $failedCount = $Data.FailedUpdates.Count
        $lines += " Installed : $installedCount"
        $lines += " Failed : $failedCount"
        $lines += " (no installs were performed)"
    }
    else {
        $installedCount = $Data.InstalledUpdates.Count
        $failedCount = $Data.FailedUpdates.Count
        $lines += " Installed : $installedCount"
        $lines += " Failed : $failedCount"
        $lines += " Reboot : $($Data.RebootSummary)"

        if ($installedCount -gt 0) {
            $lines += ""
            $lines += " INSTALLED:"
            foreach ($u in $Data.InstalledUpdates) {
                $lines += " [OK] $u"
            }
        }

        if ($failedCount -gt 0) {
            $lines += ""
            $lines += " FAILED:"
            foreach ($u in $Data.FailedUpdates) {
                $reason = if ($Data.FailureReasons.ContainsKey($u)) { $Data.FailureReasons[$u] } else { "Unknown" }
                $lines += " [FAIL] $u"
                $lines += " Reason: $reason"
            }
        }
    }

    if ($Data.ProcessedAtShutdown.Count -gt 0) {
        $lines += ""
        $lines += " PROCESSED AT SHUTDOWN:"
        foreach ($p in $Data.ProcessedAtShutdown) {
            $lines += " $p"
        }
    }

    $lines += ""
    $lines += "ERRORS / WARNINGS"

    $hasErrors = $Data.SevereEntries.Count -gt 0
    $hasWarnings = $Data.WarningEntries.Count -gt 0

    if (-not $hasErrors -and -not $hasWarnings) {
        $lines += " (none)"
    }
    else {
        if ($hasErrors) {
            $severeToShow = if ($IsVerbose) { $Data.SevereEntries } else { $Data.SevereEntries | Select-Object -Unique }
            foreach ($s in $severeToShow) {
                $lines += " [SEVERE] $s"
            }
        }
        if ($hasWarnings) {
            foreach ($w in $Data.WarningEntries) {
                $lines += " [WARNING] $w"
            }
        }
    }

    $lines += $divider

    return $lines
}

function Write-SummaryToConsole {
    param(
        [string[]]$Lines
    )

    foreach ($line in $Lines) {
        if ($line -match '^\={10,}') {
            Write-ColorHost $line -ForegroundColor Cyan
        }
        elseif ($line -match '^\s*THININSTALLER SESSION SUMMARY') {
            Write-ColorHost $line -ForegroundColor Cyan
        }
        elseif ($line -match '^\s*\[OK\]') {
            Write-ColorHost $line -ForegroundColor Green
        }
        elseif ($line -match '^\s*\[FAIL\]') {
            Write-ColorHost $line -ForegroundColor Red
        }
        elseif ($line -match '^\s*Reason:') {
            Write-ColorHost $line -ForegroundColor Red
        }
        elseif ($line -match '^\s*\[SEVERE\]') {
            Write-ColorHost $line -ForegroundColor Red
        }
        elseif ($line -match '^\s*\[WARNING\]') {
            Write-ColorHost $line -ForegroundColor Yellow
        }
        elseif ($line -match '^\s*Failed\s+:\s*[1-9]') {
            Write-ColorHost $line -ForegroundColor Red
        }
        elseif ($line -match '^\s*Installed\s+:\s*[1-9]') {
            Write-ColorHost $line -ForegroundColor Green
        }
        elseif ($line -match 'scan only|no installs|no applicable') {
            Write-ColorHost $line -ForegroundColor Yellow
        }
        elseif ($line -match '^(SYSTEM|SESSION CONFIGURATION|APPLICABLE UPDATES|INSTALLATION RESULTS|ERRORS / WARNINGS)') {
            Write-ColorHost $line -ForegroundColor White
        }
        else {
            Write-ColorHost $line -ForegroundColor Gray
        }
    }
}

#endregion

#region Main Logic

# Validate parameters
if (-not $LogFile -and -not $LogDirectory) {
    Write-Error "You must specify either -LogFile or -LogDirectory."
    return
}

if ($LogFile -and $LogDirectory) {
    Write-Error "Specify only one of -LogFile or -LogDirectory, not both."
    return
}

# Collect files to process
$filesToProcess = @()

if ($LogFile) {
    if (-not (Test-Path -Path $LogFile)) {
        Write-Error "File not found: $LogFile"
        return
    }
    $resolvedPath = (Resolve-Path $LogFile).Path
    if (-not (Test-ThinInstallerLog $resolvedPath)) {
        Write-Error "Not a valid ThinInstaller log file (missing '[Thin Installer build:' header): $LogFile"
        return
    }
    $filesToProcess += $resolvedPath
}

if ($LogDirectory) {
    if (-not (Test-Path -Path $LogDirectory -PathType Container)) {
        Write-Error "Directory not found: $LogDirectory"
        return
    }
    $candidates = Get-ChildItem -Path $LogDirectory -File | Where-Object {
        $_.Extension -in @('.txt', '.log')
    }
    foreach ($candidate in $candidates) {
        if (Test-ThinInstallerLog $candidate.FullName) {
            $filesToProcess += $candidate.FullName
        }
        else {
            Write-Warning "Skipping non-ThinInstaller file: $($candidate.Name)"
        }
    }
    if ($filesToProcess.Count -eq 0) {
        Write-Error "No valid ThinInstaller log files found in: $LogDirectory"
        return
    }
}

# Parse all files and sort by session timestamp
$parsedResults = @()
foreach ($file in $filesToProcess) {
    $parsed = ParseThinInstallerLog -FilePath $file
    if ($parsed) {
        $parsedResults += $parsed
    }
}

# Sort by session date when processing multiple files
if ($parsedResults.Count -gt 1) {
    $parsedResults = $parsedResults | Sort-Object {
        if ($_.SessionDateTime) { $_.SessionDateTime } else { [DateTime]::MinValue }
    }
}

# Generate summaries
$allSummaryLines = @()
$isVerbose = $PSCmdlet.MyInvocation.BoundParameters.ContainsKey('Verbose') -or $VerbosePreference -eq 'Continue'

# Add file header for output file
if ($OutputFile) {
    $allSummaryLines += "$ScriptName v$ScriptVersion - Generated $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')"
    $allSummaryLines += ""
}

$fileIndex = 0
foreach ($parsed in $parsedResults) {
    $summaryLines = Format-Summary -Data $parsed -IsVerbose:$isVerbose

    # Console output
    Write-SummaryToConsole -Lines $summaryLines

    # Collect for file output
    $allSummaryLines += $summaryLines

    $fileIndex++
    if ($fileIndex -lt $parsedResults.Count) {
        Write-Host ""
        $allSummaryLines += ""
    }
}

# Multi-file count
if ($parsedResults.Count -gt 1) {
    $countMsg = "Processed $($parsedResults.Count) log files"
    Write-Host ""
    Write-ColorHost $countMsg -ForegroundColor Cyan
    $allSummaryLines += ""
    $allSummaryLines += $countMsg
}

# Write to output file if requested
if ($OutputFile) {
    $outputDir = Split-Path -Path $OutputFile -Parent
    if ($outputDir -and -not (Test-Path -Path $outputDir)) {
        try {
            New-Item -ItemType Directory -Path $outputDir -Force | Out-Null
        }
        catch {
            Write-Error "Cannot create output directory: $outputDir - $_"
            return
        }
    }

    if ((Test-Path -Path $OutputFile) -and -not $Force) {
        $response = Read-Host "Output file '$OutputFile' already exists. Overwrite? (Y/N)"
        if ($response -notmatch '^[Yy]') {
            Write-Host "Output file not written."
            return
        }
    }

    try {
        $allSummaryLines | Out-File -FilePath $OutputFile -Encoding UTF8 -Force
        Write-ColorHost "Summary written to: $OutputFile" -ForegroundColor Green
    }
    catch {
        Write-Error "Failed to write output file: $_"
        return
    }
}

#endregion

}

# SIG # Begin signature block
# MIItugYJKoZIhvcNAQcCoIItqzCCLacCAQExCzAJBgUrDgMCGgUAMGkGCisGAQQB
# gjcCAQSgWzBZMDQGCisGAQQBgjcCAR4wJgIDAQAABBAfzDtgWUsITrck0sYpfvNR
# AgEAAgEAAgEAAgEAAgEAMCEwCQYFKw4DAhoFAAQUUM0sBzKnX3KdT/8hlw3uQqzl
# n1SggibcMIIFjTCCBHWgAwIBAgIQDpsYjvnQLefv21DiCEAYWjANBgkqhkiG9w0B
# AQwFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYD
# VQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtEaWdpQ2VydCBBc3N1cmVk
# IElEIFJvb3QgQ0EwHhcNMjIwODAxMDAwMDAwWhcNMzExMTA5MjM1OTU5WjBiMQsw
# CQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cu
# ZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQw
# ggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz
# 7MKnJS7JIT3yithZwuEppz1Yq3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS
# 5F/WBTxSD1Ifxp4VpX6+n6lXFllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7
# bXHiLQwb7iDVySAdYyktzuxeTsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfI
# SKhmV1efVFiODCu3T6cw2Vbuyntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jH
# trHEtWoYOAMQjdjUN6QuBX2I9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14
# Ztk6MUSaM0C/CNdaSaTC5qmgZ92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2
# h4mXaXpI8OCiEhtmmnTK3kse5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt
# 6zPZxd9LBADMfRyVw4/3IbKyEbe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPR
# iQfhvbfmQ6QYuKZ3AeEPlAwhHbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ER
# ElvlEFDrMcXKchYiCd98THU/Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4K
# Jpn15GkvmB0t9dmpsh3lGwIDAQABo4IBOjCCATYwDwYDVR0TAQH/BAUwAwEB/zAd
# BgNVHQ4EFgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wHwYDVR0jBBgwFoAUReuir/SS
# y4IxLVGLp6chnfNtyA8wDgYDVR0PAQH/BAQDAgGGMHkGCCsGAQUFBwEBBG0wazAk
# BggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEMGCCsGAQUFBzAC
# hjdodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURS
# b290Q0EuY3J0MEUGA1UdHwQ+MDwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0
# LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcmwwEQYDVR0gBAowCDAGBgRV
# HSAAMA0GCSqGSIb3DQEBDAUAA4IBAQBwoL9DXFXnOF+go3QbPbYW1/e/Vwe9mqyh
# hyzshV6pGrsi+IcaaVQi7aSId229GhT0E0p6Ly23OO/0/4C5+KH38nLeJLxSA8hO
# 0Cre+i1Wz/n096wwepqLsl7Uz9FDRJtDIeuWcqFItJnLnU+nBgMTdydE1Od/6Fmo
# 8L8vC6bp8jQ87PcDx4eo0kxAGTVGamlUsLihVo7spNU96LHc/RzY9HdaXFSMb++h
# UD38dglohJ9vytsgjTVgHAIDyyCwrFigDkBjxZgiwbJZ9VVrzyerbHbObyMt9H5x
# aiNrIv8SuFQtJ37YOtnwtoeW/VvRXKwYw02fc7cBqZ9Xql4o4rmUMIIFkDCCA3ig
# AwIBAgIQBZsbV56OITLiOQe9p3d1XDANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQG
# EwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNl
# cnQuY29tMSEwHwYDVQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMTMw
# ODAxMTIwMDAwWhcNMzgwMTE1MTIwMDAwWjBiMQswCQYDVQQGEwJVUzEVMBMGA1UE
# ChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYD
# VQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwggIiMA0GCSqGSIb3DQEBAQUA
# A4ICDwAwggIKAoICAQC/5pBzaN675F1KPDAiMGkz7MKnJS7JIT3yithZwuEppz1Y
# q3aaza57G4QNxDAf8xukOBbrVsaXbR2rsnnyyhHS5F/WBTxSD1Ifxp4VpX6+n6lX
# FllVcq9ok3DCsrp1mWpzMpTREEQQLt+C8weE5nQ7bXHiLQwb7iDVySAdYyktzuxe
# TsiT+CFhmzTrBcZe7FsavOvJz82sNEBfsXpm7nfISKhmV1efVFiODCu3T6cw2Vbu
# yntd463JT17lNecxy9qTXtyOj4DatpGYQJB5w3jHtrHEtWoYOAMQjdjUN6QuBX2I
# 9YI+EJFwq1WCQTLX2wRzKm6RAXwhTNS8rhsDdV14Ztk6MUSaM0C/CNdaSaTC5qmg
# Z92kJ7yhTzm1EVgX9yRcRo9k98FpiHaYdj1ZXUJ2h4mXaXpI8OCiEhtmmnTK3kse
# 5w5jrubU75KSOp493ADkRSWJtppEGSt+wJS00mFt6zPZxd9LBADMfRyVw4/3IbKy
# Ebe7f/LVjHAsQWCqsWMYRJUadmJ+9oCw++hkpjPRiQfhvbfmQ6QYuKZ3AeEPlAwh
# HbJUKSWJbOUOUlFHdL4mrLZBdd56rF+NP8m800ERElvlEFDrMcXKchYiCd98THU/
# Y+whX8QgUWtvsauGi0/C1kVfnSD8oR7FwI+isX4KJpn15GkvmB0t9dmpsh3lGwID
# AQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBhjAdBgNVHQ4E
# FgQU7NfjgtJxXWRM3y5nP+e6mK4cD08wDQYJKoZIhvcNAQEMBQADggIBALth2X2p
# bL4XxJEbw6GiAI3jZGgPVs93rnD5/ZpKmbnJeFwMDF/k5hQpVgs2SV1EY+CtnJYY
# ZhsjDT156W1r1lT40jzBQ0CuHVD1UvyQO7uYmWlrx8GnqGikJ9yd+SeuMIW59mdN
# Oj6PWTkiU0TryF0Dyu1Qen1iIQqAyHNm0aAFYF/opbSnr6j3bTWcfFqK1qI4mfN4
# i/RN0iAL3gTujJtHgXINwBQy7zBZLq7gcfJW5GqXb5JQbZaNaHqasjYUegbyJLkJ
# EVDXCLG4iXqEI2FCKeWjzaIgQdfRnGTZ6iahixTXTBmyUEFxPT9NcCOGDErcgdLM
# MpSEDQgJlxxPwO5rIHQw0uA5NBCFIRUBCOhVMt5xSdkoF1BN5r5N0XWs0Mr7QbhD
# parTwwVETyw2m+L64kW4I1NsBm9nVX9GtUw/bihaeSbSpKhil9Ie4u1Ki7wb/UdK
# Dd9nZn6yW0HQO+T0O/QEY+nvwlQAUaCKKsnOeMzV6ocEGLPOr0mIr/OSmbaz5mEP
# 0oUA51Aa5BuVnRmhuZyxm7EAHu/QD09CbMkKvO5D+jpxpchNJqU1/YldvIViHTLS
# oCtU7ZpXwdv6EM8Zt4tKG48BtieVU+i2iW1bvGjUI+iLUaJW+fCmgKDWHrO8Dw9T
# dSmq6hN35N6MgSGtBxBHEa2HPQfRdbzP82Z+MIIGsDCCBJigAwIBAgIQCK1AsmDS
# nEyfXs2pvZOu2TANBgkqhkiG9w0BAQwFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UE
# ChMMRGlnaUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYD
# VQQDExhEaWdpQ2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjEwNDI5MDAwMDAwWhcN
# MzYwNDI4MjM1OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQs
# IEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgQ29kZSBTaWduaW5n
# IFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
# MIICCgKCAgEA1bQvQtAorXi3XdU5WRuxiEL1M4zrPYGXcMW7xIUmMJ+kjmjYXPXr
# NCQH4UtP03hD9BfXHtr50tVnGlJPDqFX/IiZwZHMgQM+TXAkZLON4gh9NH1MgFcS
# a0OamfLFOx/y78tHWhOmTLMBICXzENOLsvsI8IrgnQnAZaf6mIBJNYc9URnokCF4
# RS6hnyzhGMIazMXuk0lwQjKP+8bqHPNlaJGiTUyCEUhSaN4QvRRXXegYE2XFf7JP
# hSxIpFaENdb5LpyqABXRN/4aBpTCfMjqGzLmysL0p6MDDnSlrzm2q2AS4+jWufcx
# 4dyt5Big2MEjR0ezoQ9uo6ttmAaDG7dqZy3SvUQakhCBj7A7CdfHmzJawv9qYFSL
# ScGT7eG0XOBv6yb5jNWy+TgQ5urOkfW+0/tvk2E0XLyTRSiDNipmKF+wc86LJiUG
# soPUXPYVGUztYuBeM/Lo6OwKp7ADK5GyNnm+960IHnWmZcy740hQ83eRGv7bUKJG
# yGFYmPV8AhY8gyitOYbs1LcNU9D4R+Z1MI3sMJN2FKZbS110YU0/EpF23r9Yy3IQ
# KUHw1cVtJnZoEUETWJrcJisB9IlNWdt4z4FKPkBHX8mBUHOFECMhWWCKZFTBzCEa
# 6DgZfGYczXg4RTCZT/9jT0y7qg0IU0F8WD1Hs/q27IwyCQLMbDwMVhECAwEAAaOC
# AVkwggFVMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFGg34Ou2O/hfEYb7
# /mF7CIhl9E5CMB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1Ud
# DwEB/wQEAwIBhjATBgNVHSUEDDAKBggrBgEFBQcDAzB3BggrBgEFBQcBAQRrMGkw
# JAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcw
# AoY1aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJv
# b3RHNC5jcnQwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQu
# Y29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwHAYDVR0gBBUwEzAHBgVngQwB
# AzAIBgZngQwBBAEwDQYJKoZIhvcNAQEMBQADggIBADojRD2NCHbuj7w6mdNW4AIa
# pfhINPMstuZ0ZveUcrEAyq9sMCcTEp6QRJ9L/Z6jfCbVN7w6XUhtldU/SfQnuxaB
# RVD9nL22heB2fjdxyyL3WqqQz/WTauPrINHVUHmImoqKwba9oUgYftzYgBoRGRjN
# YZmBVvbJ43bnxOQbX0P4PpT/djk9ntSZz0rdKOtfJqGVWEjVGv7XJz/9kNF2ht0c
# sGBc8w2o7uCJob054ThO2m67Np375SFTWsPK6Wrxoj7bQ7gzyE84FJKZ9d3OVG3Z
# XQIUH0AzfAPilbLCIXVzUstG2MQ0HKKlS43Nb3Y3LIU/Gs4m6Ri+kAewQ3+ViCCC
# cPDMyu/9KTVcH4k4Vfc3iosJocsL6TEa/y4ZXDlx4b6cpwoG1iZnt5LmTl/eeqxJ
# zy6kdJKt2zyknIYf48FWGysj/4+16oh7cGvmoLr9Oj9FpsToFpFSi0HASIRLlk2r
# REDjjfAVKM7t8RhWByovEMQMCGQ8M4+uKIw8y4+ICw2/O/TOHnuO77Xry7fwdxPm
# 5yg/rBKupS8ibEH5glwVZsxsDsrFhsP2JjMMB0ug0wcCampAMEhLNKhRILutG4UI
# 4lkNbcoFUCvqShyepf2gpx8GdOfy1lKQ/a+FSCH5Vzu0nAPthkX0tGFuv2jiJmCG
# 6sivqf6UHedjGzqGVnhOMIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMiDDpJhjAN
# BgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQg
# SW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdpQ2Vy
# dCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0MjM1OTU5
# WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNV
# BAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hB
# MjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAtHgx
# 0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxCqvkbsDpz
# 4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qchUP+AbdJ
# gMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbDhAktVJMQ
# bzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pnYJU3Gmq6
# bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI2Wv82wnJ
# RfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS638ZxqU1
# 4lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZxst7VvwDD
# jAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17yVp2NL+cn
# T6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTnYCTKIsDq
# 1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4yUozZtqg
# PrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZMBIGA1Ud
# EwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ7MtOMB8G
# A1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIBhjAT
# BgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUHMAGG
# GGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDovL2Nh
# Y2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQwQwYD
# VR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0
# VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9
# bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0pn/N0IfF
# iBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN2n7Jd2E4
# /iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a+Z1jEMK/
# DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7pGdogP8HR
# trYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZruMvNYY2
# o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspIHBldNE2K
# 9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku/qjTY6hc
# 3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZZd/BdHLi
# Ru7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeukcyIPbAv
# jSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA6TD8dC3J
# E3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvFoW2jNrbM
# 1pD2T7m3XDCCBu0wggTVoAMCAQICEAqA7xhLjfEFgtHEdqeVdGgwDQYJKoZIhvcN
# AQELBQAwaTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMUEw
# PwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0MDk2
# IFNIQTI1NiAyMDI1IENBMTAeFw0yNTA2MDQwMDAwMDBaFw0zNjA5MDMyMzU5NTla
# MGMxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UE
# AxMyRGlnaUNlcnQgU0hBMjU2IFJTQTQwOTYgVGltZXN0YW1wIFJlc3BvbmRlciAy
# MDI1IDEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDQRqwtEsae0Oqu
# YFazK1e6b1H/hnAKAd/KN8wZQjBjMqiZ3xTWcfsLwOvRxUwXcGx8AUjni6bz52fG
# Tfr6PHRNv6T7zsf1Y/E3IU8kgNkeECqVQ+3bzWYesFtkepErvUSbf+EIYLkrLKd6
# qJnuzK8Vcn0DvbDMemQFoxQ2Dsw4vEjoT1FpS54dNApZfKY61HAldytxNM89PZXU
# P/5wWWURK+IfxiOg8W9lKMqzdIo7VA1R0V3Zp3DjjANwqAf4lEkTlCDQ0/fKJLKL
# kzGBTpx6EYevvOi7XOc4zyh1uSqgr6UnbksIcFJqLbkIXIPbcNmA98Oskkkrvt6l
# PAw/p4oDSRZreiwB7x9ykrjS6GS3NR39iTTFS+ENTqW8m6THuOmHHjQNC3zbJ6nJ
# 6SXiLSvw4Smz8U07hqF+8CTXaETkVWz0dVVZw7knh1WZXOLHgDvundrAtuvz0D3T
# +dYaNcwafsVCGZKUhQPL1naFKBy1p6llN3QgshRta6Eq4B40h5avMcpi54wm0i2e
# PZD5pPIssoszQyF4//3DoK2O65Uck5Wggn8O2klETsJ7u8xEehGifgJYi+6I03Uu
# T1j7FnrqVrOzaQoVJOeeStPeldYRNMmSF3voIgMFtNGh86w3ISHNm0IaadCKCkUe
# 2LnwJKa8TIlwCUNVwppwn4D3/Pt5pwIDAQABo4IBlTCCAZEwDAYDVR0TAQH/BAIw
# ADAdBgNVHQ4EFgQU5Dv88jHt/f3X85FxYxlQQ89hjOgwHwYDVR0jBBgwFoAU729T
# SunkBnx6yuKQVvYv1Ensy04wDgYDVR0PAQH/BAQDAgeAMBYGA1UdJQEB/wQMMAoG
# CCsGAQUFBwMIMIGVBggrBgEFBQcBAQSBiDCBhTAkBggrBgEFBQcwAYYYaHR0cDov
# L29jc3AuZGlnaWNlcnQuY29tMF0GCCsGAQUFBzAChlFodHRwOi8vY2FjZXJ0cy5k
# aWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2
# U0hBMjU2MjAyNUNBMS5jcnQwXwYDVR0fBFgwVjBUoFKgUIZOaHR0cDovL2NybDMu
# ZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1waW5nUlNBNDA5
# NlNIQTI1NjIwMjVDQTEuY3JsMCAGA1UdIAQZMBcwCAYGZ4EMAQQCMAsGCWCGSAGG
# /WwHATANBgkqhkiG9w0BAQsFAAOCAgEAZSqt8RwnBLmuYEHs0QhEnmNAciH45PYi
# T9s1i6UKtW+FERp8FgXRGQ/YAavXzWjZhY+hIfP2JkQ38U+wtJPBVBajYfrbIYG+
# Dui4I4PCvHpQuPqFgqp1PzC/ZRX4pvP/ciZmUnthfAEP1HShTrY+2DE5qjzvZs7J
# IIgt0GCFD9ktx0LxxtRQ7vllKluHWiKk6FxRPyUPxAAYH2Vy1lNM4kzekd8oEARz
# FAWgeW3az2xejEWLNN4eKGxDJ8WDl/FQUSntbjZ80FU3i54tpx5F/0Kr15zW/mJA
# xZMVBrTE2oi0fcI8VMbtoRAmaaslNXdCG1+lqvP4FbrQ6IwSBXkZagHLhFU9HCrG
# /syTRLLhAezu/3Lr00GrJzPQFnCEH1Y58678IgmfORBPC1JKkYaEt2OdDh4GmO0/
# 5cHelAK2/gTlQJINqDr6JfwyYHXSd+V08X1JUPvB4ILfJdmL+66Gp3CSBXG6IwXM
# ZUXBhtCyIaehr0XkBoDIGMUG1dUtwq1qmcwbdUfcSYCn+OwncVUXf53VJUNOaMWM
# ts0VlRYxe5nK+At+DI96HAlXHAL5SlfYxJ7La54i71McVWRP66bW+yERNpbJCjyC
# YG2j+bdpxo/1Cy4uPcU3AWVPGrbn5PhDBf3Froguzzhk++ami+r3Qrx5bIbY3TVz
# giFI7Gq3zWcwggdWMIIFPqADAgECAhADMlFYfN/evhzf5XYSzZUnMA0GCSqGSIb3
# DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFB
# MD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5
# NiBTSEEzODQgMjAyMSBDQTEwHhcNMjUwMzIwMDAwMDAwWhcNMjYwNjAzMjM1OTU5
# WjBeMQswCQYDVQQGEwJVUzEXMBUGA1UECBMOTm9ydGggQ2Fyb2xpbmExFDASBgNV
# BAcTC01vcnJpc3ZpbGxlMQ8wDQYDVQQKEwZMZW5vdm8xDzANBgNVBAMTBkxlbm92
# bzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBAOPjJ/+Kdi4SqmdpYRYm
# 5E/ctl9H/KHwC3GK10hQmHGetCCuJkcx+STyvxLIuuzh6CIupbxzDPXQ2x2/5jA6
# 2EROThgMKl/0fV+hwvZhVl45idBUi0qo+91jYeK9kXjjLrxXEsX6A5Uu4Lgl56vr
# 8h6cGZg/te9ozF3k2JN80MIzSj/F769/ZpuGq9i4j1HQ7xq/aoXFlrTD86zSC7YG
# AVU5PSU06ZOOTMAAvGm7ifKv/xQyeO8EE4acIgFB5a8RRC0JQj19eIRBhtfkh1dy
# TX/ocPdsBQICpqo0VXvRb/9iaHj3+r9CWSPtx0kQxRkpHMv/qCtM7kBscljbejLA
# VOXuhWKmNemNGIu7UMIZyro3+XzI4s1biJlGp6bTShs02EbmzlyUJTgithsYgC5n
# X/WRcaHbshvy5S1EJo8m1fi5v/4bj9OTBUOjaYAVKvOjzYE7QR4PhuN/ww8HpGdR
# jLS/eS8Sz3Jxz7EVApPNSzwycDkxAR6Y0w4ymaGy3ZnTOUJjESfwqJvqigjYMcbZ
# +LJOqbLE6bQEmQ+tZiclcdoU4FhleAqQlfksb9kLc5GcU23uIp1aKQ1nji6pxMif
# IHtE5OcMgJzy60tyX/dPpxBGbR3l6+K02v5KI1/GtrVSWxvJHKlXnIMQ4EcgIZBz
# U+NPRgmPG7ZSzYRhpZl/+PrhAgMBAAGjggIDMIIB/zAfBgNVHSMEGDAWgBRoN+Dr
# tjv4XxGG+/5hewiIZfROQjAdBgNVHQ4EFgQUcBJh2GrEdlUHxwi/fkRtYI55VfAw
# PgYDVR0gBDcwNTAzBgZngQwBBAEwKTAnBggrBgEFBQcCARYbaHR0cDovL3d3dy5k
# aWdpY2VydC5jb20vQ1BTMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAKBggrBgEF
# BQcDAzCBtQYDVR0fBIGtMIGqMFOgUaBPhk1odHRwOi8vY3JsMy5kaWdpY2VydC5j
# b20vRGlnaUNlcnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIx
# Q0ExLmNybDBToFGgT4ZNaHR0cDovL2NybDQuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0
# VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5jcmwwgZQG
# CCsGAQUFBwEBBIGHMIGEMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2Vy
# dC5jb20wXAYIKwYBBQUHMAKGUGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9E
# aWdpQ2VydFRydXN0ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEu
# Y3J0MAkGA1UdEwQCMAAwDQYJKoZIhvcNAQELBQADggIBACeB1ob7bkUKbCC40mTr
# HpXQHQbaeE7ymacNpXKyHefcQij+Op9DsduyOHNLbEojHm24k8GiGjx3ZnaZGKTh
# RQHiijGN+H8Qy27SDvw2MzLnyB7XNg+uCPqIf6xWLdtdQ65T7MaBon6BIX/shzxQ
# t+Jpkr1+qcAP6wWCQ0Q0W5I0w5PKb19dMaT26mw6mnGd06pnTvgpCVRnVy8UJtb7
# Ltt7dfE1G0Cz3LdWW9iBVCI73n/DGWhO8fbiK4D4NpdiNnWVfsxhJ7DSb+6RKJXP
# eG3GwGbmuyDD3D2N9mJnW/6VYAiBwnewGRqwA6D20QKPB0QFHlqVHwkyoYIynVcE
# dfM4K3dtxP8mh6IrEEbWfctNLRgnvRsEE/GnAEmpHxLyzWRx+FILzlaZmRPSyYAO
# O8bE4nWNOTKLdpa/OMum6r/qDJmjcLs80aqMlRiG1k4F2grobscDV+lzy65du9+W
# a8qUeY6rZsnHK02DGOf4iWLqEgaUf36QH10MUpGgj/dkK5cwLCpA1+/d+mySgEF3
# 1N2RHkf5bRVq0DsR8AGT76npVtpyRdnIlIHksfB0G8dDjioKEzCneATEUkketoL1
# ML+ZOcM8t2uURmjK8ZecklHZF74jmrkYVyve2HrxcHOr2qPuwOkRQ5NLnYluYKQo
# Qv4KrbHKxS/bQKd55kJZVzTbMYIGSDCCBkQCAQEwfTBpMQswCQYDVQQGEwJVUzEX
# MBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNVBAMTOERpZ2lDZXJ0IFRydXN0
# ZWQgRzQgQ29kZSBTaWduaW5nIFJTQTQwOTYgU0hBMzg0IDIwMjEgQ0ExAhADMlFY
# fN/evhzf5XYSzZUnMAkGBSsOAwIaBQCgeDAYBgorBgEEAYI3AgEMMQowCKACgACh
# AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM
# BgorBgEEAYI3AgEVMCMGCSqGSIb3DQEJBDEWBBRvaJrW2tl/Uz0Jp81mtWcLmX/0
# ZzANBgkqhkiG9w0BAQEFAASCAgBDG8YoHWM/e3tffOFTK0b3VPQFYjG2KQb0f0l8
# XPQ+uNRCIsKZbjUwCiDqlxttt6e7RjmzRzgp1w1ccxreEvg4v/bpuyO4k+FiGYtP
# OfLjNtuOMWcVQZAeMr2w+Z4PBMYp8Qd0f0dDRHVPMSuqF1YOYfFAHUiRlWJONpKA
# xLC09HfRcUvcxUl3CjWPijMd0V+Q+Iy1i019uVEAQLDPapkzA7RApGHnDPvDWIfU
# u1yDcaUmNLaYSSnJcPpUuY4GHcRkWUP6wr1Vf29PfyB2AAv1HROaGoXphlO3dGUt
# YMLFByQSjr6q7qlFrComJ0/p5pZB57aL1VuuWY6XTAXpVA9zW6Rrpv23r3ruTy/o
# 9JI2yD6jbBLamy8naMU2Q2OIScoWIoXAEeI9YtmHXsRJDlEXZna1rWfFaEqCRFJ5
# gUHg4/IN8gERW9QfwTE70bEgwRtcIPY0JtYG7Q2jZ0Gnnn3mAOrtEo63JkL82wQ/
# 5CqrxW5Xt0UfuOYqySWtFN17BwRuB4QsbJ/tHWNH4zxXQdJlE5pWPG2H6qXwCCwA
# d32RxJWqhmkSB/2UM2PVOwh6rvxUEK05P1P1CCohGBDHQ2B6rfdYKIxBboPHTYpZ
# j3ddT33JWXcWiAiL9m7XwTjzuHp6O/Dq8SoDzrWxk1QZiJ3FTmsrRxbAJD113mZr
# rJbIhaGCAyYwggMiBgkqhkiG9w0BCQYxggMTMIIDDwIBATB9MGkxCzAJBgNVBAYT
# AlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQg
# VHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEC
# EAqA7xhLjfEFgtHEdqeVdGgwDQYJYIZIAWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMx
# CwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJBTEPFw0yNjA0MDkxNTUxNDBaMC8GCSqG
# SIb3DQEJBDEiBCCPRs7vMR+ih8NrBeBcnteleeRENh3fbrXb3k+R9KnVFzANBgkq
# hkiG9w0BAQEFAASCAgCCqDR59qPpV46rkOvEoBbbfmzMEQJHSVcrpk+bNEaw7Cmh
# fFygA9jD+bOPZnfroT5WeyWJW5/sOwxmdpV0hb9Gqb/9UuiVpawJcYGBW+inKToj
# HRQyYie5VekLsMJZ1DQ3Dgr1faUU8yOxB23oVzsv47qc8m/DfiP1ssZ2OZTKX7ST
# SkODLjKhNdfgUWqT2jwZ/4RaFVSL6wR0Q1cUYjEiYJtXpeIK5/js1bEYWD2GxLjr
# 5c0BFSvqQoDVtwxGTrsHAtBJcPQAc+LWux0mISnRPQgYCdUhHcNaYMK6CZ20j+Bm
# 8mOle1UNREgbq80Z1OcbnK/e27dVidv8aMNRLB2dxuHMJ6ZjwNLUIpz48Lxsf7Pz
# xjqNrsXjZpRciXeFpq2qSbGNIOhwQgXlI0CmX4VAp+KvMKNnmzgKULkD/E0bbwiL
# GEsG9cAEd8ovWgyXdgAtscOzcoiuZQ8k+PwukopaZBDVzG5DTmnWiwMGEz6UXC55
# msCGG/GLaUjDE1905ejzLvu3b5sqs6hXb9G34O0VSqE7xfMdbWEOA/UL2j1rws0n
# ckrK4r0Cw+uknI3f1OxapZE/wSx7/PAc5+QJliPcUKo9KxXpXGkaZn9WHKOXfUua
# 7/bg+56AyVYWUbhozk37jPfIzEM+HMfOBARtaZNCaRJbL7wkoNF71P2rM6eXeg==
# SIG # End signature block