Public/Get/Get-FilesUsingKeywords.ps1

function Get-FilesUsingKeywords {
    <#
    .SYNOPSIS
        Searches files in a directory for one or more keywords.
 
    .DESCRIPTION
        Recursively searches files under a given directory for lines matching
        any of the supplied keywords. The search always runs asynchronously: a
        PowerShell runspace is used for local execution and Invoke-Command
        -AsJob is used for remote execution. Either way Wait-TerminalState
        drives the poll/spinner loop until completion.
 
    .PARAMETER Path
        The directory to search. Required.
 
    .PARAMETER Keywords
        One or more keywords (or regex patterns) to search for within file
        contents. Required.
 
    .PARAMETER ComputerName
        Remote computer to run the search on. When omitted the search runs
        locally.
 
    .PARAMETER Credential
        Credentials used when connecting to the remote machine or accessing a
        protected directory path.
 
    .PARAMETER FileFilter
        Wildcard filter applied to file names (e.g. '*.log', '*.txt'). Defaults
        to '*' (all files).
 
    .PARAMETER NoRecurse
        When specified, only the top-level directory is searched instead of
        recursing into subdirectories.
 
    .PARAMETER CaseSensitive
        Treat keyword matching as case-sensitive.
 
    .PARAMETER SimpleMatch
        Treat keywords as literal strings rather than regular expressions.
 
    .PARAMETER UseSsh
        Use SSH transport when establishing the remote PSSession.
 
    .PARAMETER UseCredSSP
        Enable CredSSP authentication when establishing the remote PSSession.
 
    .PARAMETER SshPort
        SSH port to use when -UseSsh is specified. Defaults to 22.
 
    .PARAMETER TimeoutSeconds
        Maximum seconds to wait for the search to complete. Defaults to 1200.
 
    .PARAMETER PollSeconds
        How often Wait-TerminalState re-checks job/runspace state. Defaults to
        30.
 
    .PARAMETER HeartbeatSeconds
        How often a "still searching" log line is emitted while waiting.
        Defaults to 120.
 
    .PARAMETER ExportCsv
        When specified, exports match results to CSV. If -CsvPath is not
        provided, a file name is created using
        settings.fileSearch.singleFileNamePattern (when
        settings.fileSearch.appendToSingleFile=true) or
        settings.fileSearch.exportFileNamePattern in
        settings.fileSearch.exportDirectory when configured, otherwise in the
        current directory with a built-in fallback naming pattern.
 
    .PARAMETER CsvPath
        Path to write CSV results. Implies CSV export.
 
    .PARAMETER IncludeBinary
        When specified, searches binary and non-text files. By default, binary
        files (detected via encoding) are skipped.
 
    .OUTPUTS
        PSCustomObject summary with metadata, match counts, file summary,
        and match detail records.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Path,
        [Parameter(Mandatory)][string[]]$Keywords,

        [string]$ComputerName,
        [pscredential]$Credential,

        [string]$FileFilter = '*',
        [switch]$NoRecurse,
        [switch]$CaseSensitive,
        [switch]$SimpleMatch,

        [switch]$UseSsh,
        [switch]$UseCredSSP,
        [int]$SshPort = 22,

        [ValidateRange(10, 86400)][int]$TimeoutSeconds = 1200,
        [ValidateRange(1, 3600)] [int]$PollSeconds = 30,
        [ValidateRange(0, 3600)] [int]$HeartbeatSeconds = 120,

        [switch]$ExportCsv,
        [string]$CsvPath,
        [switch]$IncludeBinary
    )

    Set-StrictMode -Version Latest
    Initialize-TechToolboxRuntime

    # Core search logic – a self-contained scriptblock with no module-level
    # dependencies so it executes cleanly in a fresh runspace or remote session.
    $searchBlock = {
        param(
            [string]  $SearchPath,
            [string[]]$SearchKeywords,
            [string]  $Filter,
            [bool]    $Recurse,
            [bool]    $IsCaseSensitive,
            [bool]    $IsSimpleMatch,
            [bool]    $SearchBinary
        )

        $gciParams = @{
            LiteralPath = $SearchPath
            Filter      = $Filter
            ErrorAction = 'SilentlyContinue'
        }
        if ($Recurse) { $gciParams.Recurse = $true }

        # Use PSIsContainer filtering instead of -File for compatibility with older hosts.
        $files = @(Get-ChildItem @gciParams | Where-Object { -not $_.PSIsContainer })
        $hits = New-Object System.Collections.Generic.List[object]

        foreach ($file in $files) {
            # Skip binary files unless explicitly requested
            if (-not $SearchBinary) {
                try {
                    $encoding = [System.Text.Encoding]::UTF8
                    $reader = [System.IO.StreamReader]::new($file.FullName, $encoding, $true)
                    $firstLine = $reader.ReadLine()
                    $reader.Dispose()
                    if ($null -eq $firstLine) { continue } # Empty file, skip
                    # If detected encoding is not UTF8 or contains null bytes, likely binary
                    if ($firstLine -match '[\x00-\x08\x0E-\x1F]') {
                        continue # Skip binary
                    }
                }
                catch {
                    continue # Skip files we can't read as text
                }
            }

            foreach ($kw in $SearchKeywords) {
                $ssParams = @{
                    LiteralPath = $file.FullName
                    Pattern     = $kw
                    ErrorAction = 'SilentlyContinue'
                }
                if ($IsCaseSensitive) { $ssParams.CaseSensitive = $true }
                if ($IsSimpleMatch) { $ssParams.SimpleMatch = $true }

                $ssMatches = Select-String @ssParams
                foreach ($m in $ssMatches) {
                    # Sanitize line: remove control characters but keep printable ones
                    $sanitizedLine = $m.Line -replace '[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]', ''
                    $hits.Add([PSCustomObject]@{
                            FilePath   = $m.Path
                            LineNumber = $m.LineNumber
                            Line       = $sanitizedLine.Trim()
                            Keyword    = $kw
                        })
                }
            }
        }

        return $hits
    }

    # Shared arg list keeps both paths in sync.
    $invokeArgs = @(
        $Path,
        $Keywords,
        $FileFilter,
        (-not $NoRecurse),
        $CaseSensitive.IsPresent,
        $SimpleMatch.IsPresent,
        $IncludeBinary.IsPresent
    )

    $runRemote = (-not [string]::IsNullOrWhiteSpace($ComputerName))
    $resolvedComputerName = if ($runRemote) { $ComputerName } else { $env:COMPUTERNAME }
    $searchStartedAt = Get-Date
    $allMatches = @()

    # -----------------------------------------------------------------------
    # REMOTE PATH – Invoke-Command -AsJob + Wait-TerminalState
    # -----------------------------------------------------------------------
    if ($runRemote) {
        Write-Log -Level Info -Message "Searching files on $ComputerName under '$Path' for keyword(s): $($Keywords -join ', ')"

        $creds = $Credential
        if ($script:cfg.settings.defaults.promptForCredentials -and -not $creds) {
            $creds = Get-Credential -Message "Enter credentials for $ComputerName"
        }

        $session = $null
        $job = $null
        try {
            $session = Start-NewPSRemoteSession `
                -ComputerName $ComputerName `
                -Credential   $creds `
                -UseSsh:      $UseSsh `
                -UseCredSSP:  $UseCredSSP `
                -Port         $SshPort

            $job = Invoke-Command -Session $session -ScriptBlock $searchBlock `
                -ArgumentList $invokeArgs -AsJob

            # -- Poll scriptblock closes over $job --
            $poll = {
                @{ State = $job.State; Job = $job }
            }

            $getStatus = {
                param($obj)
                $obj.State   # NotStarted / Running / Completed / Failed / Stopped
            }

            $terminal = @{
                'Completed' = @{
                    Level   = 'Ok'
                    Message = "File search on $ComputerName completed."
                    Return  = $true
                }
                'Failed'    = @{
                    Level   = 'Error'
                    Message = {
                        param($obj, $status)
                        $reason = try { ($obj.Job.ChildJobs[0].JobStateInfo.Reason.Message) } catch { 'unknown error' }
                        "File search job failed on ${ComputerName}: $reason"
                    }
                    Return  = $true
                }
                'Stopped'   = @{
                    Level   = 'Warn'
                    Message = "File search job was stopped on $ComputerName."
                    Return  = $true
                }
            }

            $final = Wait-TerminalState `
                -Target           "FileSearch:$ComputerName" `
                -PollScript       $poll `
                -GetStatus        $getStatus `
                -TerminalStates   $terminal `
                -TimeoutSeconds   $TimeoutSeconds `
                -PollSeconds      $PollSeconds `
                -HeartbeatSeconds $HeartbeatSeconds `
                -WaitingMessage   "Searching $ComputerName "

            if ($final.State -ne 'Completed') { return }

            $results = Receive-Job -Job $final.Job -ErrorAction Stop
            $allMatches = @($results | ForEach-Object {
                    [PSCustomObject]@{
                        ComputerName = $ComputerName
                        FileName     = Split-Path -Leaf $_.FilePath
                        FilePath     = $_.FilePath
                        LineNumber   = $_.LineNumber
                        Line         = $_.Line
                        Keyword      = $_.Keyword
                    }
                })
        }
        catch {
            Write-Log -Level Error -Message "Get-FilesUsingKeywords failed on ${ComputerName}: $($_.Exception.Message)"
            throw
        }
        finally {
            if ($job) { Remove-Job -Job $job -Force -ErrorAction SilentlyContinue }
            if ($session) { Stop-PSRemoteSession -Session $session -Confirm:$false }
        }
    }
    # -----------------------------------------------------------------------
    # LOCAL PATH – PowerShell runspace + BeginInvoke + Wait-TerminalState
    # -----------------------------------------------------------------------
    else {
        Write-Log -Level Info -Message "Searching files locally under '$Path' for keyword(s): $($Keywords -join ', ')"

        $rs = $null
        $ps = $null
        $asyncResult = $null
        try {
            $rs = [System.Management.Automation.Runspaces.RunspaceFactory]::CreateRunspace()
            $rs.Open()

            $ps = [powershell]::Create()
            $ps.Runspace = $rs

            # AddScript + positional AddArgument to match the param() declaration.
            [void]$ps.AddScript($searchBlock.ToString())
            foreach ($arg in $invokeArgs) { [void]$ps.AddArgument($arg) }

            $asyncResult = $ps.BeginInvoke()

            # -- Poll scriptblock closes over $asyncResult and $ps --
            $poll = {
                @{ Completed = $asyncResult.IsCompleted; PS = $ps }
            }

            $getStatus = {
                param($obj)
                if ($obj.Completed) { 'Completed' } else { 'Running' }
            }

            $terminal = @{
                'Completed' = @{
                    Level   = 'Ok'
                    Message = 'Local file search completed.'
                    Return  = $true
                }
            }

            $null = Wait-TerminalState `
                -Target           "FileSearch:$env:COMPUTERNAME" `
                -PollScript       $poll `
                -GetStatus        $getStatus `
                -TerminalStates   $terminal `
                -TimeoutSeconds   $TimeoutSeconds `
                -PollSeconds      $PollSeconds `
                -HeartbeatSeconds $HeartbeatSeconds `
                -WaitingMessage   'Searching '

            $results = $ps.EndInvoke($asyncResult)

            $runspaceErrors = @($ps.Streams.Error | Where-Object { $_ })
            $runspaceErrorMessages = @(
                $runspaceErrors |
                ForEach-Object {
                    if ($_.Exception -and -not [string]::IsNullOrWhiteSpace($_.Exception.Message)) {
                        $_.Exception.Message
                    }
                    else {
                        $_.ToString()
                    }
                } |
                Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
            )

            if (@($runspaceErrorMessages).Count -gt 0) {
                $errMsg = $runspaceErrorMessages -join '; '
                throw "Local search runspace reported errors: $errMsg"
            }

            if ($ps.HadErrors) {
                Write-Verbose 'Local search runspace signaled HadErrors, but no actionable error details were present.'
            }

            $allMatches = @($results | ForEach-Object {
                    [PSCustomObject]@{
                        ComputerName = $env:COMPUTERNAME
                        FileName     = Split-Path -Leaf $_.FilePath
                        FilePath     = $_.FilePath
                        LineNumber   = $_.LineNumber
                        Line         = $_.Line
                        Keyword      = $_.Keyword
                    }
                })
        }
        catch {
            Write-Log -Level Error -Message "Get-FilesUsingKeywords failed locally: $($_.Exception.Message)"
            throw
        }
        finally {
            if ($ps -and $asyncResult -and -not $asyncResult.IsCompleted) {
                $ps.Stop()
            }
            if ($ps) { $ps.Dispose() }
            if ($rs) { $rs.Dispose() }
        }
    }

    $searchCompletedAt = Get-Date
    $duration = New-TimeSpan -Start $searchStartedAt -End $searchCompletedAt

    $files = @(
        $allMatches |
        Group-Object -Property FilePath |
        Sort-Object -Property @{ Expression = 'Count'; Descending = $true }, @{ Expression = { Split-Path -Leaf $_.Name }; Descending = $false }, @{ Expression = 'Name'; Descending = $false } |
        ForEach-Object {
            $firstItem = @($_.Group)[0]
            [PSCustomObject]@{
                FileName   = if ($firstItem -and $firstItem.FileName) { $firstItem.FileName } else { Split-Path -Leaf $_.Name }
                FilePath   = $_.Name
                MatchCount = $_.Count
                Keywords   = ($_.Group.Keyword | Sort-Object -Unique) -join ', '
            } | Add-Member -MemberType ScriptMethod -Name ToString -Value {
                '{0}: {1} match(es) [{2}]' -f (Split-Path -Leaf $this.FilePath), $this.MatchCount, $this.Keywords
            } -Force -PassThru
        }
    )

    $csvOutputPath = $null
    if ($ExportCsv -or -not [string]::IsNullOrWhiteSpace($CsvPath)) {
        $targetPath = $CsvPath
        if ([string]::IsNullOrWhiteSpace($targetPath)) {
            $defaultExportDir = (Get-Location).Path
            $defaultFileNamePattern = 'Get-FilesUsingKeywords-{computer}-{yyyyMMdd_HHmmss}.csv'
            $singleFileNamePattern = 'Get-FilesUsingKeywords-{computer}.csv'
            $appendToSingleFile = $false
            if ($script:cfg -and $script:cfg.settings -and $script:cfg.settings.fileSearch) {
                $cfgExportDir = [string]$script:cfg.settings.fileSearch.exportDirectory
                if (-not [string]::IsNullOrWhiteSpace($cfgExportDir)) {
                    $defaultExportDir = $cfgExportDir
                }

                $cfgFileNamePattern = [string]$script:cfg.settings.fileSearch.exportFileNamePattern
                if (-not [string]::IsNullOrWhiteSpace($cfgFileNamePattern)) {
                    $defaultFileNamePattern = $cfgFileNamePattern
                }

                $cfgSingleFileNamePattern = [string]$script:cfg.settings.fileSearch.singleFileNamePattern
                if (-not [string]::IsNullOrWhiteSpace($cfgSingleFileNamePattern)) {
                    $singleFileNamePattern = $cfgSingleFileNamePattern
                }

                if ($null -ne $script:cfg.settings.fileSearch.appendToSingleFile) {
                    $appendToSingleFile = [bool]$script:cfg.settings.fileSearch.appendToSingleFile
                }
            }

            if ($appendToSingleFile) {
                $defaultFileNamePattern = $singleFileNamePattern
            }

            $resolvedFileName = $defaultFileNamePattern.
            Replace('{computer}', $resolvedComputerName).
            Replace('{yyyyMMdd}', (Get-Date).ToString('yyyyMMdd')).
            Replace('{yyyyMMdd_HHmmss}', (Get-Date).ToString('yyyyMMdd_HHmmss')).
            Replace('{yyyyMMdd-HHmmss}', (Get-Date).ToString('yyyyMMdd-HHmmss'))

            # Sanitize invalid Windows filename characters from configured patterns.
            foreach ($badChar in [System.IO.Path]::GetInvalidFileNameChars()) {
                $resolvedFileName = $resolvedFileName.Replace([string]$badChar, '_')
            }

            # Windows does not allow trailing spaces/periods in file names.
            $resolvedFileName = $resolvedFileName.Trim().TrimEnd('.')

            if (-not $resolvedFileName.EndsWith('.csv', [System.StringComparison]::OrdinalIgnoreCase)) {
                $resolvedFileName = "$resolvedFileName.csv"
            }

            if ([string]::IsNullOrWhiteSpace($resolvedFileName)) {
                $resolvedFileName = "Get-FilesUsingKeywords-{0}-{1}.csv" -f $resolvedComputerName, (Get-Date -Format 'yyyyMMdd_HHmmss')
            }

            $targetPath = Join-Path -Path $defaultExportDir -ChildPath (
                $resolvedFileName
            )
        }

        $targetDir = Split-Path -Parent $targetPath
        if (-not [string]::IsNullOrWhiteSpace($targetDir) -and -not (Test-Path -LiteralPath $targetDir)) {
            $null = New-Item -ItemType Directory -Path $targetDir -Force
        }

        $exportRows = @($allMatches) | Select-Object ComputerName, FileName, FilePath, LineNumber, Keyword, Line
        $appendMode = Test-Path -LiteralPath $targetPath

        if ($appendMode) {
            $exportRows | Export-Csv -LiteralPath $targetPath -NoTypeInformation -Encoding UTF8 -Append
        }
        else {
            $exportRows | Export-Csv -LiteralPath $targetPath -NoTypeInformation -Encoding UTF8
        }

        $csvOutputPath = try { (Resolve-Path -LiteralPath $targetPath).Path } catch { $targetPath }
        if ($appendMode) {
            Write-Log -Level Info -Message "Appended $(@($allMatches).Count) match(es) to CSV: $csvOutputPath"
        }
        else {
            Write-Log -Level Info -Message "Exported $(@($allMatches).Count) match(es) to CSV: $csvOutputPath"
        }
    }

    [PSCustomObject]@{
        ComputerName    = $resolvedComputerName
        SearchPath      = $Path
        Keywords        = ($Keywords -join ', ')
        FileFilter      = $FileFilter
        Recurse         = (-not $NoRecurse.IsPresent)
        CaseSensitive   = $CaseSensitive.IsPresent
        SimpleMatch     = $SimpleMatch.IsPresent
        IncludeBinary   = $IncludeBinary.IsPresent
        StartedAt       = $searchStartedAt
        CompletedAt     = $searchCompletedAt
        DurationSeconds = [math]::Round($duration.TotalSeconds, 3)
        MatchCount      = @($allMatches).Count
        FileCount       = @($files).Count
        CsvPath         = $csvOutputPath
        Files           = @($files)
        Matches         = @($allMatches)
    }
}

# SIG # Begin signature block
# MIIfAgYJKoZIhvcNAQcCoIIe8zCCHu8CAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAb441nl+covacm
# wTUN9wUCXqwF8NF3NxDh/nO1rlk+/qCCGEowggUMMIIC9KADAgECAhAR+U4xG7FH
# qkyqS9NIt7l5MA0GCSqGSIb3DQEBCwUAMB4xHDAaBgNVBAMME1ZBRFRFSyBDb2Rl
# IFNpZ25pbmcwHhcNMjUxMjE5MTk1NDIxWhcNMjYxMjE5MjAwNDIxWjAeMRwwGgYD
# VQQDDBNWQURURUsgQ29kZSBTaWduaW5nMIICIjANBgkqhkiG9w0BAQEFAAOCAg8A
# MIICCgKCAgEA3pzzZIUEY92GDldMWuzvbLeivHOuMupgpwbezoG5v90KeuN03S5d
# nM/eom/PcIz08+fGZF04ueuCS6b48q1qFnylwg/C/TkcVRo0WFcKoFGT8yGxdfXi
# caHtapZfbSRh73r7qR7w0CioVveNBVgfMsTgE0WKcuwxemvIe/ptmkfzwAiw/IAC
# Ib0E0BjiX4PySbwWy/QKy/qMXYY19xpRItVTKNBtXzADUtzPzUcFqJU83vM2gZFs
# Or0MhPvM7xEVkOWZFBAWAubbMCJ3rmwyVv9keVDJChhCeLSz2XR11VGDOEA2OO90
# Y30WfY9aOI2sCfQcKMeJ9ypkHl0xORdhUwZ3Wz48d3yJDXGkduPm2vl05RvnA4T6
# 29HVZTmMdvP2475/8nLxCte9IB7TobAOGl6P1NuwplAMKM8qyZh62Br23vcx1fXZ
# TJlKCxBFx1nTa6VlIJk+UbM4ZPm954peB/fIqEacm8LkZ0cPwmLE5ckW7hfK4Trs
# o+RaudU1sKeA+FvpOWgsPccVRWcEYyGkwbyTB3xrIBXA+YckbANZ0XL7fv7x29hn
# gXbZipGu3DnTISiFB43V4MhNDKZYfbWdxze0SwLe8KzIaKnwlwRgvXDMwXgk99Mi
# EbYa3DvA/5ZWikLW9PxBFD7Vdr8ZiG/tRC9I2Y6fnb+PVoZKc/2xsW0CAwEAAaNG
# MEQwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMB0GA1UdDgQW
# BBRfYLVE8caSc990rnrIHUjoB7X/KjANBgkqhkiG9w0BAQsFAAOCAgEAiGB2Wmk3
# QBtd1LcynmxHzmu+X4Y5DIpMMNC2ahsqZtPUVcGqmb5IFbVuAdQphL6PSrDjaAR8
# 1S8uTfUnMa119LmIb7di7TlH2F5K3530h5x8JMj5EErl0xmZyJtSg7BTiBA/UrMz
# 6WCf8wWIG2/4NbV6aAyFwIojfAcKoO8ng44Dal/oLGzLO3FDE5AWhcda/FbqVjSJ
# 1zMfiW8odd4LgbmoyEI024KkwOkkPyJQ2Ugn6HMqlFLazAmBBpyS7wxdaAGrl18n
# 6bS7QuAwCd9hitdMMitG8YyWL6tKeRSbuTP5E+ASbu0Ga8/fxRO5ZSQhO6/5ro1j
# PGe1/Kr49Uyuf9VSCZdNIZAyjjeVAoxmV0IfxQLKz6VOG0kGDYkFGskvllIpQbQg
# WLuPLJxoskJsoJllk7MjZJwrpr08+3FQnLkRuisjDOc3l4VxFUsUe4fnJhMUONXT
# Sk7vdspgxirNbLmXU4yYWdsizz3nMUR0zebUW29A+HYme16hzrMPOeyoQjy4I5XX
# 3wXAFdworfPEr/ozDFrdXKgbLwZopymKbBwv6wtT7+1zVhJXr+jGVQ1TWr6R+8ea
# tIOFnY7HqGaxe5XB7HzOwJKdj+bpHAfXft1vUoiKr16VajLigcYCG8MdwC3sngO3
# JDyv2V+YMfsYBmItMGBwvizlQ6557NbK95EwggWNMIIEdaADAgECAhAOmxiO+dAt
# 5+/bUOIIQBhaMA0GCSqGSIb3DQEBDAUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV
# BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0yMjA4MDEwMDAwMDBa
# Fw0zMTExMDkyMzU5NTlaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy
# dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lD
# ZXJ0IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
# ggIBAL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3E
# MB/zG6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKy
# unWZanMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsF
# xl7sWxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU1
# 5zHL2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJB
# MtfbBHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObUR
# WBf3JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6
# nj3cAORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxB
# YKqxYxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5S
# UUd0viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+x
# q4aLT8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjggE6MIIB
# NjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBTs1+OC0nFdZEzfLmc/57qYrhwP
# TzAfBgNVHSMEGDAWgBRF66Kv9JLLgjEtUYunpyGd823IDzAOBgNVHQ8BAf8EBAMC
# AYYweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdp
# Y2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNv
# bS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwRQYDVR0fBD4wPDA6oDigNoY0
# aHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJlZElEUm9vdENB
# LmNybDARBgNVHSAECjAIMAYGBFUdIAAwDQYJKoZIhvcNAQEMBQADggEBAHCgv0Nc
# Vec4X6CjdBs9thbX979XB72arKGHLOyFXqkauyL4hxppVCLtpIh3bb0aFPQTSnov
# Lbc47/T/gLn4offyct4kvFIDyE7QKt76LVbP+fT3rDB6mouyXtTP0UNEm0Mh65Zy
# oUi0mcudT6cGAxN3J0TU53/oWajwvy8LpunyNDzs9wPHh6jSTEAZNUZqaVSwuKFW
# juyk1T3osdz9HNj0d1pcVIxv76FQPfx2CWiEn2/K2yCNNWAcAgPLILCsWKAOQGPF
# mCLBsln1VWvPJ6tsds5vIy30fnFqI2si/xK4VC0nftg62fC2h5b9W9FcrBjDTZ9z
# twGpn1eqXijiuZQwgga0MIIEnKADAgECAhANx6xXBf8hmS5AQyIMOkmGMA0GCSqG
# SIb3DQEBCwUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJbmMx
# GTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0IFRy
# dXN0ZWQgUm9vdCBHNDAeFw0yNTA1MDcwMDAwMDBaFw0zODAxMTQyMzU5NTlaMGkx
# CzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UEAxM4
# RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcgUlNBNDA5NiBTSEEyNTYg
# MjAyNSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQC0eDHTCphB
# cr48RsAcrHXbo0ZodLRRF51NrY0NlLWZloMsVO1DahGPNRcybEKq+RuwOnPhof6p
# vF4uGjwjqNjfEvUi6wuim5bap+0lgloM2zX4kftn5B1IpYzTqpyFQ/4Bt0mAxAHe
# HYNnQxqXmRinvuNgxVBdJkf77S2uPoCj7GH8BLuxBG5AvftBdsOECS1UkxBvMgEd
# gkFiDNYiOTx4OtiFcMSkqTtF2hfQz3zQSku2Ws3IfDReb6e3mmdglTcaarps0wjU
# jsZvkgFkriK9tUKJm/s80FiocSk1VYLZlDwFt+cVFBURJg6zMUjZa/zbCclF83bR
# VFLeGkuAhHiGPMvSGmhgaTzVyhYn4p0+8y9oHRaQT/aofEnS5xLrfxnGpTXiUOeS
# LsJygoLPp66bkDX1ZlAeSpQl92QOMeRxykvq6gbylsXQskBBBnGy3tW/AMOMCZIV
# NSaz7BX8VtYGqLt9MmeOreGPRdtBx3yGOP+rx3rKWDEJlIqLXvJWnY0v5ydPpOjL
# 6s36czwzsucuoKs7Yk/ehb//Wx+5kMqIMRvUBDx6z1ev+7psNOdgJMoiwOrUG2Zd
# SoQbU2rMkpLiQ6bGRinZbI4OLu9BMIFm1UUl9VnePs6BaaeEWvjJSjNm2qA+sdFU
# eEY0qVjPKOWug/G6X5uAiynM7Bu2ayBjUwIDAQABo4IBXTCCAVkwEgYDVR0TAQH/
# BAgwBgEB/wIBADAdBgNVHQ4EFgQU729TSunkBnx6yuKQVvYv1Ensy04wHwYDVR0j
# BBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMGA1Ud
# JQQMMAoGCCsGAQUFBwMIMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYYaHR0
# cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2FjZXJ0
# cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNVHR8E
# PDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVz
# dGVkUm9vdEc0LmNybDAgBgNVHSAEGTAXMAgGBmeBDAEEAjALBglghkgBhv1sBwEw
# DQYJKoZIhvcNAQELBQADggIBABfO+xaAHP4HPRF2cTC9vgvItTSmf83Qh8WIGjB/
# T8ObXAZz8OjuhUxjaaFdleMM0lBryPTQM2qEJPe36zwbSI/mS83afsl3YTj+IQhQ
# E7jU/kXjjytJgnn0hvrV6hqWGd3rLAUt6vJy9lMDPjTLxLgXf9r5nWMQwr8Myb9r
# EVKChHyfpzee5kH0F8HABBgr0UdqirZ7bowe9Vj2AIMD8liyrukZ2iA/wdG2th9y
# 1IsA0QF8dTXqvcnTmpfeQh35k5zOCPmSNq1UH410ANVko43+Cdmu4y81hjajV/gx
# dEkMx1NKU4uHQcKfZxAvBAKqMVuqte69M9J6A47OvgRaPs+2ykgcGV00TYr2Lr3t
# y9qIijanrUR3anzEwlvzZiiyfTPjLbnFRsjsYg39OlV8cipDoq7+qNNjqFzeGxcy
# tL5TTLL4ZaoBdqbhOhZ3ZRDUphPvSRmMThi0vw9vODRzW6AxnJll38F0cuJG7uEB
# YTptMSbhdhGQDpOXgpIUsWTjd6xpR6oaQf/DJbg3s6KCLPAlZ66RzIg9sC+NJpud
# /v4+7RWsWCiKi9EOLLHfMR2ZyJ/+xhCx9yHbxtl5TPau1j/1MIDpMPx0LckTetiS
# uEtQvLsNz3Qbp7wGWqbIiOWCnb5WqxL3/BAPvIXKUjPSxyZsq8WhbaM2tszWkPZP
# ubdcMIIG7TCCBNWgAwIBAgIQCoDvGEuN8QWC0cR2p5V0aDANBgkqhkiG9w0BAQsF
# ADBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/BgNV
# BAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYgU0hB
# MjU2IDIwMjUgQ0ExMB4XDTI1MDYwNDAwMDAwMFoXDTM2MDkwMzIzNTk1OVowYzEL
# MAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJE
# aWdpQ2VydCBTSEEyNTYgUlNBNDA5NiBUaW1lc3RhbXAgUmVzcG9uZGVyIDIwMjUg
# MTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANBGrC0Sxp7Q6q5gVrMr
# V7pvUf+GcAoB38o3zBlCMGMyqJnfFNZx+wvA69HFTBdwbHwBSOeLpvPnZ8ZN+vo8
# dE2/pPvOx/Vj8TchTySA2R4QKpVD7dvNZh6wW2R6kSu9RJt/4QhguSssp3qome7M
# rxVyfQO9sMx6ZAWjFDYOzDi8SOhPUWlLnh00Cll8pjrUcCV3K3E0zz09ldQ//nBZ
# ZREr4h/GI6Dxb2UoyrN0ijtUDVHRXdmncOOMA3CoB/iUSROUINDT98oksouTMYFO
# nHoRh6+86Ltc5zjPKHW5KqCvpSduSwhwUmotuQhcg9tw2YD3w6ySSSu+3qU8DD+n
# igNJFmt6LAHvH3KSuNLoZLc1Hf2JNMVL4Q1OpbybpMe46YceNA0LfNsnqcnpJeIt
# K/DhKbPxTTuGoX7wJNdoRORVbPR1VVnDuSeHVZlc4seAO+6d2sC26/PQPdP51ho1
# zBp+xUIZkpSFA8vWdoUoHLWnqWU3dCCyFG1roSrgHjSHlq8xymLnjCbSLZ49kPmk
# 8iyyizNDIXj//cOgrY7rlRyTlaCCfw7aSUROwnu7zER6EaJ+AliL7ojTdS5PWPsW
# eupWs7NpChUk555K096V1hE0yZIXe+giAwW00aHzrDchIc2bQhpp0IoKRR7YufAk
# prxMiXAJQ1XCmnCfgPf8+3mnAgMBAAGjggGVMIIBkTAMBgNVHRMBAf8EAjAAMB0G
# A1UdDgQWBBTkO/zyMe39/dfzkXFjGVBDz2GM6DAfBgNVHSMEGDAWgBTvb1NK6eQG
# fHrK4pBW9i/USezLTjAOBgNVHQ8BAf8EBAMCB4AwFgYDVR0lAQH/BAwwCgYIKwYB
# BQUHAwgwgZUGCCsGAQUFBwEBBIGIMIGFMCQGCCsGAQUFBzABhhhodHRwOi8vb2Nz
# cC5kaWdpY2VydC5jb20wXQYIKwYBBQUHMAKGUWh0dHA6Ly9jYWNlcnRzLmRpZ2lj
# ZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFtcGluZ1JTQTQwOTZTSEEy
# NTYyMDI1Q0ExLmNydDBfBgNVHR8EWDBWMFSgUqBQhk5odHRwOi8vY3JsMy5kaWdp
# Y2VydC5jb20vRGlnaUNlcnRUcnVzdGVkRzRUaW1lU3RhbXBpbmdSU0E0MDk2U0hB
# MjU2MjAyNUNBMS5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZIAYb9bAcB
# MA0GCSqGSIb3DQEBCwUAA4ICAQBlKq3xHCcEua5gQezRCESeY0ByIfjk9iJP2zWL
# pQq1b4URGnwWBdEZD9gBq9fNaNmFj6Eh8/YmRDfxT7C0k8FUFqNh+tshgb4O6Lgj
# g8K8elC4+oWCqnU/ML9lFfim8/9yJmZSe2F8AQ/UdKFOtj7YMTmqPO9mzskgiC3Q
# YIUP2S3HQvHG1FDu+WUqW4daIqToXFE/JQ/EABgfZXLWU0ziTN6R3ygQBHMUBaB5
# bdrPbF6MRYs03h4obEMnxYOX8VBRKe1uNnzQVTeLni2nHkX/QqvXnNb+YkDFkxUG
# tMTaiLR9wjxUxu2hECZpqyU1d0IbX6Wq8/gVutDojBIFeRlqAcuEVT0cKsb+zJNE
# suEB7O7/cuvTQasnM9AWcIQfVjnzrvwiCZ85EE8LUkqRhoS3Y50OHgaY7T/lwd6U
# Arb+BOVAkg2oOvol/DJgddJ35XTxfUlQ+8Hggt8l2Yv7roancJIFcbojBcxlRcGG
# 0LIhp6GvReQGgMgYxQbV1S3CrWqZzBt1R9xJgKf47CdxVRd/ndUlQ05oxYy2zRWV
# FjF7mcr4C34Mj3ocCVccAvlKV9jEnstrniLvUxxVZE/rptb7IRE2lskKPIJgbaP5
# t2nGj/ULLi49xTcBZU8atufk+EMF/cWuiC7POGT75qaL6vdCvHlshtjdNXOCIUjs
# arfNZzGCBg4wggYKAgEBMDIwHjEcMBoGA1UEAwwTVkFEVEVLIENvZGUgU2lnbmlu
# ZwIQEflOMRuxR6pMqkvTSLe5eTANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3
# AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisG
# AQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCAi8VyNDOWr
# hTVZLFv6dbJR4WuYecWwMlZ0wjZOTyjo3TANBgkqhkiG9w0BAQEFAASCAgBNN6V8
# i4BKUGcRioPHcWS5dTsnNRLh1N/qOL/dUJKOMuVHs2xGYTNrQchvFeYxkedL6+88
# nufQIgLwq5vQtyYI+f6lRzSRRTA5g0H9M11B/GIa334aHaceUj12Y1Yaa41sQvLL
# bR9pjkWSFjZ237XNSOeAP9AMhUn4KpLBNTOTsnSt/+jhqEw7/a8j09jEanlhHt9a
# Gmom84oddvhPv3cY0Yw8dITkvCyR1QVFpCVTXqckDI7YLRNBGAzNOlgvZb/77QtE
# OlOuTJ4lVK2ZnAL3xLiq4jB+q6oQQD5vSyIHHtlllsaiG+Jcwhd7+s1jjrreyArK
# SJ13cmcsQkiBzcclTQaSHWMnMAowXc8g4XwschIkOKAmi7C6i5pi6k7TZfC2QzFE
# 3n4rKRAwXeD5IHBNNJwBRKlH80ILLrtDqFgjA/uVqQt6zH+9zsNDjTwZmYZ2d4Vu
# hLVz7WFePYR2YubnpNyrDp0KjD6sQD98HgvQypwrxSdfw53L4mAmAPCu1DGIuT58
# ryuedclDn238a3sNinJRQAAfrxCbdAJm5S7835FmoTWUAd+sHY94a3eTTCFuypCe
# gZOWXScAfHpwqaBP/ewkCTUOaHREvLlLdGIh4y73v97xcQCGaB+Sx3DvS2PyBT4t
# BB21poMCabUl7KGpsJjk3CyeC+61rJT8rmWVOKGCAyYwggMiBgkqhkiG9w0BCQYx
# ggMTMIIDDwIBATB9MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwg
# SW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBpbmcg
# UlNBNDA5NiBTSEEyNTYgMjAyNSBDQTECEAqA7xhLjfEFgtHEdqeVdGgwDQYJYIZI
# AWUDBAIBBQCgaTAYBgkqhkiG9w0BCQMxCwYJKoZIhvcNAQcBMBwGCSqGSIb3DQEJ
# BTEPFw0yNjA1MTkxNzQwMzdaMC8GCSqGSIb3DQEJBDEiBCBogYCipfW8xcIe9jfe
# DcDvW3YWMmS5IBgfuUD2OuOdezANBgkqhkiG9w0BAQEFAASCAgBjgoVptwYUmxOA
# Qg0yz2dKgYuVo+JwH5Dfx0CBDskx7JiReknxFIWg79EPqOhTLF+0J5Yj+mVB2c97
# M0lZ/DYczkoobP6ZVq2YUtxyL7tpW05Or/bly/8xc2HxTpdz71BFkoXwiJt+jE+O
# qkJ0MVjqeD6kgAKUidMJV/8rEMtZvFarjAo3W4H+AKAD0+DBYfSWIvpMv1kaf1Nd
# WBUTvRgaP60oWbCBDDTZ66ExBGxoMScB2LqGd+9xp1eyBm/zNmTDbatiVrk7WxLm
# hGhPHSmHdXsKp2PpzP/UxNVRQmXLE+cPbYIu513x9FRmQctgNZNFqHw8t24uPhd/
# nXbSo6vpF/8ALelZbP0ca0+hM+2PUXcm8mSRC+tVChhH7lONt9/OTx5SwtYL9Go2
# rLSfgwMxVC/X+wZqGDQD+MGRzs0M5TV5PEExc9lDjcnUxKDvrUAfTv/TiVGl/Yw5
# 8uIvUIOmAkrjl5JbBCvhv8h+KBFIS8FtVYSyJcURC0IcL4dw/8mQfIFHDtgSC37r
# MlMi1OtMbHvIURhy5WVif2hsUccmjJ1IwpRePP2oY5n5DI9/hCheLu53rwpeh73k
# A8+cVklfzI2Y3oxenw3QExOMjvo12oBVAv2ykgfyAhHobi1C7XWDw4v5lPoWx2xa
# YF/FzlY44Nbq7DpqAsUrZF3HHwWJWQ==
# SIG # End signature block